Feline stationary angular momentum exercise habits, a temporal analysis.

Domesticated Feline engaged in stationary angular momentum exercise

Cat Wheel Tracker / Odometer

I want to track my cat’s exercise activity on the cat wheel, my reasons for building this is the same as what killed the cat, curiosity (and also I got a tiny bit of spare time during parenting leave).

I want to build this mostly to validate my belief that the cat religiously uses the wheel at 4am to make as much noise as possible (with the objective of waking me up):

To that end I need to collect data off the cat wheel to measure and record her activity:

  • Time of day/day of week usage patterns

  • Top speed achieved

  • Total travel time

  • Longest continuous stint

  • Average stint speed

I also want to know if the moon has anything to do with her patterns and uncover any other mysterious feline correlations.

Non-Functional Requirements:

  • Minimize maintenance and build effort

  • Maximize reliability

  • Use equipment already in my lab (do not buy any more dev boards!)

  • Eliminate risk to the cat and children from magnet ingestion, electrocution and strangulation

I want to get this done quick and dirty so I work with tools I have / know already. They are not necessarily the best tools for the job, but they are in my toolbox and will work for what I want. You should be able to follow along and build your own, maybe we can have a web3 decentralized feline cat-tivity blockchain.

Components

What you will need:

  1. Cat Wheel (I have a “One Fast Cat Wheel”)

  2. One specimen of live domesticated Felis catus with an penchant for stationary angular momentum.

  3. M5Stack Core Ink (This is what I had on hand, any ESP32 with Wifi will do the job here). The display is not necessary and in my case not easily visible.

  4. CT10 Encapsulated dry reed switch single-pole, single throw (SPST), normally open.

  5. 2x N52 Neodymium Disc Magnets.

  6. USB-C Power supply.

  7. Hot glue gun.

  8. Electrical tape and Mars bar wrappers for assembly.

Catwheel OdometerComponents

Wheel Setup

I have a One Fast Cat Wheel which I measured the diameter as 1.08 so that comes to a circumference of 3.14159 * 1.08 = 3.3929172

I have installed 2 magnets equidistant so that I can catch every half rotation and get slightly more accurate speed and distance readings.

The magnets are secured with a glue gun to eliminate the possibility of children or cats picking them out and eating them. Eating 1 magnet is fine and will safely be passed, but eating two is a severe medical emergency.

Magnet attachment

Sensor position

I am mounting The C10 sensor directly into pin 25 off the 3V offset to the left so the sensor sits more towards the middle. This is the most natural place for the sensor to live without additional build effort.

I did consider mounting the M5 somewhere the display is easily visible and running wires to get the sensor in position. I decided hiding the M5 under the wheel reduces the risk of the kids causing some type of equipment, cat or child failure.

M5 Sensor Hook up

However this proved unreliable, since when the cat would go fast, the wheel would hit the device causing a noise that scared or deterred the cat and also detached the device a few times. In the end I attached the M5 to the base of the wheel and ran some leads to secure the sensor on the rail.

Final Sensor Position

Final M5 Position

Final Assembly

The C10 will fail if the pins are bent too aggressively near the encapsulation.

ESP32 and Sensor

I have a bunch of different dev boards but the M5 Core Ink gave me a form factor that puts the magnet in the perfect position without having to build any standoffs, drilling or wiring. In the end I had to do this to an extent.

With the new position of the M5 I used a small battery box and breadboard to protect the sensor and reliably mount and position it, this also allows a LED to be installed inline to more easily calibrate the positioning (rather than depending on software).

The accuracy is 100% when the magnet and the sensor align perfectly. I set the green light to flash on each detection so that I can observe that the magnets are accurately detected.

We are getting the total number of detections and if the cat goes idle for more than 60 seconds we call that a stint and upload stint summary to Opensearch which looks like this:

{
    "totalDistance": 32.23,
    "averageSpeed": 1.4,
    "detectionsCount": 20,
    "startTime": 1714292171477,
    "endTime": 1714292254426
}

At the same time we send the individual detections to the detection-index:

{
    "time": 675455501,
    "speed": 5.62,
    "unixTimestamp": 1714292325524,
    "distance": 1.7
}

