Feline stationary angular momentum exercise habits, a temporal analysis.¶
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:
Cat Wheel (I have a “One Fast Cat Wheel”)
One specimen of live domesticated Felis catus with an penchant for stationary angular momentum.
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.
CT10 Encapsulated dry reed switch single-pole, single throw (SPST), normally open.
2x N52 Neodymium Disc Magnets.
USB-C Power supply.
Hot glue gun.
Electrical tape and Mars bar wrappers for assembly.
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.
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.
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.
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.
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