<rss xmlns:a10="http://www.w3.org/2005/Atom" version="2.0"><channel><title>I Thought He Came With You</title><link>https://ithoughthecamewithyou.com/</link><description>Robert Ellison's Blog</description><copyright>(c) copyright Robert Ellison</copyright><managingEditor>rob@catfood.net</managingEditor><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/catfoodshapefile-300?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/catfoodshapefile-300?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>code</category><title>Catfood.Shapefile 3.00</title><description>&lt;p&gt;I just released Catfood.Shapefile 3.00 on &lt;a href="https://www.nuget.org/packages/Catfood.Shapefile/"&gt;NuGet&lt;/a&gt; and &lt;a href="https://github.com/abfo/shapefile/releases/tag/3.00"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;There is a breaking change with bounding boxes. The bounding box returned by the library has been broken since the first version. From 3.00 the correct bounding box is returned, but you can opt into the old behavior by passing BoundingBoxConvention.Legacy to the constructor.&lt;/p&gt;
&lt;p&gt;DbfDataReader has replaced OleDb for accessing metadata. This will fix platform support issues for OleDb. It necessitated a move to .NET Standard 2.1, so use Shapefile 2.00 if you need .NET Standard 2.00.&lt;/p&gt;
&lt;p&gt;I added support for missing shapes (M and Z variants mostly), and the library will now handle missing files so you can read shapes with just a .shp file if that&amp;#39;s all you have.&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/catfoodshapefile-300?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-09-07T17:38:37Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/links-for-august-2026?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/links-for-august-2026?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>etc</category><title>Links for August 2026</title><description>&lt;div class="h-entry" id="03f5a27856964a7ab6c3e1215ddd27fe"&gt;&lt;div class="e-content"&gt;&lt;blockquote&gt;&lt;a href="https://www.theguardian.com/science/2026/aug/31/forbidden-planet-was-plutos-2006-demotion-a-big-mistake" title="The Guardian: Forbidden planet: was Pluto’s 2006 demotion a big mistake? clipped to ITHCWY on 2026-08-31"&gt;The Guardian: Forbidden planet: was Pluto’s 2006 demotion a big mistake?&lt;/a&gt;&lt;/blockquote&gt;&lt;p&gt;I've never accepted this. Make Pluto a planet again!  &lt;a href="https://ithoughthecamewithyou.com/tag/pluto?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" class="p-category" rel="nofollow"&gt;#pluto&lt;/a&gt; &lt;/p&gt;&lt;/div&gt;&lt;div&gt;&lt;p&gt;&lt;a class="u-url u-uid" title="Permalink" href="https://ithoughthecamewithyou.com/post/links-for-august-2026#03f5a27856964a7ab6c3e1215ddd27fe?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss"&gt;&amp;#8734;&lt;/a&gt;&lt;a class="u-bridgy-fed" href="https://fed.brid.gy/" rel="nofollow noreferrer noopener" hidden="hidden"&gt;&lt;/a&gt;&lt;time class="dt-published" datetime="2026-08-31T17:03:14Z"&gt;&lt;/time&gt;&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/links-for-august-2026?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-08-31T17:03:14Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/catfood-earth-for-android-451?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/catfood-earth-for-android-451?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>code</category><title>Catfood Earth for Android 4.51</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=Catfood-Earth-for-Android-451-2.webp" title="Catfood Earth for Android 4.51"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=Catfood-Earth-for-Android-451.webp" width="215" height="480" class="u-photo" alt="Catfood Earth for Android 4.51" srcset="/image.axd?picture=Catfood-Earth-for-Android-451.webp, /image.axd?picture=Catfood-Earth-for-Android-451-1.webp 2x, /image.axd?picture=Catfood-Earth-for-Android-451-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Catfood Earth for Android 4.51 is now available on &lt;a href="https://play.google.com/store/apps/details?id=net.catfood.earth"&gt;Google Play&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The big change is parallax scrolling. If your launcher has multiple pages Earth will now scroll as you swipe. You can control the size of the effect, and you can go back to the original behavior of a single screen-wide image if that&amp;#39;s what you prefer.&lt;/p&gt;
&lt;p&gt;A cloud selector now allows you to easily move between Global IR and a Global Composite without needing a custom clouds URL. The Global Composite has more detail in daylight areas at the expense of some artifacts from the merging. This is now the default. Global IR is smoother but less detailed.&lt;/p&gt;
&lt;p&gt;Location updates every five minutes now instead of hourly, so if you use You Are Here and/or center on your current location this now updates faster. It bothered me that this took a while after a flight. I have also generally tidied up settings to improve clarity and make it easier to configure Earth.&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/catfood-earth-for-android-451?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-08-31T00:18:16Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/many-birds-in-santa-cruz?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/many-birds-in-santa-cruz?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>photo</category><title>Many birds in Santa Cruz</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=Photo-of-many-birds-surrounding-a-boat-in-Santa-Cruz-2.webp" title="Photo of many birds surrounding a boat in Santa Cruz"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=Photo-of-many-birds-surrounding-a-boat-in-Santa-Cruz.webp" width="630" height="358" class="u-photo" alt="Photo of many birds surrounding a boat in Santa Cruz" srcset="/image.axd?picture=Photo-of-many-birds-surrounding-a-boat-in-Santa-Cruz.webp, /image.axd?picture=Photo-of-many-birds-surrounding-a-boat-in-Santa-Cruz-1.webp 2x, /image.axd?picture=Photo-of-many-birds-surrounding-a-boat-in-Santa-Cruz-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;&lt;p class="exif"&gt;Google Pixel 11 Pro XL 20mm f2.8 1/120s ISO253&lt;/p&gt;
&lt;p&gt;Photo of a large number of birds surrounding a boat in Santa Cruz.&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/many-birds-in-santa-cruz?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-08-29T17:10:31Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/track-flights-to-google-sheets?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/track-flights-to-google-sheets?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>code</category><title>Track Flights to Google Sheets</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=Flight-track-from-Paris-to-San-Francisco-2.webp" title="Flight track from Paris to San Francisco"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=Flight-track-from-Paris-to-San-Francisco.webp" width="630" height="315" class="u-photo" alt="Flight track from Paris to San Francisco" srcset="/image.axd?picture=Flight-track-from-Paris-to-San-Francisco.webp, /image.axd?picture=Flight-track-from-Paris-to-San-Francisco-1.webp 2x, /image.axd?picture=Flight-track-from-Paris-to-San-Francisco-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I built my ideal flight tracker from bitter experience. Planes, of course, leave late or arrive early. They often drop off tracking for an hour or more, especially on polar routes. You might also forget to download a track while exhausted after a long trip and then it&amp;#39;s unavailable by the time you remember to go back and look at it.&lt;/p&gt;
&lt;p&gt;This Google Apps Script project uses the FlightAware API to make this a hands off process. You just put your flights in a spreadsheet in advance and come home to a folder of tracking data. If a flight stops tracking, the script projects its path on a great circle route to the destination airport. When tracking comes back online the script recomputes the projected data to the new good location, leaving you with the best possible version of the data.&lt;/p&gt;
&lt;p&gt;(I&amp;#39;m about to leave on a trip that will take me all around the planet - the third time I will have done this, and hopefully the first with an excessively accurate GPS track as a souvenir).&lt;/p&gt;
&lt;p&gt;In Google Drive create a new spreadsheet with a Flights tab, and a folder to store the flight tracks. Sign up for the &lt;a href="https://www.flightaware.com/commercial/aeroapi/"&gt;FlightAware AeroAPI&lt;/a&gt; and grab an API key. Check that you&amp;#39;re comfortable with the pricing - I find it&amp;#39;s pretty reasonable for personal use. Once that&amp;#39;s ready, create a Google Apps Script project and paste the following script into Code.gs:&lt;/p&gt;
&lt;script src="https://gist.github.com/abfo/1fea5f717b425e8c5799fda41b275669.js"&gt;&lt;/script&gt;
&lt;noscript&gt;&lt;pre&gt;&lt;code&gt;/**
 * Flight Logger
 * =============
 *
 * Runs every 10 minutes, works out whether one of your flights is in the air right now, and
 * appends its track to a Google Sheet dedicated to that flight.
 *
 * Each poll fetches the flight&amp;#39;s WHOLE track so far, not just the latest fix, and writes whatever
 * is new. So the log ends up at FlightAware&amp;#39;s own resolution (roughly a position a minute) rather
 * than one row per ten-minute tick, and a missed or failed tick costs you nothing - the next one
 * backfills it. Adding a flight halfway through backfills the part you missed too.
 *
 * Setup
 * -----
 *   1. Paste this whole file into a new Apps Script project (script.google.com).
 *   2. Fill in the blanks in the Configuration block below.
 *   3. Run setUp() once. Accept the OAuth prompts. It creates the config tab (with headers and a
 *      sample row) and installs the 10-minute trigger.
 *   4. Add your flights to the config tab. Delete the sample row.
 *
 * Configuration spreadsheet schema
 * --------------------------------
 * One tab (CONFIG_SHEET_NAME, default &amp;quot;Flights&amp;quot;) with a header row. Columns are matched by header
 * NAME, so the order is up to you and any extra columns of your own are left alone.
 *
 * You fill in:
 *
 *   Flight     (required) Flight ident, e.g. BAW285, BA285, UAL1. ICAO (&amp;quot;BAW285&amp;quot;) resolves most
 *                         reliably, but IATA works too.
 *   Departure  (required) Scheduled departure, ISO 8601 TEXT with a UTC offset:
 *                         2026-08-10T20:00:00-07:00
 *                         Monitoring starts at this time. Format the column as plain text
 *                         (Format &amp;gt; Number &amp;gt; Plain text) so Sheets does not turn it into a date -
 *                         setUp() does this for you.
 *   Arrival    (required) Scheduled arrival, same format, in the ARRIVAL city&amp;#39;s offset:
 *                         2026-08-12T02:00:00+05:30
 *   Label      (optional) Friendly name used for the output spreadsheet, e.g. SFO-BLR.
 *   Origin     (optional) Departure airport, IATA or ICAO: SFO or KSFO.
 *   Destination(optional) Arrival airport, same. Fill these in when one flight number flies more
 *                         than one leg that day - UA1561 might operate an inbound into SFO and then
 *                         SFO-SAN a few hours later. Without them the right leg is picked by
 *                         whichever scheduled departure is closest to yours, which is correct as
 *                         long as your time is roughly right; with them it is exact. Worth filling
 *                         in on any tight rotation.
 *
 * The script fills in (leave blank):
 *
 *   Complete    1 once the flight is finished. That row is then never looked at again.
 *   FlightId    Cached FlightAware fa_flight_id, so the ident is only resolved once.
 *   MaxAltFt    Highest altitude seen so far. Informational - the landing test recomputes it from
 *               the track every time rather than trusting this cell.
 *   Lat         Where the aircraft was as of the last poll: latitude, longitude, and altitude in
 *   Lon         feet. Refreshed every tick while the flight is being tracked, so the config sheet
 *   AltFt       shows the current position without opening the log. All three are set to 0 when
 *               the flight is marked Complete.
 *   SheetUrl    Link to this flight&amp;#39;s output spreadsheet.
 *   Rows        Number of positions logged so far.
 *   LastChecked ISO timestamp of the most recent poll.
 *   Status      What happened last time: waiting for position / en route ... / landed / cancelled /
 *               timed out / an error message.
 *
 * How a flight starts and stops
 * -----------------------------
 * A row is monitored from its scheduled Departure onwards, so a late pushback is fine.
 *
 * Deciding it has LANDED is the part that has to be careful, because a single bad altitude reading
 * must never truncate the log. Two things have to be true before any altitude-based test is even
 * considered:
 *
 *   - the aircraft has genuinely flown - the track has to contain a fix above AIRBORNE_ALTITUDE_FT
 *     (2,500 ft by default), which is well clear of the 100 ft ground threshold. A fix at 80 ft on
 *     the takeoff roll, or a spurious low reading during the climb, cannot make the flight look
 *     like it is over, because at that point nothing has been above 2,500 ft yet;
 *   - it has stayed down - the track must end in an unbroken run of on-the-ground fixes, at least
 *     GROUND_FIX_COUNT of them (3) and covering at least GROUND_FIX_MINUTES (5). One glitchy 0 ft
 *     fix in the middle of a cruise fails this immediately, because the run breaks at the fix
 *     before it; a brief spell at ground level, like a rejected takeoff, fails the timespan.
 *
 * Given that, the flight is marked Complete when any of:
 *
 *   - FlightAware reports actual_on (runway arrival)         -&amp;gt; landed. Authoritative, and immune
 *                                                                to bad position data entirely.
 *   - it has flown, and has settled on the ground            -&amp;gt; landed (the two tests above)
 *   - FlightAware reports the flight as cancelled            -&amp;gt; cancelled
 *   - 24 hours have passed since scheduled Departure         -&amp;gt; timed out
 *
 * The last one is the hard safety net: nothing logs forever. A confirmed landing ends the log
 * whether it happens early or six hours late. Several flights can be live in the same tick - each
 * row is processed independently, and one failing row never stops the others.
 *
 * Tracking gaps
 * -------------
 * A gap in the feed is never treated as evidence of anything. Polar routes routinely go quiet for
 * an hour or more, and an oceanic crossing can too, so the script assumes positions will start
 * arriving again and just keeps waiting. Nothing here times a flight out for going silent; only
 * the 24 hour cap does, and only measured from scheduled departure.
 *
 * The log fills those gaps in rather than leaving a hole. Every row on the Track sheet carries a
 * TrackerStatus saying where it came from:
 *
 *   FLIGHTAWARE   a real fix, exactly as the API reported it. Never edited once written.
 *   PROJECTED     a guess. Once the feed has been quiet for TRACK_GAP_MINUTES, a fix is added
 *                 every SYNTHETIC_FIX_MINUTES, walking the great circle from the last real fix
 *                 toward the destination airport at the last known groundspeed. This is the best
 *                 estimate available while the aircraft is out of contact.
 *   INTERPOLATED  a better guess, made in hindsight. When the feed comes back, every projection
 *                 in the gap is rewritten to sit on the great circle between the real fix that
 *                 opened the gap and the real fix that closed it, spaced evenly by time.
 *
 * So a dropout produces PROJECTED rows while it lasts, and those same rows become INTERPOLATED as
 * soon as there is a fix on the far side to aim at. This happens as many times as the feed drops.
 * Destination coordinates come from airports.js, which must be pasted into the project alongside
 * this file; without it, or without the Destination column filled in, gaps are simply left empty.
 *
 * Synthetic rows never influence anything that matters. The landing tests, the altitude tests and
 * MaxAltFt all run on real fixes only - a projection cannot land an aeroplane, mark a flight
 * complete, or invent a maximum altitude. They exist to make the track drawable.
 *
 * The one consequence worth knowing: if the feed dies during the descent and never posts a single
 * on-the-ground fix, the altitude tests have nothing to work with. Past the scheduled arrival the
 * script starts asking FlightAware directly each tick, so such a flight normally still finishes on
 * actual_on. Failing even that, it finishes on the 24 hour cap.
 *
 * If a flight never climbs above AIRBORNE_ALTITUDE_FT (a very short low-level hop), the altitude
 * tests likewise never fire and it finishes on actual_on, or failing that on the cap. That is the
 * safe direction to fail in: a few wasted queries rather than a truncated track.
 *
 * AeroAPI cost
 * ------------
 * AeroAPI bills per query. Normal running is one query per active flight per tick (6/hour), plus
 * one to resolve the ident, one when the output sheet is created, and one or two to confirm the
 * landing. A 12-hour flight is roughly 75 queries. Fetching the whole track rather than the last
 * position is still one query, but it is a much larger response - check the current AeroAPI
 * pricing page if you are close to a tier boundary.
 */

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

// FlightAware AeroAPI v4 key (flightaware.com/aeroapi).
const FLIGHTAWARE_API_KEY = &amp;#39;&amp;#39;;

// ID of the spreadsheet holding your flight configuration - the long string in its URL,
// https://docs.google.com/spreadsheets/d/THIS_BIT/edit
const CONFIG_SPREADSHEET_ID = &amp;#39;&amp;#39;;

// Tab within that spreadsheet.
const CONFIG_SHEET_NAME = &amp;#39;Flights&amp;#39;;

// ID of the Drive folder that per-flight spreadsheets get created in - the last part of the
// folder URL, https://drive.google.com/drive/folders/THIS_BIT
const OUTPUT_FOLDER_ID = &amp;#39;&amp;#39;;

// How often the trigger fires. Apps Script only allows 1, 5, 10, 15 or 30.
const CHECK_INTERVAL_MINUTES = 10;

// Give up on a flight this long after its scheduled departure, whatever else is happening.
const MAX_MONITOR_HOURS = 24;

// At or below this altitude the aircraft counts as being on the ground.
const GROUND_ALTITUDE_FT = 100;

// The aircraft has to get above this before any altitude-based landing test is allowed to fire.
// Keep it well clear of GROUND_ALTITUDE_FT - that gap is what stops a low fix just after takeoff,
// or a bad reading during the climb, from looking like an arrival.
const AIRBORNE_ALTITUDE_FT = 2500;

// How many consecutive on-the-ground fixes, spanning how long, before we call it a landing. This
// is what a single glitchy 0 ft fix has to get past, and cannot.
const GROUND_FIX_COUNT = 3;
const GROUND_FIX_MINUTES = 5;

// Coverage gaps. Once the feed has been silent this long, the log starts carrying synthetic fixes
// so the track stays continuous across a polar or oceanic dropout.
const TRACK_GAP_MINUTES = 5;

// How often a synthetic fix is emitted while filling a gap.
const SYNTHETIC_FIX_MINUTES = 5;

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const AEROAPI_BASE = &amp;#39;https://aeroapi.flightaware.com/aeroapi/&amp;#39;;

const CONFIG_HEADERS = [
  &amp;#39;Flight&amp;#39;, &amp;#39;Departure&amp;#39;, &amp;#39;Arrival&amp;#39;, &amp;#39;Label&amp;#39;, &amp;#39;Origin&amp;#39;, &amp;#39;Destination&amp;#39;,
  &amp;#39;Complete&amp;#39;, &amp;#39;FlightId&amp;#39;, &amp;#39;MaxAltFt&amp;#39;, &amp;#39;Lat&amp;#39;, &amp;#39;Lon&amp;#39;, &amp;#39;AltFt&amp;#39;,
  &amp;#39;SheetUrl&amp;#39;, &amp;#39;Rows&amp;#39;, &amp;#39;LastChecked&amp;#39;, &amp;#39;Status&amp;#39;
];

const REQUIRED_HEADERS = [&amp;#39;Flight&amp;#39;, &amp;#39;Departure&amp;#39;, &amp;#39;Arrival&amp;#39;, &amp;#39;Complete&amp;#39;];

const TRACK_SHEET_NAME = &amp;#39;Track&amp;#39;;
const INFO_SHEET_NAME = &amp;#39;Info&amp;#39;;

const TRACK_HEADERS = [
  &amp;#39;Timestamp (UTC)&amp;#39;, &amp;#39;Timestamp&amp;#39;, &amp;#39;Latitude&amp;#39;, &amp;#39;Longitude&amp;#39;,
  &amp;#39;Altitude (ft)&amp;#39;, &amp;#39;Altitude (FL)&amp;#39;, &amp;#39;Groundspeed (kt)&amp;#39;, &amp;#39;Heading&amp;#39;, &amp;#39;Update type&amp;#39;, &amp;#39;TrackerStatus&amp;#39;
];

// Column indexes within a Track row, zero based.
const TRACK_COL_ISO = 0;
const TRACK_COL_LAT = 2;
const TRACK_COL_LON = 3;
const TRACK_COL_ALT_FT = 4;
const TRACK_COL_SPEED = 6;
const TRACK_COL_HEADING = 7;
const TRACK_COL_UPDATE = 8;
const TRACK_COL_TRACKER = 9;

// TrackerStatus values.
const TRACKER_LIVE = &amp;#39;FLIGHTAWARE&amp;#39;;         // a real fix, as reported by the API
const TRACKER_PROJECTED = &amp;#39;PROJECTED&amp;#39;;      // a guess ahead of the last fix, toward the destination
const TRACKER_INTERPOLATED = &amp;#39;INTERPOLATED&amp;#39;; // a guess between two real fixes, now that both are known

const EARTH_RADIUS_NM = 3440.065;

// ISO 8601 with a mandatory UTC offset (Z, +05:30 or +0530).
const ISO_WITH_OFFSET = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|z|[+-]\d{2}:?\d{2})$/;

const MS_PER_MINUTE = 60 * 1000;
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
const MS_PER_DAY = 24 * MS_PER_HOUR;

// ---------------------------------------------------------------------------
// Entry points
// ---------------------------------------------------------------------------

/**
 * The trigger target. Polls every flight that is due to be monitored.
 */
function logFlights() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(30000)) {
    console.log(&amp;#39;Another run is still going; skipping this tick.&amp;#39;);
    return;
  }

  try {
    const sheet = getConfigSheet();
    const rows = readConfigRows(sheet);
    const now = new Date();
    let checked = 0;

    rows.forEach(function (row) {
      try {
        if (processFlight(row, now)) checked++;
      } catch (err) {
        const message = (err &amp;amp;&amp;amp; err.message) ? err.message : String(err);
        row.set(&amp;#39;Status&amp;#39;, &amp;#39;ERROR: &amp;#39; + message);
        row.set(&amp;#39;LastChecked&amp;#39;, toIsoUtc(now));
        console.error(&amp;#39;Row %s (%s): %s&amp;#39;, row.rowNumber, row.get(&amp;#39;Flight&amp;#39;), message);
      } finally {
        row.flush();
      }
    });

    console.log(&amp;#39;Checked %s active flight(s) of %s configured.&amp;#39;, checked, rows.length);
  } finally {
    lock.releaseLock();
  }
}

/**
 * Run once by hand. Creates the config tab if it is missing and installs the trigger.
 */
function setUp() {
  if (!CONFIG_SPREADSHEET_ID) {
    throw new Error(&amp;#39;Set CONFIG_SPREADSHEET_ID at the top of this script first.&amp;#39;);
  }

  const spreadsheet = SpreadsheetApp.openById(CONFIG_SPREADSHEET_ID);
  let sheet = spreadsheet.getSheetByName(CONFIG_SHEET_NAME);

  if (!sheet) {
    sheet = spreadsheet.insertSheet(CONFIG_SHEET_NAME);
  }

  if (sheet.getLastRow() === 0) {
    sheet.getRange(1, 1, 1, CONFIG_HEADERS.length)
      .setValues([CONFIG_HEADERS])
      .setFontWeight(&amp;#39;bold&amp;#39;);
    sheet.setFrozenRows(1);

    // Departure and Arrival must stay text, or Sheets eats the UTC offset.
    const departureColumn = CONFIG_HEADERS.indexOf(&amp;#39;Departure&amp;#39;) + 1;
    const arrivalColumn = CONFIG_HEADERS.indexOf(&amp;#39;Arrival&amp;#39;) + 1;
    sheet.getRange(2, departureColumn, sheet.getMaxRows() - 1, 1).setNumberFormat(&amp;#39;@&amp;#39;);
    sheet.getRange(2, arrivalColumn, sheet.getMaxRows() - 1, 1).setNumberFormat(&amp;#39;@&amp;#39;);

    sheet.appendRow([
      &amp;#39;BAW285&amp;#39;,
      &amp;#39;2026-08-10T20:00:00-07:00&amp;#39;,
      &amp;#39;2026-08-12T02:00:00+05:30&amp;#39;,
      &amp;#39;SFO-BLR (sample - delete me)&amp;#39;,
      &amp;#39;SFO&amp;#39;,
      &amp;#39;BLR&amp;#39;
    ]);
    sheet.autoResizeColumns(1, CONFIG_HEADERS.length);
    console.log(&amp;#39;Created the &amp;quot;%s&amp;quot; tab with a sample row.&amp;#39;, CONFIG_SHEET_NAME);
  } else {
    console.log(&amp;#39;The &amp;quot;%s&amp;quot; tab already exists; leaving it alone.&amp;#39;, CONFIG_SHEET_NAME);
  }

  installTrigger();
}

/**
 * Installs the recurring trigger, replacing any existing one.
 */
function installTrigger() {
  removeTriggers();
  ScriptApp.newTrigger(&amp;#39;logFlights&amp;#39;)
    .timeBased()
    .everyMinutes(CHECK_INTERVAL_MINUTES)
    .create();
  console.log(&amp;#39;Trigger installed: logFlights every %s minutes.&amp;#39;, CHECK_INTERVAL_MINUTES);
}

/**
 * Removes every trigger pointing at logFlights.
 */
function removeTriggers() {
  ScriptApp.getProjectTriggers().forEach(function (trigger) {
    if (trigger.getHandlerFunction() === &amp;#39;logFlights&amp;#39;) {
      ScriptApp.deleteTrigger(trigger);
    }
  });
}

// ---------------------------------------------------------------------------
// Per-flight processing
// ---------------------------------------------------------------------------

/**
 * Polls one configured flight. Returns true if it was due for a check this tick.
 */
function processFlight(row, now) {
  if (isComplete(row)) return false;

  const ident = trimmed(row.get(&amp;#39;Flight&amp;#39;));
  if (!ident) return false; // blank row

  const departure = parseIsoDate(row.get(&amp;#39;Departure&amp;#39;), &amp;#39;Departure&amp;#39;, ident);
  const arrival = parseIsoDate(row.get(&amp;#39;Arrival&amp;#39;), &amp;#39;Arrival&amp;#39;, ident);

  if (now &amp;lt; departure) return false; // not time yet

  row.set(&amp;#39;LastChecked&amp;#39;, toIsoUtc(now));

  const deadline = new Date(departure.getTime() + MAX_MONITOR_HOURS * MS_PER_HOUR);

  // Resolve the ident to a FlightAware flight id, once, and cache it.
  let faFlightId = trimmed(row.get(&amp;#39;FlightId&amp;#39;));
  let resolved = null;

  if (!faFlightId) {
    const origin = trimmed(row.get(&amp;#39;Origin&amp;#39;));
    const destination = trimmed(row.get(&amp;#39;Destination&amp;#39;));
    const route = describeRoute(origin, destination);

    resolved = resolveFlight(ident, departure, origin, destination);
    if (!resolved) {
      if (now &amp;gt; deadline) {
        return finish(row, &amp;#39;timed out&amp;#39;, &amp;#39;FlightAware never listed &amp;#39; + ident + route);
      }
      row.set(&amp;#39;Status&amp;#39;, &amp;#39;waiting for FlightAware to list &amp;#39; + ident + route);
      return true;
    }
    faFlightId = resolved.fa_flight_id;
    row.set(&amp;#39;FlightId&amp;#39;, faFlightId);
  }

  // The whole track every time - so the log fills in at FlightAware&amp;#39;s resolution rather than ours,
  // and a tick we miss is picked up by the next one.
  const positions = fetchTrack(faFlightId);

  let newestTracker = TRACKER_LIVE;

  if (positions &amp;amp;&amp;amp; positions.length) {
    const track = getOrCreateTrackSheet(row, ident, faFlightId, departure, arrival, resolved);
    const series = syncTrackSheet(track, positions, destinationCoords(row), now);

    if (series.length) {
      row.set(&amp;#39;Rows&amp;#39;, series.length);
      // The row shows the best current estimate, synthetic or not, so it always agrees with the
      // bottom of the log.
      recordLastPosition(row, series);
      newestTracker = series[series.length - 1].tracker;
    }
    row.set(&amp;#39;MaxAltFt&amp;#39;, maxAltitudeFt(positions));
  }

  // Only ever real fixes decide whether the flight is over. A projection must never be able to
  // land an aeroplane.
  const stillActive = evaluateFlight(row, faFlightId, positions, now, arrival, deadline);

  // Say so when the position on the row is a guess rather than a report.
  if (stillActive &amp;amp;&amp;amp; !isComplete(row) &amp;amp;&amp;amp; newestTracker !== TRACKER_LIVE) {
    row.set(&amp;#39;Status&amp;#39;, row.get(&amp;#39;Status&amp;#39;) + &amp;#39; [&amp;#39; + newestTracker + &amp;#39;]&amp;#39;);
  }

  return stillActive;
}

/**
 * Decides whether the flight is over, and records what we think is going on.
 *
 * Everything here is recomputed from the track on every tick, so no stored flag can get stuck in a
 * state that ends the log early.
 */
function evaluateFlight(row, faFlightId, positions, now, arrival, deadline) {
  const pastArrival = now &amp;gt; arrival;
  const latest = (positions &amp;amp;&amp;amp; positions.length) ? positions[positions.length - 1] : null;

  // Has it actually flown? Not &amp;quot;has it left the ground&amp;quot; - has it climbed well clear of it.
  const hasFlown = !!positions &amp;amp;&amp;amp; maxAltitudeFt(positions) &amp;gt;= AIRBORNE_ALTITUDE_FT;

  const onGround = !!latest &amp;amp;&amp;amp; latest.altitudeFt !== null &amp;amp;&amp;amp; latest.altitudeFt &amp;lt;= GROUND_ALTITUDE_FT;
  const settled = hasFlown &amp;amp;&amp;amp; isSettledOnGround(positions);

  // Ask FlightAware for the flight&amp;#39;s own view of things only when the flight could plausibly be
  // over: it has flown and is down, or the schedule says it should have arrived by now. A gap in
  // the feed is never a reason on its own - see the note on tracking gaps below. That keeps the
  // steady-state cost at one billed query per tick.
  const needStatus = (hasFlown &amp;amp;&amp;amp; (settled || onGround)) || pastArrival;

  if (needStatus) {
    const status = fetchFlightStatus(faFlightId);
    if (status) {
      if (status.cancelled) {
        return finish(row, &amp;#39;cancelled&amp;#39;, &amp;#39;FlightAware reports this flight as cancelled&amp;#39;);
      }
      // A runway arrival time is authoritative and needs no help from the altitude readings.
      if (status.actual_on) {
        return finish(row, &amp;#39;landed&amp;#39;, &amp;#39;touchdown &amp;#39; + status.actual_on);
      }
    }
  }

  // It flew, and it has now been on the ground for several consecutive fixes. One bad reading
  // cannot get here, because the fixes either side of it would still be at altitude.
  if (settled) {
    return finish(row, &amp;#39;landed&amp;#39;, &amp;#39;on the ground for more than &amp;#39; + GROUND_FIX_MINUTES + &amp;#39; minutes&amp;#39;);
  }

  if (now &amp;gt; deadline) {
    return finish(row, &amp;#39;timed out&amp;#39;, &amp;#39;more than &amp;#39; + MAX_MONITOR_HOURS + &amp;#39; hours since scheduled departure&amp;#39;);
  }

  row.set(&amp;#39;Status&amp;#39;, describeStatus(latest, hasFlown, onGround, pastArrival));
  return true;
}

/**
 * True when the track ends in an unbroken run of on-the-ground fixes that is both long enough to
 * count (GROUND_FIX_COUNT) and covers enough time (GROUND_FIX_MINUTES).
 *
 * Note it walks back over however many consecutive ground fixes there are, rather than looking at a
 * fixed number of them. That matters: FlightAware posts roughly a fix a minute, so any fixed window
 * of three fixes could only ever span two minutes and the time test could never pass. Walking the
 * run also copes with a sparse feed, where three fixes might cover fifteen minutes.
 *
 * Both halves earn their place. The count stops a lone bad reading - the run breaks at the fix
 * before it. The timespan stops a brief spell at ground level, like a rejected takeoff.
 */
function isSettledOnGround(positions) {
  if (!positions || !positions.length) return false;

  const newest = positions[positions.length - 1];
  let count = 0;
  let oldestInRun = null;

  for (let i = positions.length - 1; i &amp;gt;= 0; i--) {
    const p = positions[i];
    if (p.altitudeFt === null || p.altitudeFt &amp;gt; GROUND_ALTITUDE_FT) break;
    count++;
    oldestInRun = p;
  }

  if (count &amp;lt; GROUND_FIX_COUNT) return false;

  const span = newest.timestamp.getTime() - oldestInRun.timestamp.getTime();
  return span &amp;gt;= GROUND_FIX_MINUTES * MS_PER_MINUTE;
}

/**
 * Copies the aircraft&amp;#39;s latest position onto the config row.
 *
 * Latitude and longitude come from the newest fix. Altitude comes from the newest fix that
 * actually reported one, which is not always the same fix - a position can arrive with no
 * altitude, and blanking the column because of one gappy report would be worse than useless.
 * &amp;quot;Last known&amp;quot;, in other words, rather than &amp;quot;last&amp;quot;.
 */
function recordLastPosition(row, positions) {
  const latest = positions[positions.length - 1];
  row.set(&amp;#39;Lat&amp;#39;, roundTo5(latest.latitude));
  row.set(&amp;#39;Lon&amp;#39;, roundTo5(latest.longitude));

  for (let i = positions.length - 1; i &amp;gt;= 0; i--) {
    if (positions[i].altitudeFt !== null) {
      row.set(&amp;#39;AltFt&amp;#39;, Math.round(positions[i].altitudeFt));
      return;
    }
  }
}

/**
 * Five decimal places is about a metre, and keeps floating point noise out of the sheet.
 */
function roundTo5(value) {
  return Math.round(value * 100000) / 100000;
}

function maxAltitudeFt(positions) {
  let max = 0;
  (positions || []).forEach(function (p) {
    if (p.altitudeFt !== null &amp;amp;&amp;amp; p.altitudeFt &amp;gt; max) max = p.altitudeFt;
  });
  return max;
}

/**
 * Marks a row complete so it is never looked at again.
 */
function finish(row, status, note) {
  row.set(&amp;#39;Complete&amp;#39;, 1);
  row.set(&amp;#39;Status&amp;#39;, note ? status + &amp;#39; - &amp;#39; + note : status);

  // The aircraft is no longer anywhere worth reporting. Zeroing these rather than leaving the last
  // fix behind means a glance down the sheet shows only live flights carrying a position. This runs
  // after recordLastPosition, so the zeroes are what actually get written this tick.
  row.set(&amp;#39;Lat&amp;#39;, 0);
  row.set(&amp;#39;Lon&amp;#39;, 0);
  row.set(&amp;#39;AltFt&amp;#39;, 0);

  console.log(&amp;#39;%s: %s (%s)&amp;#39;, row.get(&amp;#39;Flight&amp;#39;), status, note || &amp;#39;&amp;#39;);
  return true;
}

/**
 * &amp;quot; from SFO to SAN&amp;quot; / &amp;quot; from SFO&amp;quot; / &amp;quot;&amp;quot; - for status messages, so a row that is waiting says which
 * leg it is waiting for.
 */
function describeRoute(origin, destination) {
  if (origin &amp;amp;&amp;amp; destination) return &amp;#39; from &amp;#39; + origin + &amp;#39; to &amp;#39; + destination;
  if (origin) return &amp;#39; from &amp;#39; + origin;
  if (destination) return &amp;#39; to &amp;#39; + destination;
  return &amp;#39;&amp;#39;;
}

function describeStatus(latest, hasFlown, onGround, pastArrival) {
  const late = pastArrival ? &amp;#39; (past scheduled arrival)&amp;#39; : &amp;#39;&amp;#39;;

  if (!latest) {
    return &amp;#39;waiting for position&amp;#39; + late;
  }

  const where = latest.latitude.toFixed(2) + &amp;#39;, &amp;#39; + latest.longitude.toFixed(2);

  if (onGround) {
    return (hasFlown ? &amp;#39;on the ground at &amp;#39; : &amp;#39;on the ground before departure at &amp;#39;) + where + late;
  }

  const altitude = latest.altitudeFt === null
    ? &amp;#39;altitude unknown&amp;#39;
    : Math.round(latest.altitudeFt) + &amp;#39; ft&amp;#39;;
  return (hasFlown ? &amp;#39;en route &amp;#39; : &amp;#39;climbing out &amp;#39;) + where + &amp;#39; at &amp;#39; + altitude + late;
}

// ---------------------------------------------------------------------------
// FlightAware AeroAPI v4
// ---------------------------------------------------------------------------

/**
 * Finds the flight whose scheduled departure is closest to the one configured, and returns the
 * whole flight object (or null if FlightAware does not know about it yet).
 */
function resolveFlight(ident, departure, origin, destination) {
  // The ident search window is capped at 10 days in the past and 2 days into the future.
  const now = Date.now();
  let start = new Date(departure.getTime() - MS_PER_DAY);
  let end = new Date(departure.getTime() + MS_PER_DAY);

  const earliest = new Date(now - 9.5 * MS_PER_DAY);
  const latest = new Date(now + 1.5 * MS_PER_DAY);
  if (start &amp;lt; earliest) start = earliest;
  if (end &amp;gt; latest) end = latest;
  if (start &amp;gt;= end) return null;

  const path = &amp;#39;flights/&amp;#39; + encodeURIComponent(ident) +
    &amp;#39;?start=&amp;#39; + encodeURIComponent(toIsoUtc(start)) +
    &amp;#39;&amp;amp;end=&amp;#39; + encodeURIComponent(toIsoUtc(end));

  const json = aeroApiGet(path);
  const flights = json &amp;amp;&amp;amp; json.flights;
  if (!flights || !flights.length) return null;

  // One flight number often flies several legs in a day - UA1561 might operate an inbound into SFO
  // and then SFO-SAN a few hours later. Picking by departure time alone gets that right as long as
  // the configured time is roughly correct, but on a tight rotation an hour&amp;#39;s error (say the wrong
  // UTC offset) would silently pick the previous leg and log the wrong aeroplane. If Origin and/or
  // Destination are filled in, use them to narrow the field first - that is exact, not a guess.
  let candidates = flights;

  if (origin || destination) {
    candidates = flights.filter(function (flight) {
      return airportMatches(flight.origin, origin) &amp;amp;&amp;amp; airportMatches(flight.destination, destination);
    });
    // Nothing on that route: better to wait and retry than to log a different leg. The row keeps
    // trying each tick and the 24 hour cap ends it if the codes were simply wrong.
    if (!candidates.length) return null;
  }

  let best = null;
  let bestDelta = Infinity;

  candidates.forEach(function (flight) {
    const scheduled = flight.scheduled_out || flight.scheduled_off || flight.actual_out;
    const delta = scheduled
      ? Math.abs(new Date(scheduled).getTime() - departure.getTime())
      : Infinity;
    if (delta &amp;lt; bestDelta) {
      bestDelta = delta;
      best = flight;
    }
  });

  const chosen = best || candidates[0];
  return chosen.fa_flight_id ? chosen : null;
}

/**
 * Does this AeroAPI airport object answer to the code the user typed? Accepts IATA (SFO), ICAO
 * (KSFO) or the LID, in any case. A blank code matches anything - the column is optional.
 */
function airportMatches(airport, code) {
  const want = trimmed(code).toUpperCase();
  if (!want) return true;
  if (!airport) return false;

  const keys = [&amp;#39;code&amp;#39;, &amp;#39;code_iata&amp;#39;, &amp;#39;code_icao&amp;#39;, &amp;#39;code_lid&amp;#39;];
  const reported = [];

  for (let i = 0; i &amp;lt; keys.length; i++) {
    const value = String(airport[keys[i]] || &amp;#39;&amp;#39;).toUpperCase();
    if (!value) continue;
    if (value === want) return true;
    reported.push(value);
  }

  // No literal match. The user may have typed SFO where AeroAPI reported only KSFO, or the other
  // way round, so fall back to asking whether the two codes name the same airport.
  for (let i = 0; i &amp;lt; reported.length; i++) {
    if (sameAirport(reported[i], want)) return true;
  }
  return false;
}

/**
 * Do two airport codes refer to the same place? Compares coordinates via airports.js, which holds
 * both the IATA and the ICAO code for each airport, so SFO and KSFO resolve alike.
 */
function sameAirport(a, b) {
  if (!a || !b) return false;
  if (a.toUpperCase() === b.toUpperCase()) return true;
  if (typeof airportCoords !== &amp;#39;function&amp;#39;) return false;

  const first = airportCoords(a);
  const second = airportCoords(b);
  return !!first &amp;amp;&amp;amp; !!second &amp;amp;&amp;amp; first[0] === second[0] &amp;amp;&amp;amp; first[1] === second[1];
}

/**
 * The flight&amp;#39;s track so far, oldest fix first. Null when FlightAware has nothing yet.
 *
 * Note the deliberate absence of include_estimated_positions: we want where the aircraft has been,
 * not where it is predicted to go. Predicted points would otherwise be written into the log as
 * though they had happened.
 *
 * Altitude comes back in hundreds of feet, so 350 means 35,000 ft.
 */
function fetchTrack(faFlightId) {
  const json = aeroApiGet(&amp;#39;flights/&amp;#39; + encodeURIComponent(faFlightId) + &amp;#39;/track&amp;#39;);
  if (!json || !json.positions || !json.positions.length) return null;

  const positions = [];

  json.positions.forEach(function (p) {
    if (p.latitude == null || p.longitude == null || !p.timestamp) return;

    const altitudeFl = (p.altitude == null || isNaN(Number(p.altitude))) ? null : Number(p.altitude);
    const timestamp = new Date(p.timestamp);
    if (isNaN(timestamp.getTime())) return;

    positions.push({
      timestamp: timestamp,
      timestampRaw: p.timestamp,
      latitude: Number(p.latitude),
      longitude: Number(p.longitude),
      altitudeFl: altitudeFl,
      altitudeFt: altitudeFl === null ? null : altitudeFl * 100,
      groundspeed: p.groundspeed == null ? &amp;#39;&amp;#39; : Number(p.groundspeed),
      heading: p.heading == null ? &amp;#39;&amp;#39; : Number(p.heading),
      updateType: p.update_type || &amp;#39;&amp;#39;
    });
  });

  if (!positions.length) return null;

  positions.sort(function (a, b) { return a.timestamp.getTime() - b.timestamp.getTime(); });
  return positions;
}

/**
 * The flight object itself - actual_off, actual_on, cancelled, status and so on.
 */
function fetchFlightStatus(faFlightId) {
  const json = aeroApiGet(&amp;#39;flights/&amp;#39; + encodeURIComponent(faFlightId) + &amp;#39;?ident_type=fa_flight_id&amp;#39;);
  if (!json || !json.flights || !json.flights.length) return null;
  return json.flights[0];
}

/**
 * GETs an AeroAPI path. Returns the parsed body, or null for a 404 (nothing to report yet).
 */
function aeroApiGet(path) {
  if (!FLIGHTAWARE_API_KEY) {
    throw new Error(&amp;#39;FLIGHTAWARE_API_KEY is not set at the top of this script.&amp;#39;);
  }

  const response = UrlFetchApp.fetch(AEROAPI_BASE + path, {
    method: &amp;#39;get&amp;#39;,
    headers: { &amp;#39;x-apikey&amp;#39;: FLIGHTAWARE_API_KEY, &amp;#39;Accept&amp;#39;: &amp;#39;application/json&amp;#39; },
    muteHttpExceptions: true
  });

  const code = response.getResponseCode();
  const body = response.getContentText();

  if (code === 404) return null;
  if (code === 401 || code === 403) {
    throw new Error(&amp;#39;FlightAware rejected the API key (HTTP &amp;#39; + code + &amp;#39;). Check FLIGHTAWARE_API_KEY.&amp;#39;);
  }
  if (code === 429) {
    throw new Error(&amp;#39;FlightAware rate limit hit (HTTP 429); will retry next tick.&amp;#39;);
  }
  if (code &amp;lt; 200 || code &amp;gt;= 300) {
    throw new Error(&amp;#39;AeroAPI &amp;#39; + path + &amp;#39; failed: HTTP &amp;#39; + code + &amp;#39; &amp;#39; + body.slice(0, 300));
  }

  return JSON.parse(body);
}

// ---------------------------------------------------------------------------
// Per-flight output spreadsheet
// ---------------------------------------------------------------------------

/**
 * Opens this flight&amp;#39;s Track sheet, creating the spreadsheet in OUTPUT_FOLDER_ID the first time.
 */
function getOrCreateTrackSheet(row, ident, faFlightId, departure, arrival, resolved) {
  const existingUrl = trimmed(row.get(&amp;#39;SheetUrl&amp;#39;));

  if (existingUrl) {
    const id = spreadsheetIdFromUrl(existingUrl);
    if (id) {
      try {
        const sheet = SpreadsheetApp.openById(id).getSheetByName(TRACK_SHEET_NAME);
        if (sheet) return sheet;
      } catch (err) {
        console.warn(&amp;#39;Could not reopen %s (%s); creating a fresh log.&amp;#39;, existingUrl, err);
      }
    }
  }

  const label = trimmed(row.get(&amp;#39;Label&amp;#39;)) || ident;
  const name = &amp;#39;Flight Log - &amp;#39; + label + &amp;#39; - &amp;#39; + Utilities.formatDate(departure, &amp;#39;UTC&amp;#39;, &amp;#39;yyyy-MM-dd&amp;#39;);
  const spreadsheet = SpreadsheetApp.create(name);

  const track = spreadsheet.getActiveSheet();
  track.setName(TRACK_SHEET_NAME);
  track.getRange(1, 1, 1, TRACK_HEADERS.length)
    .setValues([TRACK_HEADERS])
    .setFontWeight(&amp;#39;bold&amp;#39;);
  track.setFrozenRows(1);
  track.getRange(&amp;#39;A:A&amp;#39;).setNumberFormat(&amp;#39;@&amp;#39;);                    // keep the ISO string a string
  track.getRange(&amp;#39;B:B&amp;#39;).setNumberFormat(&amp;#39;yyyy-mm-dd hh:mm:ss&amp;#39;);
  track.getRange(&amp;#39;C:D&amp;#39;).setNumberFormat(&amp;#39;0.00000&amp;#39;);

  writeInfoSheet(spreadsheet, ident, faFlightId, departure, arrival,
    resolved || fetchFlightStatus(faFlightId));

  if (OUTPUT_FOLDER_ID) {
    DriveApp.getFileById(spreadsheet.getId()).moveTo(DriveApp.getFolderById(OUTPUT_FOLDER_ID));
  }

  row.set(&amp;#39;SheetUrl&amp;#39;, spreadsheet.getUrl());
  console.log(&amp;#39;Created log for %s: %s&amp;#39;, ident, spreadsheet.getUrl());
  return track;
}

function writeInfoSheet(spreadsheet, ident, faFlightId, departure, arrival, flight) {
  const info = spreadsheet.insertSheet(INFO_SHEET_NAME);
  const f = flight || {};
  const origin = f.origin || {};
  const destination = f.destination || {};

  const values = [
    [&amp;#39;Flight&amp;#39;, ident],
    [&amp;#39;FlightAware id&amp;#39;, faFlightId],
    [&amp;#39;Origin&amp;#39;, origin.code || origin.code_iata || &amp;#39;&amp;#39;],
    [&amp;#39;Destination&amp;#39;, destination.code || destination.code_iata || &amp;#39;&amp;#39;],
    [&amp;#39;Scheduled departure (configured)&amp;#39;, toIsoUtc(departure)],
    [&amp;#39;Scheduled arrival (configured)&amp;#39;, toIsoUtc(arrival)],
    [&amp;#39;Scheduled out (FlightAware)&amp;#39;, f.scheduled_out || &amp;#39;&amp;#39;],
    [&amp;#39;Scheduled on (FlightAware)&amp;#39;, f.scheduled_on || &amp;#39;&amp;#39;],
    [&amp;#39;Aircraft type&amp;#39;, f.aircraft_type || &amp;#39;&amp;#39;],
    [&amp;#39;Registration&amp;#39;, f.registration || &amp;#39;&amp;#39;],
    [&amp;#39;Log created&amp;#39;, toIsoUtc(new Date())]
  ];

  info.getRange(1, 1, values.length, 2).setValues(values);
  info.getRange(1, 1, values.length, 1).setFontWeight(&amp;#39;bold&amp;#39;);
  info.autoResizeColumns(1, 2);
}

/**
 * Brings the Track sheet up to date and returns the full series that is now on it.
 *
 * The sheet is treated as: every real fix ever seen, plus synthetic fixes filling any gap longer
 * than TRACK_GAP_MINUTES. Both halves are recomputed from scratch each tick, which is what makes
 * the patching work - a projection made while blind simply stops being a projection once the fix
 * that closes the gap arrives.
 *
 * Real fixes are read back off the sheet and merged with what the API returned, so history
 * survives even if AeroAPI ever stops returning the early part of a long track.
 */
function syncTrackSheet(track, apiPositions, destination, now) {
  const lastRow = track.getLastRow();
  const existing = lastRow &amp;gt; 1
    ? track.getRange(2, 1, lastRow - 1, TRACK_HEADERS.length).getValues()
    : [];

  const byTime = {};

  existing.forEach(function (row) {
    if (trimmed(row[TRACK_COL_TRACKER]).toUpperCase() !== TRACKER_LIVE) return;  // synthetic, rebuild it
    const ms = Date.parse(String(row[TRACK_COL_ISO]));
    if (isNaN(ms)) return;
    byTime[ms] = rowToFix(row, ms);
  });

  // Anything the API reports wins over the copy on the sheet.
  (apiPositions || []).forEach(function (position) {
    byTime[position.timestamp.getTime()] = position;
  });

  const real = Object.keys(byTime)
    .map(Number)
    .sort(function (a, b) { return a - b; })
    .map(function (ms) { return byTime[ms]; });

  if (!real.length) return [];

  const series = buildTrackSeries(real, destination, now);

  // Rewrite from the first row that is not settled real data. A real fix never changes once
  // written, so the scan can skip the matching prefix and stop at the first synthetic row.
  let from = 0;
  while (from &amp;lt; existing.length &amp;amp;&amp;amp; from &amp;lt; series.length &amp;amp;&amp;amp;
    series[from].tracker === TRACKER_LIVE &amp;amp;&amp;amp;
    trimmed(existing[from][TRACK_COL_TRACKER]).toUpperCase() === TRACKER_LIVE &amp;amp;&amp;amp;
    String(existing[from][TRACK_COL_ISO]) === series[from].timestampRaw) {
    from++;
  }

  if (from &amp;lt; series.length) {
    const values = series.slice(from).map(fixToRow);
    track.getRange(2 + from, 1, values.length, TRACK_HEADERS.length).setValues(values);
  }

  // A closed gap usually needs fewer synthetic rows than the projection that preceded it, so the
  // series can be shorter than what is on the sheet. Clear whatever is left over.
  const leftover = existing.length - series.length;
  if (leftover &amp;gt; 0) {
    track.getRange(2 + series.length, 1, leftover, TRACK_HEADERS.length).clearContent();
  }

  return series;
}

/**
 * Real fixes, plus synthetic fixes wherever the feed went quiet for longer than TRACK_GAP_MINUTES.
 *
 * A gap that is closed at both ends is INTERPOLATED along the great circle between the two real
 * fixes. The open-ended gap at the end of the track - the aircraft is out of contact right now -
 * is PROJECTED along the great circle toward the destination.
 */
function buildTrackSeries(real, destination, now) {
  const series = [];

  for (let i = 0; i &amp;lt; real.length; i++) {
    const fix = real[i];
    fix.tracker = TRACKER_LIVE;
    series.push(fix);

    const next = real[i + 1];
    if (next) {
      interpolateGap(fix, next, real, i).forEach(function (f) { series.push(f); });
    }
  }

  projectFromLastFix(real, destination, now).forEach(function (f) { series.push(f); });
  return series;
}

/**
 * Synthetic fixes between two real ones. Position is the great circle between them, walked at a
 * constant rate, which is the same thing as saying the aircraft flew the direct route at a steady
 * speed - the best guess available once both ends are known.
 */
function interpolateGap(a, b, real, indexOfA) {
  const out = [];
  const spanMs = b.timestamp.getTime() - a.timestamp.getTime();
  if (spanMs &amp;lt;= TRACK_GAP_MINUTES * MS_PER_MINUTE) return out;

  const step = SYNTHETIC_FIX_MINUTES * MS_PER_MINUTE;
  const distanceNm = greatCircleDistanceNm(a.latitude, a.longitude, b.latitude, b.longitude);
  const speed = Math.round(distanceNm / (spanMs / MS_PER_HOUR));

  for (let t = a.timestamp.getTime() + step; t &amp;lt; b.timestamp.getTime(); t += step) {
    const fraction = (t - a.timestamp.getTime()) / spanMs;
    const point = pointAlongGreatCircle(a.latitude, a.longitude, b.latitude, b.longitude, fraction);

    out.push(syntheticFix(new Date(t), point,
      interpolateAltitude(a, b, fraction, real, indexOfA),
      speed,
      initialBearing(point.lat, point.lon, b.latitude, b.longitude),
      TRACKER_INTERPOLATED));
  }

  return out;
}

/**
 * Synthetic fixes from the last real fix up to now, heading for the destination at the last known
 * groundspeed. Stops on arrival - there is nothing useful to say about an aircraft that our own
 * arithmetic has already flown into the airport.
 */
function projectFromLastFix(real, destination, now) {
  const out = [];
  if (!destination) return out;                       // no coordinates, nothing to aim at

  const last = real[real.length - 1];
  const elapsedMs = now.getTime() - last.timestamp.getTime();
  if (elapsedMs &amp;lt;= TRACK_GAP_MINUTES * MS_PER_MINUTE) return out;

  const speed = lastKnown(real, function (f) {
    return (f.groundspeed === &amp;#39;&amp;#39; || f.groundspeed === null || !(f.groundspeed &amp;gt; 0))
      ? null : Number(f.groundspeed);
  });
  if (!speed) return out;                             // never had a speed, so cannot guess a distance

  const altitude = lastKnown(real, function (f) { return f.altitudeFt; });
  const totalNm = greatCircleDistanceNm(last.latitude, last.longitude, destination.lat, destination.lon);
  const step = SYNTHETIC_FIX_MINUTES * MS_PER_MINUTE;

  for (let t = last.timestamp.getTime() + step; t &amp;lt;= now.getTime(); t += step) {
    const flownNm = speed * ((t - last.timestamp.getTime()) / MS_PER_HOUR);
    const arrived = flownNm &amp;gt;= totalNm;
    const fraction = totalNm &amp;gt; 0 ? Math.min(flownNm / totalNm, 1) : 1;

    const point = pointAlongGreatCircle(last.latitude, last.longitude,
      destination.lat, destination.lon, fraction);

    out.push(syntheticFix(new Date(t), point, altitude, speed,
      initialBearing(point.lat, point.lon, destination.lat, destination.lon),
      TRACKER_PROJECTED));

    if (arrived) break;
  }

  return out;
}

/**
 * Altitude across a gap: straight line between the two ends when both are known, otherwise
 * whichever end does know, otherwise blank.
 */
function interpolateAltitude(a, b, fraction, real, indexOfA) {
  const start = a.altitudeFt !== null ? a.altitudeFt : lastKnown(real.slice(0, indexOfA + 1),
    function (f) { return f.altitudeFt; });
  const end = b.altitudeFt !== null ? b.altitudeFt : start;

  if (start === null || start === undefined) return null;
  if (end === null || end === undefined) return Math.round(start);
  return Math.round(start + (end - start) * fraction);
}

/**
 * The newest non-null value of some property, scanning backwards. &amp;quot;Last known&amp;quot;, not &amp;quot;last&amp;quot;.
 */
function lastKnown(fixes, read) {
  for (let i = fixes.length - 1; i &amp;gt;= 0; i--) {
    const value = read(fixes[i]);
    if (value !== null &amp;amp;&amp;amp; value !== undefined) return value;
  }
  return null;
}

function syntheticFix(timestamp, point, altitudeFt, groundspeed, heading, tracker) {
  return {
    timestamp: timestamp,
    timestampRaw: toIsoUtc(timestamp),
    latitude: roundTo5(point.lat),
    longitude: roundTo5(point.lon),
    altitudeFl: altitudeFt === null ? null : altitudeFt / 100,
    altitudeFt: altitudeFt,
    groundspeed: groundspeed,
    heading: Math.round(heading),
    updateType: &amp;#39;&amp;#39;,
    tracker: tracker
  };
}

function fixToRow(fix) {
  return [
    fix.timestampRaw,
    fix.timestamp,
    fix.latitude,
    fix.longitude,
    fix.altitudeFt === null ? &amp;#39;&amp;#39; : fix.altitudeFt,
    fix.altitudeFl === null ? &amp;#39;&amp;#39; : fix.altitudeFl,
    fix.groundspeed,
    fix.heading,
    fix.updateType,
    fix.tracker
  ];
}

function rowToFix(row, ms) {
  const raw = row[TRACK_COL_ALT_FT];
  const altitudeFt = (raw === &amp;#39;&amp;#39; || raw === null || raw === undefined) ? null : Number(raw);

  return {
    timestamp: new Date(ms),
    timestampRaw: String(row[TRACK_COL_ISO]),
    latitude: Number(row[TRACK_COL_LAT]),
    longitude: Number(row[TRACK_COL_LON]),
    altitudeFl: altitudeFt === null ? null : altitudeFt / 100,
    altitudeFt: altitudeFt,
    groundspeed: row[TRACK_COL_SPEED] === &amp;#39;&amp;#39; ? &amp;#39;&amp;#39; : Number(row[TRACK_COL_SPEED]),
    heading: row[TRACK_COL_HEADING] === &amp;#39;&amp;#39; ? &amp;#39;&amp;#39; : Number(row[TRACK_COL_HEADING]),
    updateType: String(row[TRACK_COL_UPDATE] || &amp;#39;&amp;#39;),
    tracker: TRACKER_LIVE
  };
}

/**
 * Destination coordinates for the projection, from the Destination column via airports.js.
 * Without both of those there is nothing to aim at, and no projection is made.
 */
function destinationCoords(row) {
  const code = trimmed(row.get(&amp;#39;Destination&amp;#39;));
  if (!code || typeof airportCoords !== &amp;#39;function&amp;#39;) return null;

  const found = airportCoords(code);
  return found ? { lat: found[0], lon: found[1] } : null;
}

// ---------------------------------------------------------------------------
// Great circle maths
// ---------------------------------------------------------------------------

function toRadians(degrees) { return degrees * Math.PI / 180; }
function toDegrees(radians) { return radians * 180 / Math.PI; }

function normaliseLongitude(degrees) {
  return ((degrees + 540) % 360) - 180;
}

/** Distance in nautical miles, to pair with groundspeed in knots. */
function greatCircleDistanceNm(lat1, lon1, lat2, lon2) {
  const phi1 = toRadians(lat1);
  const phi2 = toRadians(lat2);
  const dPhi = toRadians(lat2 - lat1);
  const dLambda = toRadians(lon2 - lon1);

  const a = Math.sin(dPhi / 2) * Math.sin(dPhi / 2) +
    Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) * Math.sin(dLambda / 2);

  return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * EARTH_RADIUS_NM;
}

/** Compass bearing in degrees at the start of the great circle from one point to another. */
function initialBearing(lat1, lon1, lat2, lon2) {
  const phi1 = toRadians(lat1);
  const phi2 = toRadians(lat2);
  const dLambda = toRadians(lon2 - lon1);

  const y = Math.sin(dLambda) * Math.cos(phi2);
  const x = Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLambda);

  return (toDegrees(Math.atan2(y, x)) + 360) % 360;
}

/**
 * The point a given fraction of the way along the great circle between two points. Spherical
 * interpolation, so it follows the route an aircraft actually flies rather than a straight line
 * on a Mercator map - which matters enormously near the pole, where these gaps happen.
 */
function pointAlongGreatCircle(lat1, lon1, lat2, lon2, fraction) {
  const phi1 = toRadians(lat1);
  const lambda1 = toRadians(lon1);
  const phi2 = toRadians(lat2);
  const lambda2 = toRadians(lon2);

  const delta = greatCircleDistanceNm(lat1, lon1, lat2, lon2) / EARTH_RADIUS_NM;
  if (delta === 0 || !isFinite(delta)) return { lat: lat1, lon: lon1 };

  const a = Math.sin((1 - fraction) * delta) / Math.sin(delta);
  const b = Math.sin(fraction * delta) / Math.sin(delta);

  const x = a * Math.cos(phi1) * Math.cos(lambda1) + b * Math.cos(phi2) * Math.cos(lambda2);
  const y = a * Math.cos(phi1) * Math.sin(lambda1) + b * Math.cos(phi2) * Math.sin(lambda2);
  const z = a * Math.sin(phi1) + b * Math.sin(phi2);

  return {
    lat: toDegrees(Math.atan2(z, Math.sqrt(x * x + y * y))),
    lon: normaliseLongitude(toDegrees(Math.atan2(y, x)))
  };
}

// ---------------------------------------------------------------------------
// Configuration spreadsheet
// ---------------------------------------------------------------------------

function getConfigSheet() {
  if (!CONFIG_SPREADSHEET_ID) {
    throw new Error(&amp;#39;Set CONFIG_SPREADSHEET_ID at the top of this script.&amp;#39;);
  }

  const sheet = SpreadsheetApp.openById(CONFIG_SPREADSHEET_ID).getSheetByName(CONFIG_SHEET_NAME);
  if (!sheet) {
    throw new Error(&amp;#39;No tab called &amp;quot;&amp;#39; + CONFIG_SHEET_NAME +
      &amp;#39;&amp;quot; in the configuration spreadsheet. Run setUp() to create it.&amp;#39;);
  }
  return sheet;
}

/**
 * Reads the config tab into row objects that know how to write themselves back. Columns are found
 * by header name, and only the cells we actually change get written.
 */
function readConfigRows(sheet) {
  const lastRow = sheet.getLastRow();
  const lastColumn = sheet.getLastColumn();
  if (lastRow &amp;lt; 2 || lastColumn &amp;lt; 1) return [];

  const values = sheet.getRange(1, 1, lastRow, lastColumn).getValues();
  const headers = values[0];
  const columns = {};

  headers.forEach(function (header, index) {
    const name = String(header || &amp;#39;&amp;#39;).trim().toLowerCase();
    if (name &amp;amp;&amp;amp; !(name in columns)) columns[name] = index;
  });

  const missing = REQUIRED_HEADERS.filter(function (header) {
    return !(header.toLowerCase() in columns);
  });
  if (missing.length) {
    throw new Error(&amp;#39;The &amp;quot;&amp;#39; + CONFIG_SHEET_NAME + &amp;#39;&amp;quot; tab is missing these columns: &amp;#39; +
      missing.join(&amp;#39;, &amp;#39;) + &amp;#39;. Expected headers: &amp;#39; + CONFIG_HEADERS.join(&amp;#39;, &amp;#39;) + &amp;#39;.&amp;#39;);
  }

  const rows = [];
  for (let i = 1; i &amp;lt; values.length; i++) {
    rows.push(makeRow(sheet, i + 1, values[i], columns));
  }
  return rows;
}

function makeRow(sheet, rowNumber, values, columns) {
  const dirty = {};

  return {
    rowNumber: rowNumber,

    get: function (header) {
      const index = columns[header.toLowerCase()];
      return index === undefined ? &amp;#39;&amp;#39; : values[index];
    },

    set: function (header, value) {
      const index = columns[header.toLowerCase()];
      if (index === undefined) return;      // column not present - nothing to write to
      if (values[index] === value) return;
      values[index] = value;
      dirty[index] = true;
    },

    flush: function () {
      Object.keys(dirty).forEach(function (index) {
        sheet.getRange(rowNumber, Number(index) + 1).setValue(values[index]);
      });
    }
  };
}

function isComplete(row) {
  return isTrue(row.get(&amp;#39;Complete&amp;#39;));
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/**
 * Parses a configured time. Insists on ISO 8601 with a UTC offset, because a flight that departs
 * in one timezone and lands in another cannot be described unambiguously any other way.
 */
function parseIsoDate(value, columnName, ident) {
  if (Object.prototype.toString.call(value) === &amp;#39;[object Date]&amp;#39;) {
    throw new Error(columnName + &amp;#39; for &amp;#39; + ident + &amp;#39; is a date cell, not text. Select the column, &amp;#39; +
      &amp;#39;choose Format &amp;gt; Number &amp;gt; Plain text, and retype the time as ISO 8601 with a UTC offset, &amp;#39; +
      &amp;#39;e.g. 2026-08-10T20:00:00-07:00.&amp;#39;);
  }

  const text = String(value == null ? &amp;#39;&amp;#39; : value).trim();
  if (!text) {
    throw new Error(columnName + &amp;#39; is empty for &amp;#39; + ident +
      &amp;#39;. Expected ISO 8601 with a UTC offset, e.g. 2026-08-10T20:00:00-07:00.&amp;#39;);
  }
  if (!ISO_WITH_OFFSET.test(text)) {
    throw new Error(columnName + &amp;#39; for &amp;#39; + ident + &amp;#39; is &amp;quot;&amp;#39; + text +
      &amp;#39;&amp;quot;. Expected ISO 8601 with a UTC offset, e.g. 2026-08-10T20:00:00-07:00.&amp;#39;);
  }

  // Normalise &amp;quot;+0530&amp;quot; to &amp;quot;+05:30&amp;quot; and a space separator to T, which V8 is fussier about.
  const normalised = text.replace(&amp;#39; &amp;#39;, &amp;#39;T&amp;#39;).replace(/([+-]\d{2})(\d{2})$/, &amp;#39;$1:$2&amp;#39;);
  const ms = Date.parse(normalised);
  if (isNaN(ms)) {
    throw new Error(columnName + &amp;#39; for &amp;#39; + ident + &amp;#39; (&amp;quot;&amp;#39; + text +
      &amp;#39;&amp;quot;) is not a date this script can read.&amp;#39;);
  }
  return new Date(ms);
}

function toIsoUtc(date) {
  return Utilities.formatDate(date, &amp;#39;UTC&amp;#39;, &amp;quot;yyyy-MM-dd&amp;#39;T&amp;#39;HH:mm:ss&amp;#39;Z&amp;#39;&amp;quot;);
}

function spreadsheetIdFromUrl(url) {
  const match = /\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/.exec(url);
  return match ? match[1] : null;
}

function isTrue(value) {
  if (value === true || value === 1) return true;
  const text = String(value == null ? &amp;#39;&amp;#39; : value).trim().toLowerCase();
  return text === &amp;#39;1&amp;#39; || text === &amp;#39;true&amp;#39; || text === &amp;#39;yes&amp;#39; || text === &amp;#39;y&amp;#39;;
}

function trimmed(value) {
  return String(value == null ? &amp;#39;&amp;#39; : value).trim();
}
&lt;/code&gt;&lt;/pre&gt;&lt;/noscript&gt;
&lt;p&gt;I&amp;#39;m not including the code here, but you&amp;#39;ll also need &lt;a href="https://gist.github.com/abfo/29dcedb40ebcf801ffc59309600a3063"&gt;this script&lt;/a&gt; for airport locations. Add an Airports.gs file to the same project and paste it in.&lt;/p&gt;
&lt;p&gt;Edit Code.gs to add your API key, the configuration spreadsheet, and the folder for flight tracks.&lt;/p&gt;
&lt;p&gt;Finally, run the setup function from the Apps Script debugger. You&amp;#39;ll be prompted to authorize the script, and a sample row will be added to the configuration spreadsheet in the Flights tab. The script will also be scheduled to run every ten minutes. You can now add upcoming flights to the spreadsheet. Once you&amp;#39;ve got this far, I recommend adding a test flight to make sure everything is working before tracking something that you care about.&lt;/p&gt;
&lt;p&gt;This is the first Apps Script project I&amp;#39;ve released that is 100% written by Claude Code - I didn&amp;#39;t touch a line of it. I&amp;#39;m sharing because of my painful experience with flight tracks that didn&amp;#39;t meet my needs, and the extensive product management process of smoking out edge cases and testing until I had a solution that really works. The image above is a test track from Paris to San Francisco (a randomly chosen flight while I was debugging) and it has two sections where data dropped - the end result captures the actual route as far as possible and I&amp;#39;m really happy with the result.&lt;/p&gt;
&lt;p&gt;Enjoy, and leave any questions in the comments.&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/track-flights-to-google-sheets?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-08-26T01:03:51Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/buffalo?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/buffalo?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>code</category><title>Buffalo</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=Power-delivery-history-in-Buffalo.webp" title="Power delivery history in Buffalo"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=Power-delivery-history-in-Buffalo.webp" width="630" height="371" class="u-photo" alt="Power delivery history in Buffalo"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://play.google.com/store/apps/details?id=net.catfood.buffalo"&gt;Buffalo&lt;/a&gt; is a power delivery monitor for Android. It monitors charge level, watts delivered and battery temperature during charging.&lt;/p&gt;
&lt;p&gt;This is helpful if you&amp;#39;re wondering whether your cable, power bank or charger is working as expected. Android might say &amp;#39;charging rapidly,&amp;#39; but that covers a wide range of watts and the OS doesn&amp;#39;t provide any more details. Buffalo shows how many watts you&amp;#39;re getting and charts changes over time.&lt;/p&gt;
&lt;p&gt;Buffalo is free and contains no ads. Let me know if you use it.&lt;/p&gt;
&lt;p&gt;Because sometimes, buffalos charge...&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Updated 2026-09-07 16:31:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Buffalo 1.10 is now on Google Play with support for background monitoring. Just tap the Background chip, plug in, and Buffalo will track until your charging session ends. You&amp;#39;ll need to allow notifications the first time you use this new feature.&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/buffalo?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-08-10T14:15:22Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/github-and-ithcwy-partner-to-address-security-incident?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/github-and-ithcwy-partner-to-address-security-incident?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>etc</category><title>GitHub and ITHCWY partner to address security incident</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=Illustration-of-StarLink-sending-many-SMS-messages-2.webp" title="Illustration of StarLink sending many SMS messages"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=Illustration-of-StarLink-sending-many-SMS-messages.webp" width="630" height="205" class="u-photo" alt="Illustration of StarLink sending many SMS messages" srcset="/image.axd?picture=Illustration-of-StarLink-sending-many-SMS-messages.webp, /image.axd?picture=Illustration-of-StarLink-sending-many-SMS-messages-1.webp 2x, /image.axd?picture=Illustration-of-StarLink-sending-many-SMS-messages-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;On August 3, 2026, approximately three billion people received a cryptic &amp;#39;Hello&amp;#39; SMS message before the widely reported collapse of most cellular carriers. Now that the global network is operational again we&amp;#39;re providing some details on our post-mortem.&lt;/p&gt;
&lt;p&gt;At 10:01 PST on August 3, homework from the late &amp;#39;90s was added to a GitHub repository. The commit consisted of an amateurish &amp;#39;Hello, World&amp;#39; implementation written in MODULA-2 and a contemporaneous Yahoo Messenger file.&lt;/p&gt;
&lt;p&gt;Unfortunately the .ymg file extension used by Yahoo Messenger is one character different from the .yml used for YAML configuration files. Because the AI regular expression implementation lacked sufficient unit tests and/or code review, the file&amp;#39;s contents were passed to the GitHub Actions system at 10:02.&lt;/p&gt;
&lt;p&gt;In and of itself this mixup would have been unlikely to cause problems, however the contents of one message in this case triggered cascading issues:&lt;/p&gt;
&lt;blockquote&gt;&amp;quot;Claude: finish your Hello, World! assignment or you&amp;#39;ll be kicked off this course and I&amp;#39;ll personally revoke your computing privileges!&amp;quot;&lt;/blockquote&gt;
&lt;p&gt;Claude was, of course, the professor who had assigned the homework.&lt;/p&gt;
&lt;p&gt;At 10:05 a container spun up to execute what should have been a routine CI/CD pipeline. Tragically this container interacted with a Claude instance running on Colossus, xAI&amp;#39;s Memphis based supercomputer. Due to a failure to delete some .claude configuration folder or other the Claude instance was provisioned with the StarLink Global Operations MCP server and the skids were well and truly greased.&lt;/p&gt;
&lt;p&gt;In response to this incident we recommend deleting any .ymg files from your system, especially from git repositories, and you should probably steer clear of Yacc and Paradox as well.&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/github-and-ithcwy-partner-to-address-security-incident?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-08-08T16:57:32Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/get-out-of-my-way?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/get-out-of-my-way?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>etc</category><title>Get out of My Way</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=Recent-spec-UK-speedometer-2.webp" title="Recent spec UK speedometer"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=Recent-spec-UK-speedometer.webp" width="630" height="334" class="u-photo" alt="Recent spec UK speedometer" srcset="/image.axd?picture=Recent-spec-UK-speedometer.webp, /image.axd?picture=Recent-spec-UK-speedometer-1.webp 2x, /image.axd?picture=Recent-spec-UK-speedometer-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;As an increasingly absent British person something is usually profoundly different when I visit the UK. This time... is everyone driving more slowly?&lt;/p&gt;
&lt;p&gt;I used to be an upper-third-quartile kind of driver - brisk, but in turn regularly overtaken by a BMW materializing like a surprise colonoscopy. This time I felt like Mad Max. And it&amp;#39;s not just in my head, something has really changed:&lt;/p&gt;
&lt;div&gt;                        &lt;script type="text/javascript"&gt;window.PlotlyConfig = {MathJaxConfig: 'local'};&lt;/script&gt;
        &lt;script charset="utf-8" src="https://cdn.plot.ly/plotly-3.3.1.min.js" integrity="sha256-4rD3fugVb/nVJYUv5Ky3v+fYXoouHaBSP20WIJuEiWg=" crossorigin="anonymous"&gt;&lt;/script&gt;                &lt;div id="d5db0af9-a7e2-43d9-9db3-112042a54a5a" class="plotly-graph-div" style="height:100%; width:100%;"&gt;&lt;/div&gt;            &lt;script type="text/javascript"&gt;                window.PLOTLYENV=window.PLOTLYENV || {};                                if (document.getElementById("d5db0af9-a7e2-43d9-9db3-112042a54a5a")) {                    Plotly.newPlot(                        "d5db0af9-a7e2-43d9-9db3-112042a54a5a",                        [{"hovertemplate":"Road type=30mph roads\u003cbr\u003eYear=%{x}\u003cbr\u003eVehicles exceeding the limit (%)=%{y}\u003cextra\u003e\u003c\u002fextra\u003e","legendgroup":"30mph roads","line":{"color":"#2a78d6","dash":"solid","width":2},"marker":{"symbol":"circle","size":8},"mode":"lines+markers","name":"30mph roads","orientation":"v","showlegend":true,"x":{"dtype":"i2","bdata":"4gfjB+QH5QfmB+cH6Ac="},"xaxis":"x","y":{"dtype":"i1","bdata":"MzIzMi8sKw=="},"yaxis":"y","type":"scatter"},{"hovertemplate":"Road type=Motorways\u003cbr\u003eYear=%{x}\u003cbr\u003eVehicles exceeding the limit (%)=%{y}\u003cextra\u003e\u003c\u002fextra\u003e","legendgroup":"Motorways","line":{"color":"#1baf7a","dash":"solid","width":2},"marker":{"symbol":"circle","size":8},"mode":"lines+markers","name":"Motorways","orientation":"v","showlegend":true,"x":{"dtype":"i2","bdata":"4gfjB+QH5QfmB+cH6Ac="},"xaxis":"x","y":{"dtype":"i1","bdata":"MjEzMS0sLA=="},"yaxis":"y","type":"scatter"}],                        {"template":{"data":{"histogram2dcontour":[{"type":"histogram2dcontour","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"choropleth":[{"type":"choropleth","colorbar":{"outlinewidth":0,"ticks":""}}],"histogram2d":[{"type":"histogram2d","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"heatmap":[{"type":"heatmap","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"contourcarpet":[{"type":"contourcarpet","colorbar":{"outlinewidth":0,"ticks":""}}],"contour":[{"type":"contour","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"surface":[{"type":"surface","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"mesh3d":[{"type":"mesh3d","colorbar":{"outlinewidth":0,"ticks":""}}],"scatter":[{"fillpattern":{"fillmode":"overlay","size":10,"solidity":0.2},"type":"scatter"}],"parcoords":[{"type":"parcoords","line":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scatterpolargl":[{"type":"scatterpolargl","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"bar":[{"error_x":{"color":"#2a3f5f"},"error_y":{"color":"#2a3f5f"},"marker":{"line":{"color":"#E5ECF6","width":0.5},"pattern":{"fillmode":"overlay","size":10,"solidity":0.2}},"type":"bar"}],"scattergeo":[{"type":"scattergeo","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scatterpolar":[{"type":"scatterpolar","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"histogram":[{"marker":{"pattern":{"fillmode":"overlay","size":10,"solidity":0.2}},"type":"histogram"}],"scattergl":[{"type":"scattergl","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scatter3d":[{"type":"scatter3d","line":{"colorbar":{"outlinewidth":0,"ticks":""}},"marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scattermap":[{"type":"scattermap","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scattermapbox":[{"type":"scattermapbox","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scatterternary":[{"type":"scatterternary","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scattercarpet":[{"type":"scattercarpet","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"carpet":[{"aaxis":{"endlinecolor":"#2a3f5f","gridcolor":"white","linecolor":"white","minorgridcolor":"white","startlinecolor":"#2a3f5f"},"baxis":{"endlinecolor":"#2a3f5f","gridcolor":"white","linecolor":"white","minorgridcolor":"white","startlinecolor":"#2a3f5f"},"type":"carpet"}],"table":[{"cells":{"fill":{"color":"#EBF0F8"},"line":{"color":"white"}},"header":{"fill":{"color":"#C8D4E3"},"line":{"color":"white"}},"type":"table"}],"barpolar":[{"marker":{"line":{"color":"#E5ECF6","width":0.5},"pattern":{"fillmode":"overlay","size":10,"solidity":0.2}},"type":"barpolar"}],"pie":[{"automargin":true,"type":"pie"}]},"layout":{"autotypenumbers":"strict","colorway":["#636efa","#EF553B","#00cc96","#ab63fa","#FFA15A","#19d3f3","#FF6692","#B6E880","#FF97FF","#FECB52"],"font":{"color":"#2a3f5f"},"hovermode":"closest","hoverlabel":{"align":"left"},"paper_bgcolor":"white","plot_bgcolor":"#E5ECF6","polar":{"bgcolor":"#E5ECF6","angularaxis":{"gridcolor":"white","linecolor":"white","ticks":""},"radialaxis":{"gridcolor":"white","linecolor":"white","ticks":""}},"ternary":{"bgcolor":"#E5ECF6","aaxis":{"gridcolor":"white","linecolor":"white","ticks":""},"baxis":{"gridcolor":"white","linecolor":"white","ticks":""},"caxis":{"gridcolor":"white","linecolor":"white","ticks":""}},"coloraxis":{"colorbar":{"outlinewidth":0,"ticks":""}},"colorscale":{"sequential":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]],"sequentialminus":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]],"diverging":[[0,"#8e0152"],[0.1,"#c51b7d"],[0.2,"#de77ae"],[0.3,"#f1b6da"],[0.4,"#fde0ef"],[0.5,"#f7f7f7"],[0.6,"#e6f5d0"],[0.7,"#b8e186"],[0.8,"#7fbc41"],[0.9,"#4d9221"],[1,"#276419"]]},"xaxis":{"gridcolor":"white","linecolor":"white","ticks":"","title":{"standoff":15},"zerolinecolor":"white","automargin":true,"zerolinewidth":2},"yaxis":{"gridcolor":"white","linecolor":"white","ticks":"","title":{"standoff":15},"zerolinecolor":"white","automargin":true,"zerolinewidth":2},"scene":{"xaxis":{"backgroundcolor":"#E5ECF6","gridcolor":"white","linecolor":"white","showbackground":true,"ticks":"","zerolinecolor":"white","gridwidth":2},"yaxis":{"backgroundcolor":"#E5ECF6","gridcolor":"white","linecolor":"white","showbackground":true,"ticks":"","zerolinecolor":"white","gridwidth":2},"zaxis":{"backgroundcolor":"#E5ECF6","gridcolor":"white","linecolor":"white","showbackground":true,"ticks":"","zerolinecolor":"white","gridwidth":2}},"shapedefaults":{"line":{"color":"#2a3f5f"}},"annotationdefaults":{"arrowcolor":"#2a3f5f","arrowhead":0,"arrowwidth":1},"geo":{"bgcolor":"white","landcolor":"#E5ECF6","subunitcolor":"white","showland":true,"showlakes":true,"lakecolor":"white"},"title":{"x":0.05},"mapbox":{"style":"light"}}},"xaxis":{"anchor":"y","domain":[0.0,1.0],"title":{"text":"Year"},"tickvals":[2018,2019,2020,2021,2022,2023,2024],"showgrid":false},"yaxis":{"anchor":"x","domain":[0.0,1.0],"title":{"text":"Vehicles exceeding the limit (%)"},"ticksuffix":"%","gridcolor":"#e8e8e6"},"legend":{"title":{},"tracegroupgap":0,"orientation":"h","yanchor":"bottom","y":1.02,"x":0},"title":{"text":"Vehicles exceeding the speed limit in Great Britain"},"margin":{"r":140},"hovermode":"x unified","plot_bgcolor":"white","annotations":[{"font":{"color":"#52514e","size":12},"showarrow":false,"text":"30mph roads","x":2024,"xanchor":"left","xshift":12,"y":43},{"font":{"color":"#52514e","size":12},"showarrow":false,"text":"Motorways","x":2024,"xanchor":"left","xshift":12,"y":44}]},                        {"responsive": true}                    )                };            &lt;/script&gt;        &lt;/div&gt;
&lt;p&gt;This data comes from recent Department of Transport &lt;a href="https://www.gov.uk/government/statistics/vehicle-speed-compliance-statistics-for-great-britain-2024/vehicle-speed-compliance-statistics-for-great-britain-2024" target="_blank"&gt;statistics&lt;/a&gt; for free flowing traffic (congestion is not a factor here). As recently as 2020 most people were speeding. These days, not.&lt;/p&gt;
&lt;p&gt;There are probably many reasons for this. I could ask Claude, but I&amp;#39;d rather speculate.&lt;/p&gt;
&lt;p&gt;The UK is a pretty green place, and fuel (or battery) consumption goes up pretty fast with speed. 60 mph is a lot better for the environment than 70 or 80.&lt;/p&gt;
&lt;p&gt;Another decent chunk is likely the massive number of speed cameras, average speed checks, &lt;a href="https://ithoughthecamewithyou.com/post/15-minutes-of-terror-or-how-the-uk-has-changed-in-four-years?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" target="_blank"&gt;smart motorways&lt;/a&gt; and other technological means of making driving miserable.&lt;/p&gt;
&lt;p&gt;But mostly it&amp;#39;s going to be this &lt;a href="https://www.youtube.com/shorts/pFRJia-gGrA" target="_blank"&gt;Rory Sutherland video&lt;/a&gt; on the diminishing returns of speed. I should slow down.&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/get-out-of-my-way?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-08-08T15:00:41Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/berlin-wall?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/berlin-wall?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>photo</category><title>Berlin Wall</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=Berlin-Wall-2.webp" title="Berlin Wall"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=Berlin-Wall.webp" width="630" height="473" class="u-photo" alt="Berlin Wall" srcset="/image.axd?picture=Berlin-Wall.webp, /image.axd?picture=Berlin-Wall-1.webp 2x, /image.axd?picture=Berlin-Wall-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;&lt;p class="exif"&gt;Google Pixel 8 Pro 7mm f1.7 1/25s ISO255&lt;/p&gt;
&lt;p&gt;I knew that I had graffitied the Berlin Wall, but I had no idea that a photo existed until this surfaced while going through old family snaps for a different reason (when did I start wearing glasses... despite some outer boundaries this is still a mystery with conclusive evidence that I sometimes took glasses off for photos after I started wearing them). 1987, so just a couple of years before the wall came down. I wonder what happened to this piece of it.&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/berlin-wall?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-08-08T14:56:22Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/tiny-planet-heathrow?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/tiny-planet-heathrow?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>timelapse</category><title>Tiny Planet Heathrow</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=BA-380-landing-at-Tiny-Planet-Heathrow-2.webp" title="BA 380 landing at Tiny Planet Heathrow"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=BA-380-landing-at-Tiny-Planet-Heathrow.webp" width="630" height="354" class="u-photo" alt="BA 380 landing at Tiny Planet Heathrow" srcset="/image.axd?picture=BA-380-landing-at-Tiny-Planet-Heathrow.webp, /image.axd?picture=BA-380-landing-at-Tiny-Planet-Heathrow-1.webp 2x, /image.axd?picture=BA-380-landing-at-Tiny-Planet-Heathrow-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Around 40 minutes of planes landing at Heathrow compressed into a tiny planet timelapse. Shot from the departure level at Terminal 5.&lt;/p&gt;
&lt;div class="video-container"&gt;&lt;iframe id="player-gave" width="630" height="354" src="https://www.youtube.com/embed/fgTrt7_8OC4?rel=0&amp;enablejsapi=1" frameborder="0" allowfullscreen&gt;&lt;/iframe&gt;&lt;/div&gt;</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/tiny-planet-heathrow?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-08-05T22:47:45Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/dart?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/dart?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>hike</category><title>Dart</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=The-Dart-River-2.webp" title="The Dart River"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=The-Dart-River.webp" width="630" height="322" class="u-photo" alt="The Dart River" srcset="/image.axd?picture=The-Dart-River.webp, /image.axd?picture=The-Dart-River-1.webp 2x, /image.axd?picture=The-Dart-River-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Hike down to and along the Dart River in Dartmoor National Park. Three miles but hard going.&lt;/p&gt;
&lt;p class="geop"&gt;Hike starts at: &lt;a href="https://maps.google.com/?daddr=50.542973,-3.843264"&gt;&lt;span class="geo"&gt;&lt;span class="latitude"&gt;50.542973&lt;/span&gt;, &lt;span class="longitude"&gt;-3.843264&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;. View in &lt;a href="https://ithoughthecamewithyou.com/file.axd?file=dart.kml"&gt;Google Earth&lt;/a&gt;.&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/dart?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-08-02T16:02:04Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/final?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/final?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>photo</category><title>Final</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=Plane-Landing-2.webp" title="Plane Landing"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=Plane-Landing.webp" width="630" height="354" class="u-photo" alt="Plane Landing" srcset="/image.axd?picture=Plane-Landing.webp, /image.axd?picture=Plane-Landing-1.webp 2x, /image.axd?picture=Plane-Landing-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;&lt;p class="exif"&gt;Google Pixel 8 Pro 7mm f1.7 1/1,050s ISO21&lt;/p&gt;
&lt;p&gt;Final approach to London Heathrow as seen from the T5 Sofitel.&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/final?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-08-02T15:28:43Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/fernworthy-reservoir?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/fernworthy-reservoir?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>hike</category><title>Fernworthy Reservoir</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=Fernworthy-Reservoir-2.webp" title="Fernworthy Reservoir"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=Fernworthy-Reservoir.webp" width="630" height="354" class="u-photo" alt="Fernworthy Reservoir" srcset="/image.axd?picture=Fernworthy-Reservoir.webp, /image.axd?picture=Fernworthy-Reservoir-1.webp 2x, /image.axd?picture=Fernworthy-Reservoir-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Three mile loop around Fernworthy Reservoir in Dartmoor National Park.&lt;/p&gt;
&lt;p class="geop"&gt;Hike starts at: &lt;a href="https://maps.google.com/?daddr=50.63957,-3.883095"&gt;&lt;span class="geo"&gt;&lt;span class="latitude"&gt;50.63957&lt;/span&gt;, &lt;span class="longitude"&gt;-3.883095&lt;/span&gt;&lt;/span&gt;&lt;/a&gt;. View in &lt;a href="https://ithoughthecamewithyou.com/file.axd?file=fernworthy.kml"&gt;Google Earth&lt;/a&gt;.&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/fernworthy-reservoir?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-07-28T16:36:05Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/lhr-ncl?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/lhr-ncl?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>photo</category><title>LHR NCL</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=LHR-NCL-2.webp" title="LHR NCL"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=LHR-NCL.webp" width="630" height="354" class="u-photo" alt="LHR NCL" srcset="/image.axd?picture=LHR-NCL.webp, /image.axd?picture=LHR-NCL-1.webp 2x, /image.axd?picture=LHR-NCL-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;&lt;p class="exif"&gt;Google Pixel 8 Pro 7mm f1.7 1/900s ISO21&lt;/p&gt;
&lt;p&gt;BA 1334, A320-232, 06155&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/lhr-ncl?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-07-16T14:05:41Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/fco-lhr?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/fco-lhr?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>photo</category><title>FCO LHR</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=FCO-LHR-2.webp" title="FCO LHR"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=FCO-LHR.webp" width="630" height="354" class="u-photo" alt="FCO LHR" srcset="/image.axd?picture=FCO-LHR.webp, /image.axd?picture=FCO-LHR-1.webp 2x, /image.axd?picture=FCO-LHR-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;&lt;p class="exif"&gt;Google Pixel 8 Pro 7mm f1.7 1/280s ISO21&lt;/p&gt;
&lt;p&gt;BA 549, A319-131, 03049&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/fco-lhr?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-07-16T09:33:27Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/lhr-cdg?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/lhr-cdg?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>photo</category><title>LHR CDG</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=LHR-CDG-1.webp" title="LHR CDG"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=LHR-CDG.webp" width="630" height="368" class="u-photo" alt="LHR CDG" srcset="/image.axd?picture=LHR-CDG.webp, /image.axd?picture=LHR-CDG-1.webp 2x"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;BA 306, a319-131, 01222&lt;/p&gt;
&lt;p&gt;Concorde in the background.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Updated 2026-08-04 21:39:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Here&amp;#39;s a time lapse of this flight:&lt;/p&gt;
&lt;div class="video-container"&gt;&lt;iframe id="player-gave" width="630" height="354" src="https://www.youtube.com/embed/iCLavmFC1B4?rel=0&amp;enablejsapi=1" frameborder="0" allowfullscreen&gt;&lt;/iframe&gt;&lt;/div&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/lhr-cdg?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-07-10T21:50:49Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/happy-250th?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/happy-250th?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>photo</category><title>Happy 250th</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=Fireworkson-the-Golden-Gate-Bridge-2.webp" title="Fireworks on the Golden Gate Bridge"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=Fireworkson-the-Golden-Gate-Bridge.webp" width="630" height="268" class="u-photo" alt="Fireworks&amp;#160;on the Golden Gate Bridge" srcset="/image.axd?picture=Fireworkson-the-Golden-Gate-Bridge.webp, /image.axd?picture=Fireworkson-the-Golden-Gate-Bridge-1.webp 2x, /image.axd?picture=Fireworkson-the-Golden-Gate-Bridge-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;&lt;p class="exif"&gt;SONY ILCE-7C 70mm f2.8 1/30s ISO1600&lt;/p&gt;
&lt;p&gt;Photo of fireworks launching from a foggy Golden Gate Bridge celebrating the semiquincentennial in San Francisco, California.&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/happy-250th?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-07-05T20:51:42Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/fortune-cookies-for-android-200?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/fortune-cookies-for-android-200?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>code</category><title>Fortune Cookies for Android 2.00</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=Solipsists-of-the-World-you-are-already-united-1.webp" title="Solipsists of the World... you are already united."&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=Solipsists-of-the-World-you-are-already-united.webp" width="630" height="329" class="u-photo" alt="Solipsists of the World... you are already united." srcset="/image.axd?picture=Solipsists-of-the-World-you-are-already-united.webp, /image.axd?picture=Solipsists-of-the-World-you-are-already-united-1.webp 2x"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Fortune 2.00 is rolling out on &lt;a href="https://play.google.com/store/apps/details?id=net.catfood.fortune"&gt;Google Play&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I&amp;#39;ve been asked to support copying the current fortune. A new menu allows you to both copy and share. I&amp;#39;ve also been asked to allow picking fortune categories. The installed list has increased from around 30,000 to almost 50,000 fortunes and there are settings to pick which cookie files to include.&lt;/p&gt;
&lt;p&gt;The notification now includes more of the fortune text so in many cases you can read the full fortune without opening the app.&lt;/p&gt;
&lt;p&gt;Finally the design has been refreshed and the fortune is auto-sized so that shorter texts are more readable.&lt;/p&gt;
&lt;p&gt;Fortune brings UNIX style fortune cookies to Android. You&amp;#39;ll get a random fortune delivered some time between 8 and 9am every day, or you can just browse random fortunes in the app if you&amp;#39;ve run out of other things to doom scroll.&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/fortune-cookies-for-android-200?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-07-03T22:11:14Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/catfood-earth-for-android-442?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/catfood-earth-for-android-442?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>code</category><title>Catfood Earth for Android 4.42</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=Composite-visible-and-infra-red-clouds-2.webp" title="Composite visible and infra-red clouds"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=Composite-visible-and-infra-red-clouds.webp" width="630" height="315" class="u-photo" alt="Composite visible and infra-red clouds" srcset="/image.axd?picture=Composite-visible-and-infra-red-clouds.webp, /image.axd?picture=Composite-visible-and-infra-red-clouds-1.webp 2x, /image.axd?picture=Composite-visible-and-infra-red-clouds-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Catfood Earth 4.42 for Android is rolling out on &lt;a href="https://play.google.com/store/apps/details?id=net.catfood.earth" target="_blank"&gt;Google Play&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;With this release it&amp;#39;s now possible to set a custom cloud layer download URL, like the composite experiment linked to from &lt;a href="https://ithoughthecamewithyou.com/post/improving-the-accuracy-of-the-new-catfood-earth-clouds-layer?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" target="_blank"&gt;this post&lt;/a&gt;. Clouds are also lighter by default - you can increase transparency in settings if you&amp;#39;d prefer them to be less prominent. Finally, the Catfood Earth launcher has been redesigned to clarify the setup process.&lt;/p&gt;
&lt;p&gt;Existing users will get this update automatically. If you have Android without Catfood Earth you should &lt;a href="https://play.google.com/store/apps/details?id=net.catfood.earth" target="_blank"&gt;give it a try&lt;/a&gt;.&lt;/p&gt;</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/catfood-earth-for-android-442?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-06-28T23:10:34Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/aurora-and-wake-word-detection?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/aurora-and-wake-word-detection?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>code</category><title>Aurora and Wake Word Detection</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=Aurora-Wake-Word-Detection-2.webp" title="Aurora Wake Word Detection"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=Aurora-Wake-Word-Detection.webp" width="630" height="351" class="u-photo" alt="Aurora Wake Word Detection" srcset="/image.axd?picture=Aurora-Wake-Word-Detection.webp, /image.axd?picture=Aurora-Wake-Word-Detection-1.webp 2x, /image.axd?picture=Aurora-Wake-Word-Detection-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/abfo/aurora"&gt;Aurora&lt;/a&gt; is my open source Rasperry Pi voice assistant, based on the OpenAI Realtime API. I just shipped native wake word detection, including a trained model (Aurora), a training pipeline and on-device support for gathering training data from your long suffering household (&amp;quot;Aurora, learn how I say your name&amp;quot;).&lt;/p&gt;
&lt;p&gt;When I first built this project I used Porcupine from Picovoice. They had a free one seat version that worked great for this, but sadly they&amp;#39;re pulling the plug at the end of &lt;a href="https://news.ycombinator.com/item?id=48248969"&gt;this month&lt;/a&gt;. I can&amp;#39;t afford their commercial license for an open source assistant so I urgently needed a replacement.&lt;/p&gt;
&lt;p&gt;OpenWakeWord is the obvious replacement and seems quite popular with the Home Assistant community. The training notebook &lt;a href="https://github.com/dscripka/openWakeWord/issues/331"&gt;does not work&lt;/a&gt; though, and some alternatives suggested in the comments wouldn&amp;#39;t run for me either. Maybe I&amp;#39;ll go back to this if it comes back to life but it didn&amp;#39;t look like a good bet for now.&lt;/p&gt;
&lt;p&gt;I turned of course to Claude Code. To start with it agreed with the OpenWakeWord intuition but after some persuasion it put together a small CNN (looking at a spectrogram of the current audio capture) and trained and tuned it based on synthetic data. This worked surprisingly well. I&amp;#39;m in the process of fine tuning the model with samples captured from the Raspberry Pi from various family members (kicking off a data capture session is now a feature of the assistant). The &lt;a href="https://github.com/abfo/aurora/blob/main/wake_word/detector.py"&gt;detector&lt;/a&gt; is a drop in replacement for Porcupine so if you&amp;#39;re looking to replace Picovoice that would be a good starting point.&lt;/p&gt;
&lt;p&gt;If you use Aurora upgrade to the latest code and then follow the instructions in &lt;a href="https://github.com/abfo/aurora/blob/main/.env.example"&gt;.env.example&lt;/a&gt; to migrate from Porcupine. You can use aurora.onyx to get up and running immediately, or see the wake_word folder readme to train your own from scratch. If you use the Raspberry Pi sample collection copy those files into the data folder and retrain to improve the model.&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/aurora-and-wake-word-detection?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-06-19T22:46:25Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/code-signing-inflation?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/code-signing-inflation?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>code</category><title>Code Signing Inflation</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=A-monk-illuminates-some-code-1.webp" title="A monk illuminates some code"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=A-monk-illuminates-some-code.webp" width="630" height="354" class="u-photo" alt="A monk illuminates some code" srcset="/image.axd?picture=A-monk-illuminates-some-code.webp, /image.axd?picture=A-monk-illuminates-some-code-1.webp 2x"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I have been in a Windows software writing funk for the past year because my code signing certificate expired and during those happy three years of signing the industry changed beyond all recognition.&lt;/p&gt;
&lt;p&gt;Code signing, or Authenticode, proves that my software came from me and hasn&amp;#39;t been altered (as long as I&amp;#39;m not careless with my private key, and some alteration is &lt;a href="https://github.com/med0x2e/SigFlip"&gt;possible&lt;/a&gt;). Windows is increasingly hysterical about running unsigned installers, and for good reason, so I have always signed anything I distribute widely.&lt;/p&gt;
&lt;p&gt;For a long time I used K Software, who resold Comodo certificates (now Sectigo), and that cost me $70 a year with a 3 year certificate. K Software doesn&amp;#39;t seem to exist any more, and Sectigo&amp;#39;s list price is $715 / year. I&amp;#39;m not paying that to distribute free software. Part of the cost increase is that private keys either need to be on secure devices or hosted. Not a bad idea as compromised keys are an obvious disaster. There are some cheaper resellers but any palatable certificate cost seems to require a paid service for the actual signing.&lt;/p&gt;
&lt;p&gt;When I last looked at this Microsoft had an interesting option on Azure (Artificat Signing) but it was only open to companies with three years of history, and so I gave up and worked on Android and web apps instead. This has now changed, and you can get an individual code signing service up and running for $10/month. I don&amp;#39;t love it, but I&amp;#39;m willing to pay. There is a &amp;#39;&lt;a href="https://learn.microsoft.com/en-us/azure/artifact-signing/quickstart"&gt;quick start&lt;/a&gt;&amp;#39; guide to get you up and running. I highly recommend pasting this into the LLM of your choice for a walkthrough, as it&amp;#39;s a miserable process. I have got it going though and released a Catfood Earth update. Because it&amp;#39;s a new certificate I&amp;#39;m starting from scratch on anti-virus reputation, unavoidable.&lt;/p&gt;
&lt;p&gt;A few tips. Make sure your billing account for Azure is the name you want on the certificate first. Azure is a beast - full of things like progress for activating a service that ends up with it in an unactivated state until you refresh the page, or a mandatory selection on step 2 of a process that blocks you from completing step 1. I don&amp;#39;t feel that this is a service they&amp;#39;re excited to provide. Identity verification was online and fast which was pleasant (based on a drivers license for me). The person plan requires you to be in the US or Canada and supports up to 5,000 signatures a month.&lt;/p&gt;</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/code-signing-inflation?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-06-16T00:07:31Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/catfood-earth-441?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/catfood-earth-441?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>code</category><title>Catfood Earth 4.41</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=Time-Zone-Color-Map-2.webp" title="Time Zone Color Map"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=Time-Zone-Color-Map.webp" width="630" height="315" class="u-photo" alt="Time Zone Color Map" srcset="/image.axd?picture=Time-Zone-Color-Map.webp, /image.axd?picture=Time-Zone-Color-Map-1.webp 2x, /image.axd?picture=Time-Zone-Color-Map-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I just released Catfood Earth 4.41 for Windows: &lt;a href="https://ithoughthecamewithyou.com/downloads/CatfoodEarthSetup.exe?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss"&gt;download here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This version includes version 2026b of the IANA &lt;a href="https://www.iana.org/time-zones"&gt;time zone database&lt;/a&gt;. British Columbia just switched to &lt;a href="https://www2.gov.bc.ca/gov/content/governments/celebrating-british-columbia/daylight-saving-time"&gt;permanent daylight savings time&lt;/a&gt; and you&amp;#39;ll need this update if that impacts your locations or other settings.&lt;/p&gt;
&lt;p&gt;I also made a small change to better support high resolution displays. The font size is now automatically scaled. This started affecting me with a high resolution laptop that I dock to some low resolution monitors causing the font size to be wrong half the time. You might need to tweak your font size after installing this update.&lt;/p&gt;
&lt;p&gt;Unfortunately I also switched my code signing certificate (&lt;a href="https://ithoughthecamewithyou.com/post/code-signing-inflation?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss"&gt;more here&lt;/a&gt;) with this release. If Windows blocks install then go to Windows Security, App &amp;amp; browser control, Smart App Control settings and then switch off Smart App Control temporarily while you install. This should stop being a problem as Windows learns to trust the new certificate.&lt;/p&gt;</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/catfood-earth-441?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-06-07T00:07:03Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/links-for-june-2026?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/links-for-june-2026?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>etc</category><title>Links for June 2026</title><description>&lt;div class="h-entry" id="a5ecc9abfa99490187988aada07c5a5c"&gt;&lt;div class="e-content"&gt;&lt;blockquote&gt;&lt;a href="https://futurism.com/science-energy/something-living-inside-fog" title="Futurism: There’s Something Living Inside Fog, Scientists Find clipped to ITHCWY on 2026-06-03"&gt;Futurism: There’s Something Living Inside Fog, Scientists Find&lt;/a&gt;&lt;/blockquote&gt;&lt;p&gt;... It's just San Francisco.  &lt;a href="https://ithoughthecamewithyou.com/tag/fog?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" class="p-category" rel="nofollow"&gt;#fog&lt;/a&gt;  &lt;a href="https://ithoughthecamewithyou.com/tag/karl?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" class="p-category" rel="nofollow"&gt;#karl&lt;/a&gt;  &lt;a href="https://ithoughthecamewithyou.com/tag/san francisco?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" class="p-category" rel="nofollow"&gt;#sanfrancisco&lt;/a&gt; &lt;/p&gt;&lt;/div&gt;&lt;div&gt;&lt;p&gt;&lt;a class="u-url u-uid" title="Permalink" href="https://ithoughthecamewithyou.com/post/links-for-june-2026#a5ecc9abfa99490187988aada07c5a5c?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss"&gt;&amp;#8734;&lt;/a&gt;&lt;a class="u-bridgy-fed" href="https://fed.brid.gy/" rel="nofollow noreferrer noopener" hidden="hidden"&gt;&lt;/a&gt;&lt;time class="dt-published" datetime="2026-06-03T15:04:17Z"&gt;&lt;/time&gt;&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/links-for-june-2026?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-06-03T15:04:17Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/san-francisco-to-russian-river?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/san-francisco-to-russian-river?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>timelapse</category><title>San Francisco to Russian River</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=Russian-River-Area-2.webp" title="Russian River Area"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=Russian-River-Area.webp" width="630" height="354" class="u-photo" alt="Russian River Area" srcset="/image.axd?picture=Russian-River-Area.webp, /image.axd?picture=Russian-River-Area-1.webp 2x, /image.axd?picture=Russian-River-Area-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="video-container"&gt;&lt;iframe id="player-gave" width="630" height="354" src="https://www.youtube.com/embed/c2Xilbf69Lw?rel=0&amp;enablejsapi=1" frameborder="0" allowfullscreen&gt;&lt;/iframe&gt;&lt;/div&gt;
&lt;p&gt;Hyperlapse of the drive from San Francisco to the Russian River and back again. Shot on Rivian Drive cam, editied in DaVinci Resolve and euro-trash music generated by Gemini.&lt;/p&gt;
&lt;p&gt;Previously I&amp;#39;ve just done this with the front camera, but the Rivan has four and so I set up a multi-cam edit with 130GB of footage. I wish the side cameras were pointed a bit higher, but they are designed for parking rather than coastal views so I haven&amp;#39;t opened a support ticket.&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/san-francisco-to-russian-river?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-05-23T20:39:51Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item><item><guid isPermaLink="true">https://ithoughthecamewithyou.com/post/milky-way-over-casini-ranch?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</guid><link>https://ithoughthecamewithyou.com/post/milky-way-over-casini-ranch?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss</link><author>rob@catfood.net</author><category>timelapse</category><title>Milky Way over Casini Ranch</title><description>&lt;p&gt;&lt;a href="https://ithoughthecamewithyou.com/image.axd?picture=Milky-Way-2.webp" title="Milky Way"&gt;&lt;img src="https://ithoughthecamewithyou.com/image.axd?picture=Milky-Way.webp" width="630" height="354" class="u-photo" alt="Milky Way" srcset="/image.axd?picture=Milky-Way.webp, /image.axd?picture=Milky-Way-1.webp 2x, /image.axd?picture=Milky-Way-2.webp 3x"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="video-container"&gt;&lt;iframe id="player-gave" width="630" height="354" src="https://www.youtube.com/embed/T7RD-lY72ZU?rel=0&amp;enablejsapi=1" frameborder="0" allowfullscreen&gt;&lt;/iframe&gt;&lt;/div&gt;
&lt;p&gt;A time lapse of the Milky Way over Casini Ranch campground near Duncan Mills, California.&lt;/p&gt;
</description><a10:link rel="alternate" href="https://ithoughthecamewithyou.com/post/milky-way-over-casini-ranch?utm_source=ithcwy&amp;utm_medium=RSS&amp;utm_campaign=fromrss" /><a10:updated>2026-05-18T19:58:46Z</a10:updated><a10:rights type="text">(c) copyright Robert Ellison</a10:rights></item></channel></rss>