This detection-index data is not really useful, but I do want to process it one day to find the maximum speed. I could have done that on chip and pass the max, min values as new fields in the summary data but the dev environment to push code to this M5 is terribly delicate and I never want to go through that again.

Opensearch setup

To store the data I am using Opensearch (The AWS fork of Elastic Search) TLS and authentication is turned off because I do not want to deal with TLS on the ESP and I do not want to have to worry about expiring certs.

Getting data into the index is very simple there is a bit of setup to get the index right.

Opensearch

Running a minimal Opensearch cluster that includes Opensearch Dashboards (Kibana) in Kubernetes with authentication and TLS disabled.

#k8s
apiVersion: apps/v1
kind: Deployment
metadata:
    name: opensearch-cluster
    namespace: opensearch
spec:
    replicas: 1
    selector:
        matchLabels:
            app: opensearch
    template:
        metadata:
            labels:
                app: opensearch
        spec:
            securityContext:
                fsGroup: 1000
            containers:
                - name: opensearch-node
                  # image: opensearchproject/opensearch:2.13.0
                  # rebuilt from above in docker file in gitlab registry
                  image: gitlab.cetinich.net:5050/cetinich/k8s/os-main:latest
                  imagePullPolicy: Always
                  # this holds bash open so you can shell in and fix if needed
                  # command: ["/bin/bash", "-c", "tail -f /dev/null"]
                  env:
                      - name: cluster.name
                        value: opensearch-cluster
                      - name: node.name
                        value: opensearch-node
                      - name: discovery.seed_hosts
                        value: opensearch-node
                      - name: cluster.initial_cluster_manager_nodes
                        value: opensearch-node
                      - name: bootstrap.memory_lock
                        value: 'true'
                      - name: OPENSEARCH_JAVA_OPTS
                        value: -Xms512m -Xmx512m
                      - name: s3.client.default.region
                        value: ap-southeast-2
                      - name: AWS_ACCESS_KEY_ID
                        valueFrom:
                            secretKeyRef:
                                name: aws-backup-secret-write
                                key: AWS_ACCESS_KEY_ID
                      - name: AWS_SECRET_ACCESS_KEY
                        valueFrom:
                            secretKeyRef:
                                name: aws-backup-secret-write
                                key: AWS_SECRET_ACCESS_KEY
                      - name: OPENSEARCH_INITIAL_ADMIN_PASSWORD
                        value: SOME_VALUE
                  volumeMounts:
                      - name: opensearch-data
                        mountPath: /usr/share/opensearch/data
                      - name: localtime
                        mountPath: /etc/localtime
                        readOnly: true
            volumes:
                - name: opensearch-data
                  persistentVolumeClaim:
                      claimName: opensearch-data-pvc
                - name: localtime
                  hostPath:
                      path: /etc/localtime
                      type: FileOrCreate
            imagePullSecrets:
                - name: regcred

---
apiVersion: v1
kind: PersistentVolume
metadata:
    name: pv-nuc1
    namespace: opensearch
    annotations:
        pv.beta.kubernetes.io/gid: '1000'
spec:
    capacity:
        storage: 20Gi
    accessModes:
        - ReadWriteOnce
    storageClassName: local-storage-es
    persistentVolumeReclaimPolicy: Retain
    hostPath:
        path: /mnt/iscsi/ssd/k8s/opensearch

---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
    name: opensearch-data-pvc
    namespace: opensearch
spec:
    accessModes:
        - ReadWriteOnce
    storageClassName: local-storage-es
    resources:
        requests:
            storage: 20Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
    name: opensearch-dashboards
    namespace: opensearch
spec:
    replicas: 1
    selector:
        matchLabels:
            app: opensearch-dashboards
    template:
        metadata:
            labels:
                app: opensearch-dashboards
        spec:
            containers:
                - name: opensearch-dashboards
                  image: opensearchproject/opensearch-dashboards:2.13.0
                  ports:
                      - containerPort: 5601
                  env:
                      - name: OPENSEARCH_HOSTS
                        value: '["http://opensearch-node:9200"]'
                      - name: DISABLE_SECURITY_DASHBOARDS_PLUGIN
                        value: 'true'
                  volumeMounts:
                      - name: localtime
                        mountPath: /etc/localtime
                        readOnly: true
            volumes:
                - name: localtime
                  hostPath:
                      path: /etc/localtime
                      type: FileOrCreate

