Train Pages

Tech Blog

RTAC Software

Videos

Model Train-related Notes Blog -- these are personal notes and musings on the subject of model train control, automation, electronics, or whatever I find interesting. I also have more posts in a blog dedicated to the maintenance of the Randall Museum Model Railroad.

2026-01-30 - Temperature & Humidity Sensor

Category Misc

Regularly each year we have a number of mechanical failures -- typically broken solder joints -- which are only explained by possible track thermal expansion. It’s a known issue with model railroads: the metal rails tend to shrink or expand based on temperature, and the wood workbench tends to shift with humidity. Sometimes the effects are drastic and visible, some other times they are more subtle. Thus I decided to install a simple Tapo T310 temperature & humidity sensor. I can then query it using a Linux script, and display the results in a Google Analytics dashboard:

One of the things I’m currently tracking is the min/max for temperature and relative humidity per day:

The script captures all the values hourly, then computes the min/max at the end of the day. It was a bit tricky and interesting to be able to automatically backfill a day min/max value to GA4 in a way compatible with the Looker Studio dashboard.

The hourly crontab script uses python-kasa in a virtual environment:

if [[ ! -x "bin/kasa" ]]; then

    echo "## Run _setup.sh first."

    exit 1

fi

function c_to_f() {

    local C="$1"

    python -c "print(round(32+$C*9/5,1))"

}

function on_minute() {

    bin/kasa --host "$IP" --username "$NAME" --password "$PASS" --type smart --json state > "$TMP"

    C=$(grep current_ "$TMP")

    TC=$(echo "$C" | sed -n '/\"current_temp\"/s/.*: \([0-9.-]\+\).*\+/\1/p')

    H=$(echo "$C" | sed -n '/\"current_humidity\"/s/.*: \([0-9.-]\+\).*\+/\1/p')

    if [[ -z "$TC" || -z "$H" ]]; then

        echo "No data"

        return

    fi

    TF=$(c_to_f "$TC")

    echo -n "Data: C=$TC, F=$TF, H=$H"

    grep \"at_low_battery\" "$TMP"

    bin/event_ga4.sh --user t310__ "t310_c_val" "$TC"

    bin/event_ga4.sh --user t310__ "t310_f_val" "$TF"

    bin/event_ga4.sh --user t310__ "t310_h_val" "$H"

    if grep -qs "$YMD,$HHMM," "$CSV"; then

        echo "## WARNING skipping dup: $CSV_LINE >> $CSV"

    else

        CSV_LINE="$YMD,$HHMM,$TC,$H"

        echo "$CSV_LINE" >> "$CSV"

    fi

}

The events are sent using the GA4 Measurement Protocol; the script is quite rustic yet gets the job done:

ACTION="$1"

VALUE="$2"

# These dates are supposed to be in the GA4's property timezone.

DATE_SEC=$(date $ISO_DATE +%Y%m%d%H%M%S)

DATE_MIN=$(date $ISO_DATE +%Y%m%d%H%M)

# The Unix Timestamp must be in *micro*seconds and is supposed to be UTC.

UTC_MICROS=$(date $ISO_DATE --utc +%s000000)

DATA="{'client_id':'$CL_ID','events':[{'name':'$ACTION','params':{'items':[]"

# Note: per GA4 doc, event value expects a currency.

if [[ -n "$VALUE" ]]; then DATA="$DATA,'value':$VALUE,'currency':'USD'"; fi

if [[ -n "$CAT"   ]]; then DATA="$DATA,'event_category':'$CAT'"; fi

if [[ -n "$LABEL" ]]; then DATA="$DATA,'event_label':'$LABEL'"; fi

DATA="$DATA,'date_sec':'$DATE_SEC'"

DATA="$DATA,'date_min':'$DATE_MIN'"

DATA="$DATA,'timestamp_micros':'$UTC_MICROS'"

DATA="$DATA}}]}"

GA_URL="$GA_URL?api_secret=$APP_SEC&measurement_id=$GA_ID"

R=$(curl $CURL_OPT --data "$DATA" --write-out "%{http_code}" "$GA_URL")

if [[ "$R" == "204" ]]; then

    echo "GA4 OK $R"

fi

This is just an excerpt of both scripts. I skipped argument parsing and validation, as well as the exponential timeout retry loop on failure. Generating JSON payloads via string expansion is fun -- bash heredocs are overrated. The date_sec and date_min are custom event properties which are what allow backfilling into Looker Studio time charts properly.

At the end of the year, I’ll probably import the CSV into Google Sheets and see if I can find some interesting correlation with actual events. Based on prior experience, we know we will have issues. They seem to happen yearly pretty much at the same seasonable time, which can’t be a coincidence.


  Generated on 2026-01-30 by Rig4j 0.1-Exp-bc668ce