I'm going to preface this with a caveat: I'm a little bit pissed off. I bought a Bip 3 watch because I wanted to link it to Gadgetbridge and keep health data local.
Unfortunately it's turned out (jira-projects/MISC#34) that that's not possible: the manufacturer appears to have changed the underlying firmware and Gadgetbridge doesn't have support for it. That'd be ok(ish) if the Zepp app provided a way to access the data without having to give it to Google Fit.
I've raised this ticket to track efforts in getting something up and running
Activity
31-Jul-23 14:54
assigned to @btasker
31-Jul-23 14:54
moved from project-management-only/staging#7
31-Jul-23 14:54
assigned to @btasker
31-Jul-23 14:55
In theory there's probably a database in the phone's storage somewhere. However, it's not accessible via File Manager (the expected path is
/data/data/com.huami.watch.hmwatchmanager/databases/
) but that's probably because I'm not rooted.31-Jul-23 16:27
Looking in the DNS logs, the app talks to
api-mifit-de2.zepp.com
I was going to MiTM it, but it looks like someone's already done the work
There's some code at https://github.com/rolandsz/Mi-Fit-and-Zepp-workout-exporter/tree/master, but it doesn't lend itself well to automation (because it has to pop a browser for login).
But, I've used it to help capture the underlying apptoken - need to test it later to see whether they have a finite lifetime, or if it's a set value. If the latter, then we should be able to use it to talk to their API.
Unfortunately, it looks like the Zepp app doesn't trust user-installed CA certs, so MiTMing to get paths isn't straight forward.
So, I think the options are:
v1/sport/run/history.json
is valid so we may be able to work out logical paths from that)Doing
1.
will be entirely contingent on the validity of the tokens.2.
is doable but ridiculous: I shouldn't need to root my phone to extract data generated by a device strapped to my wrist.I'm quite keen not to do
3.
, I've got to quite a lot of trouble to push Google as far out of my life as I've currently managed, I don't really want to undo that.4.
is something of a last resort - I've got a few days to mess around before the window on that closes.5.
would be good, if a little overly complex. I guess a subset of5.
would be to see whether the app sends any intents when it syncs with the watch. It's just possible we might be able to do something with Tasker.31-Jul-23 16:33
The frustrating thing is it looks like they used to have a documented web api.
31-Jul-23 17:01
I think the answer, for the time being, is to sleep on it. My inclination at the moment though, is to return it and buy something that does work with gadgetbridge (the problem, if we were to stick with Amznfit, is it looks like compatability may depend on firmware version).
It's such a shame the Bangle.js 2 is only IP67 rated, as that's what I was originally looking at. I settled for the Bip because I wanted to wear it whilst swimming.
31-Jul-23 23:44
The token still seems to work - it's worth noting that it requires that
appplatform
andappname
headers also be submitted:curl -v \ -H "AppPlatform: web" \ -H "appname: com.xiaomi.hm.health" \ -H "apptoken: $APP_TOKEN" \ https://api-mifit-de2.huami.com/v1/sport/run/history.json
Results in json
{"code":1,"message":"success","data":{"next":-1,"summary":[]}}
So, this might have legs...
Had another search around for resources that might be of use. Hacking the Mi Fit API looks particularly hopeful
That said, the following doesn't currently work
curl -v \ -H "AppPlatform: web" \ -H "appname: com.xiaomi.hm.health" \ -H "apptoken: $APP_TOKEN" \ -X GET \ --data-urlencode 'query_type=summary' \ --data-urlencode 'device_type=android_phone' \ --data-urlencode 'userid=7074932982' \ --data-urlencode 'from_date=2023-07-30' \ --data-urlencode 'to_date=2023-08-01' \ https://api-mifit.huami.com/v1/data/band_data.json
Tomorrow, I might look at reregistering for the app with email/pass rather than SSO. My guess though, is that I may need a different endpoint.
01-Aug-23 10:12
I've created a new Zepp account using an email rather than SSO - obviously there's currently no data to return, but, things do look potentially hopeful (I added a print to show the response body)
ben@optimus:~/Downloads$ python3 mifit_api.py --email <redacted> --password <redacted> Logging in with email <redacted> Obtained access token Retrieveing mi band data {'code': 1, 'message': 'success', 'data': []}
And actually, thinking about it, the script has a hardcoded from + to date in it. Adjusting those so they are no longer in 2019 leads to a response
ben@optimus:~/Downloads$ python3 mifit_api.py --email <redacted> --password <redacted> Logging in with email <redacted> Obtained access token Retrieveing mi band data 2023-08-01 v = 6 Total sleep: 00:00 , deep sleep 00:00 , light sleep 00:00 , slept from 2023-07-31 00:00:00 until 2023-07-31 00:00:00 Total steps: 2234 , used 53 kcals , walked 1587 meters 09:59 - 10:17 725 steps light activity 10:19 - 10:35 860 steps slow walking 10:49 - 10:54 504 steps slow walking goal = 8000 tz = 0 algv = 2.12.33 sn = 2171F311006533 byteLength = 8 sync = 1690884430
Looks like we're cooking on gas, just need to turn it into something that can write out to InfluxDB
01-Aug-23 10:22
OK, I've dropped a copy of the original script from https://github.com/micw/hacking-mifit-api into a repo.
So we need to
01-Aug-23 10:31
Let's start here.
The API response data looks like this
{ "code":1, "message":"success", "data":[ { "uid":"<redacted>", "data_type":0, "date_time":"2023-08-01", "source": 256, "summary":"eyJ2Ijo2LCJzbHAiOnsic3QiOjE2OTA3NTgwMDAsImVkIjoxNjkwNzU4MDAwLCJvYnQiOi0xMTc3OTk4MzM2LCJlYnQiOjEyMiwiZHAiOjAsImx0IjowLCJ3ayI6MCwidXNyU3QiOi0xNDQwLCJ1c3JFZCI6LTE0NDAsIndjIjowLCJzdXBOYXAiOnRydWUsInN1cFJlbSI6ZmFsc2UsImlzIjowLCJsYiI6MCwidG8iOjAsImR0IjowLCJyaHIiOjAsInNzIjowLCJwcyI6MCwicGUiOjQ4MH0sInN0cCI6eyJ0dGwiOjIyMzQsImRpcyI6MTU4NywiY2FsIjo1Mywid2siOjI1LCJybiI6MCwicnVuRGlzdCI6ODcsInJ1bkNhbCI6Nywic3RhZ2UiOlt7InN0YXJ0Ijo1OTksInN0b3AiOjYxNywibW9kZSI6NywiZGlzIjo1MjEsImNhbCI6MTcsInN0ZXAiOjcyNX0seyJzdGFydCI6NjE5LCJzdG9wIjo2MzUsIm1vZGUiOjEsImRpcyI6NTk4LCJjYWwiOjE4LCJzdGVwIjo4NjB9LHsic3RhcnQiOjY0OSwic3RvcCI6NjU0LCJtb2RlIjoxLCJkaXMiOjM2MCwiY2FsIjoxMiwic3RlcCI6NTA0fV19LCJnb2FsIjo4MDAwLCJ0eiI6IjAiLCJhbGd2IjoiMi4xMi4zMyIsInNuIjoiMjE3MUYzMTEwMDY1MzMiLCJieXRlTGVuZ3RoIjo4LCJzeW5jIjoxNjkwODg0NDMwfQ==", "uuid": "null" } ] }
Each day is represented by a different dict within the list
data
. The attributesummary
is a base64 encoded JSON blobWe already know what some of these are:
slp
- Sleepstp
- StepsAnnoyingly, it doesn't appear to expose the extra stats that we wanted (may need to go ahead and put some effort into MiTMing if we want those).
For now, then, we won't worry too much about capturing those extra details.
01-Aug-23 10:36
mentioned in commit utilities/zepp_to_influxdb@dfe080653f432bc2776ff1f7fdcd026d31165fe0
Message
Import the InfluxDB client for jira-projects/MISC#35
We're not yet writing anything in, but the script should now take credentials from environment variables
01-Aug-23 10:38
I'm cheating a little here and re-using some of the work I did for my gadgetbridge_to_influxdb script.
Need to have the script turn the values in the API response into something that can be consumed by the newly added function.
The format it expects is a list of dicts, with each dict being of the form
{ "timestamp": r[0] * 1000000000, # Convert to nanos fields : { "intensity" : r[2], "steps" : r[3], "heart_rate" : r[5], "sleep" : r[6], "deep_sleep" : r[7], "rem_sleep" : r[8], }, tags : { "device" : devices[f"dev-{r[1]}"], "activity_kind" : r[4], "sample_type" : "activity" } }
01-Aug-23 11:07
mentioned in commit utilities/zepp_to_influxdb@ea2c3f3493cf3ee79ea02feb20a9958a599c27be
Message
Collect sleep data ready for submission into InfluxDB jira-projects/MISC#35
01-Aug-23 11:17
mentioned in commit utilities/zepp_to_influxdb@62c01dcb920918f5a4079372a388978327c62654
Message
Write sleep results onwards into InfluxDB (jira-projects/MISC#35)
This commit brings with it an unpleasant (but temporary) change.
Various variables have been moved to a global scope. This will be changed later when config is folded into a dict
01-Aug-23 11:31
mentioned in commit utilities/zepp_to_influxdb@7b4d39d3b365fd48cc795b972987f2f123c2704f
Message
Extract step data jira-projects/MISC#35
I've just spotted an issue though, the stage timestamps are not what I had expected. I thought they were essentially epoch/60 but they're actually the minute of the day (i.e. 600 = 10am).
So we need to adjust the way that these timestamps are converted
01-Aug-23 11:38
mentioned in commit utilities/zepp_to_influxdb@3f4b3be40132c4ce0a5b9ce7a6865f3529df7753
Message
Convert minutes of the day into an epoch timestamp (jira-projects/MISC#35)
This combines the record's day with minutes of the day to generate a timestamp
01-Aug-23 11:42
mentioned in commit utilities/zepp_to_influxdb@be2aac561b3f2a5ad5b0920b1f4b6e4e56b853b6
Message
Capture step goal and append device serial number as a tag (jira-projects/MISC#35)
01-Aug-23 12:31
mentioned in commit utilities/zepp_to_influxdb@a65b90704fd34b2d83a565d65547571981f64cfd
Message
Dynamically construct timerange to query (jira-projects/MISC#35)
By default we'll ask the API for the last 2 days, however this can be overridden using env var
QUERY_DURATION
01-Aug-23 12:44
mentioned in commit utilities/zepp_to_influxdb@65dd8dc86fd460a84394259f30428e78f0b29692
Message
Containerise application (jira-projects/MISC#35)
01-Aug-23 14:53
OK, as we've got a viable container, I've started setting up to have it as a Kubernetes cronjob.
Creating secrets
kubectl create secret generic zepp --from-literal='email=<redacted>' --from-literal='pass=<redacted>' kubectl create secret generic influxdbv1 \ --from-literal=influxdb_token='<redacted>' \ --from-literal=influxdb_org='<redacted>' \ --from-literal=influxdb_url='http://<redacted>:8086'
Defined the job
apiVersion: batch/v1 kind: Job metadata: name: zepp-to-influxdb spec: template: spec: containers: - name: zepp-to-influxdb image: bentasker12/zepp_to_influxdb:latest imagePullPolicy: IfNotPresent env: - name: INFLUXDB_BUCKET value: "telegraf" - name: INFLUXDB_MEASUREMENT value: "zepp" - name: QUERY_DURATION value: "2" - name: INFLUXDB_TOKEN valueFrom: secretKeyRef: name: influxdbv1 key: influxdb_token - name: INFLUXDB_ORG valueFrom: secretKeyRef: name: influxdbv1 key: influxdb_org - name: INFLUXDB_URL valueFrom: secretKeyRef: name: influxdbv1 key: influxdb_url - name: ZEPP_EMAIL valueFrom: secretKeyRef: name: zepp key: email - name: ZEPP_PASS valueFrom: secretKeyRef: name: zepp key: pass restartPolicy: OnFailure
Applied it
Listing jobs
ben@bumblebee:~/charts$ kubectl get job --all-namespaces NAMESPACE NAME COMPLETIONS DURATION AGE default zepp-to-influxdb 1/1 11s 3m1s ingress-nginx ingress-nginx-admission-create 1/1 9s 6d5h ingress-nginx ingress-nginx-admission-patch 1/1 10s 6d5h
Get the name of the pod that ran the job
kubectl describe job zepp-to-influxdb | grep "Created pod" | awk '{print $NF}'
Pass the podname to
kubectl logs
kubectl logs zepp-to-influxdb-89qbv Logging in with email <redacted> Obtained access token Retrieving mi band data 2023-08-01 Skipped v = 6 Skipped tz = 0 Skipped algv = 2.12.33 Skipped byteLength = 8
01-Aug-23 14:54
As that worked, then, defining a cronjob
apiVersion: batch/v1 kind: CronJob metadata: name: zepp-to-influxdb spec: schedule: "50 * * * *" jobTemplate: spec: template: spec: containers: - name: zepp-to-influxdb image: bentasker12/zepp_to_influxdb:latest imagePullPolicy: IfNotPresent env: - name: INFLUXDB_BUCKET value: "telegraf" - name: INFLUXDB_MEASUREMENT value: "zepp" - name: QUERY_DURATION value: "2" - name: INFLUXDB_TOKEN valueFrom: secretKeyRef: name: influxdbv1 key: influxdb_token - name: INFLUXDB_ORG valueFrom: secretKeyRef: name: influxdbv1 key: influxdb_org - name: INFLUXDB_URL valueFrom: secretKeyRef: name: influxdbv1 key: influxdb_url - name: ZEPP_EMAIL valueFrom: secretKeyRef: name: zepp key: email - name: ZEPP_PASS valueFrom: secretKeyRef: name: zepp key: pass restartPolicy: OnFailure
Enabling
It should now run once an hour. It looks like the watch syncs to the app (and the app to servers) less frequently than that, so we shouldn't really need it to run more regularly.
01-Aug-23 15:20
mentioned in commit utilities/zepp_to_influxdb@e3462ece25de395a234309d6b9f9e655efce32c2
Message
Add README (jira-projects/MISC#35)
01-Aug-23 15:26
Querying stats out then:
Total steps per day for the last week
SELECT max("total_steps") AS "total_steps" FROM "telegraf"."autogen"."zepp" WHERE time > now() - 7d AND "activity_type"='steps' GROUP BY time(1d) FILL(null)
Calories burnt per day for the last week
SELECT max("calories") AS "calories" FROM "telegraf"."autogen"."zepp" WHERE time > now() - 7d AND "activity_type"='steps' GROUP BY time(1d) FILL(null)
Number of recorded activities per day
SELECT max("recorded_activities") AS "activites" FROM "telegraf"."autogen"."zepp" WHERE time > now() - 7d GROUP BY time(1d) FILL(null)
Time spent in activities per day
SELECT sum("activity_duration_m")*60 AS "activity_duration_s" FROM "telegraf"."autogen"."zepp" WHERE time > now() - 7d AND "activity_type"!='steps' GROUP BY time(1d) FILL(null)
Sleep stats
SELECT max("deep_sleep_min") AS "deep_sleep_min", max("rem_sleep_min") AS "rem_sleep_min", max("total_sleep_min") AS "total_sleep_min" FROM "telegraf"."autogen"."zepp" WHERE time > now() - 7d AND "activity_type"='sleep' GROUP BY time(1d) FILL(null)
01-Aug-23 15:48
I've created a new public repo at https://github.com/bentasker/zepp_to_influxdb and pushed into that.
I think we're up and running - anything extra should be dropped into a new ticket within the relevant project.
01-Aug-23 16:49
I've set up some downsampling of the data - it's a little finger in the air, but should allow me to extract interesting stats
downsample_zepp_stepcount: # Name for the task name: "Downsample Zepp Step count" influx: home1x # Query the last n mins period: 180 # Window into n minute blocks window: 60 # taken from in_bucket bucket: telegraf measurement: - zepp fields: - total_steps - goal filters: - '(r.activity_type == "steps" or r._field == "goal")' aggregates: max: as: total_steps: "steps" goal: "goal" output_influx: - influx: home2xreal output_bucket: health downsample_zepp_distance_and_calories: # Name for the task name: "Downsample Zepp distance and calories" influx: home1x # Query the last n mins period: 180 # Window into n minute blocks window: 60 # taken from in_bucket bucket: telegraf measurement: - zepp fields: - calories - distance_m filters: - 'r.activity_type == "steps"' aggregates: max: output_influx: - influx: home2xreal output_bucket: health downsample_zepp_mean_distance_and_calories: # Name for the task name: "Downsample Zepp Activity distance and calories" influx: home1x # Query the last n mins period: 180 # Window into n minute blocks window: 60 # taken from in_bucket bucket: telegraf measurement: - zepp fields: - calories - distance_m - activity_duration_m filters: - 'r.activity_type != "steps"' aggregates: mean: field_suffix: "_mean" count: field_suffix: "_count" max: field_suffix: "_max" output_influx: - influx: home2xreal output_bucket: health downsample_zepp_activitycount: # Name for the task name: "Downsample Zepp activity count" influx: home1x # Query the last n mins period: 180 # Window into n minute blocks window: 60 # taken from in_bucket bucket: telegraf measurement: - zepp fields: - recorded_activities - recorded_light_activity_events - recorded_slow_walking_events - recorded_fast_walking_events - recorded_running_events aggregates: max: output_influx: - influx: home2xreal output_bucket: health downsample_zepp_sleepdata: # Name for the task name: "Downsample Zepp sleep data" influx: home1x # Query the last n mins period: 180 # Window into n minute blocks window: 60 # taken from in_bucket bucket: telegraf measurement: - zepp filters: - 'r.activity_type == "sleep"' fields: - deep_sleep_min - rem_sleep_min - total_sleep_min aggregates: max: output_influx: - influx: home2xreal output_bucket: health downsample_zepp_sleepdata_mean: # Name for the task name: "Downsample Zepp per-stage sleep data" influx: home1x # Query the last n mins period: 180 # Window into n minute blocks window: 60 # taken from in_bucket bucket: telegraf measurement: - zepp filters: - 'r.activity_type == "sleep_stage"' fields: - deep_sleep_min - rem_sleep_min - total_sleep_min aggregates: mean: field_suffix: "_mean" count: field_suffix: "_count" max: field_suffix: "_max" output_influx: - influx: home2xreal output_bucket: health downsample_zepp_sleepdata_count: # Name for the task name: "Downsample Zepp sleep counts" influx: home1x # Query the last n mins period: 180 # Window into n minute blocks window: 60 # taken from in_bucket bucket: telegraf measurement: - zepp fields: - recorded_sleep_events - recorded_light_sleep_events - recorded_deep_sleep_events aggregates: max: output_influx: - influx: home2xreal output_bucket: health
Broadly, what we're collecting is
Incremental daily totals:
Per-activity type:
Per Sleep Stage:
01-Aug-23 16:50
mentioned in commit sysconfigs/downsample_configs@3b592467c9d57f7e7993d2c308b51f9a6673df3a
Message
Add downsampling task for data added in jira-projects/MISC#35
04-Aug-23 13:22
I've made a few improvements since this comment. Issue tracking is in
utilities/zepp_to_influxdb
.