---
apiVersion: v1
kind: Service
metadata:
    name: opensearch-service
    namespace: opensearch
spec:
    selector:
        app: opensearch
    ports:
        - name: rest
          port: 9200
          targetPort: 9200
        - name: performance-analyzer
          port: 9600
          targetPort: 9600
    type: LoadBalancer

---
# Internal in cluster service
apiVersion: v1
kind: Service
metadata:
    name: opensearch-node
    namespace: opensearch
spec:
    selector:
        app: opensearch
    ports:
        - name: rest
          port: 9200
          targetPort: 9200
        - name: performance-analyzer
          port: 9600
          targetPort: 9600
    type: ClusterIP

---
# External service
apiVersion: v1
kind: Service
metadata:
    name: opensearch-dashboards-service
    namespace: opensearch
    annotations:
        tailscale.com/expose: 'true'
        tailscale.com/hostname: 'kibana'
spec:
    selector:
        app: opensearch-dashboards
    ports:
        - name: dashboards
          port: 80
          targetPort: 5601
    type: LoadBalancer

Index setup

Opensearch will not automatically assign the correct data type for a unix timestamp in milliseconds, so you need to set the mapping for the index.

summary-index

{
    "properties": {
        "startTime": {
            "format": "epoch_millis",
            "type": "date"
        },
        "endTime": {
            "format": "epoch_millis",
            "type": "date"
        }
    }
}

detection-index

{
    "properties": {
        "unixTimestamp": {
            "format": "epoch_millis",
            "type": "date"
        }
    }
}

Scripted fields for hour of day histogram

I want to make a histogram based on the hour of the day to see when the cat is most active, I should have put this field in the ESP code, but I already completed the install and the interruption to cat wheel service seemed to have caused Luna much distress and great inconvenience to the point where I was attacked for meddling with the wheel. Rather than risk another cat attack, I did it the long way and extracted the data on the Opensearch side.

I need the hourOfDay and dayOfWeek to exist in the index in order to generate the histogram. Whilst I doubt the cat knows what day it is, she may change her behavior based on her observations of my patterns of behavior that vary over each day of the week. For example, Brent is not leaving the house when he normally does! it must be one of those days where he stays home and plays cat games! 🐈‍⬛:

  "script_fields": {
    "hourOfDay": {
      "script": {
        "source": "doc['startTime'].value.getHour()",
        "lang": "painless"
      }
    },
    "dayOfWeek": {
      "script": {
        "source": "doc['startTime'].value.getDayOfWeek()",
        "lang": "painless"
      }
    }
  },

The problem here is the scripted fields are handled as a number and loose the time class and all the automatic TZ adjustments that would normally happen for a date field in Kibana / Opensearch dashboards. So we need to use painless script to manually adjust the number to get numbers that make sense using a hard coded TZ offset.

// # We are in +8 so...
def offset = 8;
def offsetSeconds = doc['startTime'].value.getOffset().getTotalSeconds();
def offsetHours = (offsetSeconds / 3600) + offset;
def adjustedHour = doc['startTime'].value.getHour() + offsetHours % 24;
return adjustedHour

After updating the scripted fields we need to re-index. The Opensearch Dashboard UI only allows re-indexing into a new index. I wanted to re-index in place so I used the _update_by_query API:

curl -X POST   http://opensearch.cetinich.net:9200/summary-index/_update_by_query

Note If you try and use /_reindex you will get this error:

curl -X POST   http://opensearch.cetinich.net:9200/_reindex  -H 'Content-Type: application/json'   -d '{ "source": { "index": "summary-index" }, "dest": { "index": "summary-index" } }'
{"error":{"root_cause":[{"type":"action_request_validation_exception","reason":"Validation Failed: 1: reindex cannot write into an index its reading from [summary-index];"}],"type":"action_request_validation_exception","reason":"Validation Failed: 1: reindex cannot write into an index its reading from [summary-index];"},"status":400}
curl -X POST   http://opensearch.cetinich.net:9200/_reindex  -H 'Content-Type: application/json'   -d '{ "source": { "index": "summary-index" }, "dest": { "index": "summary-index" } }'

curl    http://opensearch.cetinich.net:9200/summary-index

ESP main.cpp

There is some software debounce on the sensor pin 25 that is set up based on the top speed of a cat ~35 KM/h, this way if the cat hops off and damped oscillations by chance occur with the magnet directly above the sensor, the oscillations will not record a cat traveling at Mach 3.

#include <Arduino.h>
#include <M5CoreInk.h>
#include <WiFi.h>
#include <time.h>
#include <envsensors.hpp>
#include "esp_adc_cal.h"
#include "icon.h"
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>

#define WIFI_RETRY_CONNECTION 10

Ink_Sprite TimePageSprite(&M5.M5Ink);

RTC_TimeTypeDef RTCtime, RTCTimeSave;
RTC_DateTypeDef RTCDate;
uint8_t second = 0, minutes = 0;

const char *NTP_SERVER = "sg.pool.ntp.org";
const char *TZ_INFO = "SGT-8"; // enter your time zone (https://remotemonitoringsystems.ca/time-zone-abbreviations.php)

volatile bool toFlash = false;

tm timeinfo;
time_t now;
long unsigned lastNTPtime;
unsigned long startMillis;

void set_led(bool status)
{
    digitalWrite(LED_EXT_PIN, !status);
}

void flash_led(int times, int delayTime)
{
    set_led(false);
    for (int i = 0; i < times; i++)
    {
        set_led(true);
        delay(delayTime);
        set_led(false);
        delay(delayTime);
    }
}

double getDoubleValueFromOs()
{
    WiFiClient client;
    HTTPClient http;

    String url = String(OPENSEARCH_API) + "/summary-index/_search";
    String jsonData = "{\"size\":0,\"query\":{\"match_all\":{}},\"aggregations\":{\"sum_distance\":{\"sum\":{\"field\":\"totalDistance\"}}}}";
    http.begin(client, url);
    int httpCode = http.POST(jsonData);
    Serial.println("httpCode: " + String(httpCode) + " url: " + url);

    double value = 0;
    if (httpCode == 200)
    {
        String payload = http.getString();
        Serial.println("payload: " + payload);

        DynamicJsonDocument doc(1024);
        deserializeJson(doc, payload);
        const char *getValue = doc["aggregations"]["sum_distance"]["value"];

        if (getValue != NULL && strlen(getValue) > 0 && isDigit(getValue[0]))
        {
            value = atof(getValue);
        }
    }

    http.end();
    client.stop();
    return value;
}

void drawImageToSprite(int posX, int posY, image_t *imagePtr, Ink_Sprite *sprite)
{
    sprite->drawBuff(posX, posY,
                     imagePtr->width, imagePtr->height, imagePtr->ptr);
}

void drawTotalDistance()
{

    char distanceCharArray[10];
    double historicalDistance = getDoubleValueFromOs();
    Serial.print("Historical Distance loaded from redis: ");
    Serial.println(historicalDistance);
    // Draw the total distance traveled at the top center of the screen
    int roundedDistance = round(historicalDistance); // Round the distance to the nearest whole number
    String distanceString = String(roundedDistance);
    distanceString.toCharArray(distanceCharArray, distanceString.length() + 1);
    String distanceStringObj(distanceCharArray); // Convert distanceCharArray to a String object

    Serial.println(distanceStringObj.c_str());
    TimePageSprite.drawString(10, 10, "Total Distance", &AsciiFont8x16);
    TimePageSprite.drawString(10, 20, distanceStringObj.c_str(), &AsciiFont24x48);
}

void drawTime(RTC_TimeTypeDef *time)
{
    drawImageToSprite(10, 76, &num55[time->Hours / 10], &TimePageSprite);
    drawImageToSprite(50, 76, &num55[time->Hours % 10], &TimePageSprite);
    drawImageToSprite(90, 76, &num55[10], &TimePageSprite);
    drawImageToSprite(110, 76, &num55[time->Minutes / 10], &TimePageSprite);
    drawImageToSprite(150, 76, &num55[time->Minutes % 10], &TimePageSprite);
}

void drawDate(RTC_DateTypeDef *date)
{
    int posX = 15, num = 0;
    int posY = 154;
    for (int i = 0; i < 4; i++)
    {
        num = (date->Year / int(pow(10, 3 - i)) % 10);
        drawImageToSprite(posX, posY, &num18x29[num], &TimePageSprite);
        posX += 17;
    }
    drawImageToSprite(posX, posY, &num18x29[10], &TimePageSprite);
    posX += 17;

    drawImageToSprite(posX, posY, &num18x29[date->Month / 10 % 10], &TimePageSprite);
    posX += 17;
    drawImageToSprite(posX, posY, &num18x29[date->Month % 10], &TimePageSprite);
    posX += 17;

    drawImageToSprite(posX, posY, &num18x29[10], &TimePageSprite);
    posX += 17;

    drawImageToSprite(posX, posY, &num18x29[date->Date / 10 % 10], &TimePageSprite);
    posX += 17;
    drawImageToSprite(posX, posY, &num18x29[date->Date % 10], &TimePageSprite);
    posX += 17;
}

void drawWarning(const char *str)
{
    M5.M5Ink.clear();
    TimePageSprite.clear(CLEAR_DRAWBUFF | CLEAR_LASTBUFF);
    drawImageToSprite(76, 40, &warningImage, &TimePageSprite);
    int length = 0;
    while (*(str + length) != '\0')
        length++;
    TimePageSprite.drawString((200 - length * 8) / 2, 100, str, &AsciiFont8x16);
    TimePageSprite.pushSprite();
}

void drawTimePage()
{
    M5.rtc.GetTime(&RTCtime);
    drawTime(&RTCtime);
    minutes = RTCtime.Minutes;
    M5.rtc.GetDate(&RTCDate);
    drawDate(&RTCDate);
    TimePageSprite.pushSprite();
}

void flushTimePage()
{
    M5.M5Ink.clear();
    TimePageSprite.clear(CLEAR_DRAWBUFF | CLEAR_LASTBUFF);
    // drawTimePage();

    M5.rtc.GetTime(&RTCtime);
    if (minutes != RTCtime.Minutes)
    {
        M5.rtc.GetTime(&RTCtime);
        M5.rtc.GetDate(&RTCDate);

        drawTime(&RTCtime);
        drawDate(&RTCDate);
        drawTotalDistance();
        TimePageSprite.pushSprite();
        minutes = RTCtime.Minutes;
    }

    M5.update();

    M5.M5Ink.clear();
    TimePageSprite.clear(CLEAR_DRAWBUFF | CLEAR_LASTBUFF);
}

struct Detection
{
    unsigned long time;
    double speed;
    unsigned long long unixTimestamp; // with millis
    double distance;
};
Detection detections[2000];
volatile int detectionCount = 0;

volatile unsigned long startTime = 0;
const double diameter = 1.08;
const double circumference = 3.14159 * diameter;
//  = 3.39292; // Wheel circumference in meters
const int magnetCount = 2; // Number of magnets on the wheel
volatile unsigned long lastDetectionTime = 0;
volatile double speed = 0.0;
volatile double distance = 0.0;
volatile double totalDistance = 0.0;

void wifiInit()
{
    Serial.print("[WiFi] connecting to " + String(WIFI_SSID));
    WiFi.begin(WIFI_SSID, WIFI_PASS);
    int wifi_retry = 0;
    while (WiFi.status() != WL_CONNECTED && wifi_retry++ < WIFI_RETRY_CONNECTION)
    {
        Serial.print(".");
        delay(500);
    }
    if (wifi_retry >= WIFI_RETRY_CONNECTION)
        Serial.println(" failed!");
    else
        Serial.println(" connected!");
}

unsigned long long getUnixTimeWithMillis()
{
    struct timeval tv;
    gettimeofday(&tv, NULL);
    unsigned long long millisSinceEpoch = (unsigned long long)(tv.tv_sec) * 1000 + (unsigned long long)(tv.tv_usec) / 1000;
    return millisSinceEpoch;
}

void addDetection(unsigned long time, double speed)
{
    if (detectionCount < (sizeof(detections) / sizeof(detections[0])))
    {
        detections[detectionCount++] = {time, speed, getUnixTimeWithMillis(), distance};
    }
    // This doesn't handle overflow of detections array
}
void uploadData()
{
    Serial.println("Uploading data...");
    wifiInit();
    delay(500);
    if (detectionCount == 0)
    {
        Serial.println("Nothing to upload");
        return; // Nothing to upload
    }

    HTTPClient http;
    WiFiClient client;
    http.begin(client, OPENSEARCH_API);
    http.addHeader("Content-Type", "application/json");

    String api = OPENSEARCH_API;
    String url = api + "/summary-index/_doc";
    http.setURL(url);
    // Serial.println(" totalTimeHours: " + String(totalTimeHours));
    unsigned long long startTimeUnix = detections[0].unixTimestamp;
    unsigned long long endTimeUnix = detections[detectionCount - 1].unixTimestamp;
    double summaryElapsedMillis = endTimeUnix - startTimeUnix;
    double averageSpeedKmPerH = (totalDistance / summaryElapsedMillis) * 3600; // Speed in km/h
    String summary_payload = "{\"totalDistance\": " + String(totalDistance) + ", \"averageSpeed\": " + String(averageSpeedKmPerH) + ", \"detectionsCount\": " + String(detectionCount) + ", \"startTime\": " + String(startTimeUnix) + ", \"endTime\": " + String(endTimeUnix) + "}";
    int httpCode = http.POST(summary_payload);
    Serial.println("summary payload: " + summary_payload);
    Serial.println("httpCode for summary payload: " + String(httpCode) + " url: " + url);
    String detectionIndexUrl = api + "/detection-index/_doc";
    http.setURL(detectionIndexUrl);
    for (int i = 0; i < detectionCount; i++)
    {
        String payload = "{\"time\": " + String(detections[i].time) + ", \"speed\": " + String(detections[i].speed) + ", \"unixTimestamp\": " + String(detections[i].unixTimestamp) + ", \"distance\": " + String(detections[i].distance) + "}";
        Serial.println("detection payload: " + payload);
        int httpCode = http.POST(payload);
        Serial.println("httpCode: " + String(httpCode) + " url: " + detectionIndexUrl);
        Serial.print("HTTP Code: ");
        Serial.println(httpCode);
    }
    http.end();

    double historicalDistance = getDoubleValueFromOs();

    Serial.print("loaded historicalDistance value as: ");
    Serial.println(historicalDistance);
    historicalDistance += totalDistance;
    Serial.print("historicalDistance after adding it with totalDistance: ");
    Serial.println(historicalDistance);

    // Reset data after upload
    // TODO there is a chance this gets corrupted or crashes
    // if stint is getting uploaded or taking time due to network and a new stint begins.
    detectionCount = 0;
    totalDistance = 0.0;
    lastDetectionTime = 0;
    startTime = 0; // Reset start time after data upload
    // clear detections array
    memset(detections, 0, sizeof(detections));
    flash_led(3, 200);
    client.stop();
    Serial.println("Completed upload");
}

void IRAM_ATTR detectMagnet()
{
    unsigned long currentTime = millis();
    // Only calculate speed if lastDetectionTime is not 0 (i.e., not the first detection)
    if (lastDetectionTime != 0)
    {
        unsigned long timeDifference = currentTime - lastDetectionTime;
        // fix the timeDifference is overflowing use a better data type for timeDifference
        //  Debouncing
        if (timeDifference < 600)
        {
            return;
        };
        // Calculate speed and distance
        double timeElapsedSeconds = static_cast<double>(timeDifference) / 1000.0;
        distance = circumference / magnetCount;
        speed = (distance / timeElapsedSeconds) * 3600 / 1000; // Speed in m/s

        totalDistance += distance;

        Serial.print("Speed (m/s): ");
        Serial.print(speed);
        toFlash = true; // flashing during an interrupt not a good idea
    }
    lastDetectionTime = currentTime;
    addDetection(currentTime, speed);
}

float getBatVoltage()
{
    analogSetPinAttenuation(35, ADC_11db);
    esp_adc_cal_characteristics_t *adc_chars = (esp_adc_cal_characteristics_t *)calloc(1, sizeof(esp_adc_cal_characteristics_t));
    esp_adc_cal_characterize(ADC_UNIT_1, ADC_ATTEN_DB_11, ADC_WIDTH_BIT_12, 3600, adc_chars);
    uint16_t ADCValue = analogRead(35);

    uint32_t BatVolmV = esp_adc_cal_raw_to_voltage(ADCValue, adc_chars);
    float BatVol = float(BatVolmV) * 25.1 / 5.1 / 1000;
    return BatVol;
}

void checkBatteryVoltage(bool powerDownFlag)
{
    float batVol = getBatVoltage();
    Serial.printf("[BATT] Voltage %.2f\r\n", batVol);

    if (batVol > 3.2)
        return;

    drawWarning("Battery voltage is low");
    if (powerDownFlag == true)
    {
        M5.shutdown();
    }
    while (1)
    {
        batVol = getBatVoltage();
        if (batVol > 3.2)
            return;
    }
}

void checkRTC()
{
    M5.rtc.GetTime(&RTCtime);
    if (RTCtime.Seconds == RTCTimeSave.Seconds)
    {
        drawWarning("RTC Error");
        while (1)
        {
            if (M5.BtnMID.wasPressed())
                return;
            delay(10);
            M5.update();
        }
    }
}

unsigned long getTime()
{
    time_t now;
    struct tm timeinfo;
    if (!getLocalTime(&timeinfo))
    {
        return (0);
    }
    time(&now);
    return now;
}

void showTime(tm localTime)
{
    Serial.print("[NTP] ");
    Serial.print(localTime.tm_mday);
    Serial.print('/');
    Serial.print(localTime.tm_mon + 1);
    Serial.print('/');
    Serial.print(localTime.tm_year - 100);
    Serial.print('-');
    Serial.print(localTime.tm_hour);
    Serial.print(':');
    Serial.print(localTime.tm_min);
    Serial.print(':');
    Serial.print(localTime.tm_sec);
    Serial.print(" Day of Week ");
    if (localTime.tm_wday == 0)
        Serial.println(7);
    else
        Serial.println(localTime.tm_wday);
}

void saveRtcData()
{
    RTCtime.Minutes = timeinfo.tm_min;
    RTCtime.Seconds = timeinfo.tm_sec;
    RTCtime.Hours = timeinfo.tm_hour;
    RTCDate.Year = timeinfo.tm_year + 1900;
    RTCDate.Month = timeinfo.tm_mon + 1;
    RTCDate.Date = timeinfo.tm_mday;
    RTCDate.WeekDay = timeinfo.tm_wday;

    char timeStrbuff[64];
    sprintf(timeStrbuff, "%d/%02d/%02d %02d:%02d:%02d",
            RTCDate.Year, RTCDate.Month, RTCDate.Date,
            RTCtime.Hours, RTCtime.Minutes, RTCtime.Seconds);

    Serial.println("[NTP] in: " + String(timeStrbuff));

    M5.rtc.SetTime(&RTCtime);
    M5.rtc.SetDate(&RTCDate);
}

bool getNTPtime(int sec)
{
    {
        Serial.print("[NTP] sync.");
        uint32_t start = millis();
        do
        {
            time(&now);
            localtime_r(&now, &timeinfo);
            Serial.print(".");
            delay(10);
        } while (((millis() - start) <= (1000 * sec)) && (timeinfo.tm_year < (2016 - 1900)));
        if (timeinfo.tm_year <= (2016 - 1900))
            return false; // the NTP call was not successful

        Serial.print("now ");
        Serial.println(now);
        saveRtcData();
        char time_output[30];
        strftime(time_output, 30, "%a  %d-%m-%y %T", localtime(&now));
        Serial.print("[NTP] ");
        Serial.println(time_output);
    }
    return true;
}

void ntpInit()
{
    if (WiFi.isConnected())
    {
        configTime(0, 0, NTP_SERVER);
        // See https://github.com/nayarsystems/posix_tz_db/blob/master/zones.csv for Timezone codes for your region
        setenv("TZ", TZ_INFO, 1);
        if (getNTPtime(10))
        { // wait up to 10sec to sync
        }
        else
        {
            Serial.println("[NTP] Time not set");
            ESP.restart();
        }
        showTime(timeinfo);
        lastNTPtime = time(&now);
        startMillis = millis(); // initial start time
    }
}

struct MyRTCDate
{
    int Year;    // Year (the number of years since 1900, so you need to subtract 1900)
    int Month;   // Month of the year from 1 to 12 (0 to 11 in tm structure)
    int Date;    // Day of the month from 1 to 31
    int WeekDay; // Day of the week (not used for mktime)
};

struct MyRTCTime
{
    int Seconds; // Seconds after the minute from 0 to 59
    int Minutes; // Minutes after the hour from 0 to 59
    int Hours;   // Hours since midnight from 0 to 23
};

// Converts MyRTCDate and MyRTCTime to Unix timestamp
time_t convertToUnixTimestamp(MyRTCDate RTCDate, MyRTCTime RTCtime)
{
    struct tm timeStruct;

    timeStruct.tm_year = RTCDate.Year - 1900; // Year since 1900
    timeStruct.tm_mon = RTCDate.Month - 1;    // Month, 0 - 11
    timeStruct.tm_mday = RTCDate.Date;        // Day of the month, 1 - 31
    timeStruct.tm_hour = RTCtime.Hours;       // Hours, 0 - 23
    timeStruct.tm_min = RTCtime.Minutes;      // Minutes, 0 - 59
    timeStruct.tm_sec = RTCtime.Seconds;      // Seconds, 0 - 59
    timeStruct.tm_isdst = -1;                 // Is Daylight Saving Time, -1 for unknown

    // mktime converts a tm structure to time_t counting seconds since the Unix Epoch
    time_t unixTime = mktime(&timeStruct);

    return unixTime;
}

void setup()
{
    pinMode(LED_EXT_PIN, OUTPUT);
    set_led(false);
    M5.begin();
    Serial.println(__TIME__);
    M5.rtc.GetTime(&RTCTimeSave);
    M5.update();
    M5.M5Ink.clear();
    M5.M5Ink.drawBuff((uint8_t *)image_CoreInkWWellcome);
    delay(100);
    wifiInit();
    ntpInit();
    drawTotalDistance();
    checkBatteryVoltage(false);
    TimePageSprite.creatSprite(0, 0, 200, 200);
    drawTimePage();
    attachInterrupt(digitalPinToInterrupt(25), detectMagnet, FALLING); // 5 for top button on m5 core ink
}

void loop()
{
    flushTimePage();

    // Check for inactivity
    if (millis() - lastDetectionTime > 60000 && lastDetectionTime != 0)
    {                 //  60000 = 1 minute of inactivity
        uploadData(); // Upload data and reset
        flash_led(6, 200);
    }

    // every 12 hours syn to the ntp via ntpInit()
    if (millis() - startMillis > 43200000)
    {
        ntpInit();
        startMillis = millis();
    }

    if (toFlash)
    {
        flash_led(1, 150);
        toFlash = false;
    }

    if (M5.BtnPWR.wasPressed())
    {
        Serial.printf("Btn %d was pressed \r\n", BUTTON_EXT_PIN);
        digitalWrite(LED_EXT_PIN, LOW);
        // M5.shutdown();
    }

    M5.update();
}

Feline temporal exercise behavior analysis

That was a round about way to go to get this opensearch time of day graph, but it has validated that the cat clearly has a preference to excercise at 4am. She mostly sleeps during the day and at about 6pm she likes to do another round of excercise, she typically has the zoomies at 6pm so this all seems to make sense.

Time of day heatmap

The day of week over hour of day heat map was quite useless and not worthy of displaying here.

Future stuff

I looked at doing OTA flashes but the setup was too much effort and I hope to never touch this code again.

I was for some reason never able to read any data on the SRAM, it would have been nice to persist the total travel distance onboard but it never returns any data so instead we pull that from Open Search and display it on the e-ink:

curl -X GET \
 'http://opensearch.cetinich.net:9200/summary-index/_search' \
 -H 'Content-Type: application/json' \
 -d '{ "size": 0, "query": { "match_all": {} }, "aggregations": {
"sum_distance": { "sum": { "field": "totalDistance" } } } }' | jq ."aggregations".sum_distance.value

If I ever get time it would be nice to build in a treat dispense to reward Luna for breaking top speed or stint distance records.

Comments

comments powered by Disqus