Files
pebble/src/fw/services/normal/activity/workout_service.c
T

641 lines
22 KiB
C

/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "workout_service.h"
#include "activity_algorithm.h"
#include "activity_calculators.h"
#include "activity_insights.h"
#include "activity_private.h"
#include "hr_util.h"
#include "apps/system_apps/workout/workout_utils.h"
#include "applib/app.h"
#include "applib/health_service.h"
#include "kernel/events.h"
#include "kernel/pbl_malloc.h"
#include "services/common/evented_timer.h"
#include "services/common/regular_timer.h"
#include "system/passert.h"
#include "util/time/time.h"
#include "util/units.h"
#include <os/mutex.h>
#define WORKOUT_HR_READING_TS_EXPIRE (SECONDS_PER_MINUTE)
#define WORKOUT_ENDED_HR_SUBSCRIPTION_TS_EXPIRE (10 * SECONDS_PER_MINUTE)
#define WORKOUT_ACTIVE_HR_SUBSCRIPTION_TS_EXPIRE (SECONDS_PER_HOUR)
#define WORKOUT_ABANDONED_NOTIFICATION_TIMEOUT_MS (55 * MS_PER_MINUTE)
#define WORKOUT_ABANDON_WORKOUT_TIMEOUT_MS (5 * MS_PER_MINUTE)
//! Allocated when a Workout is started
typedef struct CurrentWorkoutData {
ActivitySessionType type;
time_t start_utc;
time_t last_paused_utc;
time_t duration_completed_pauses_s;
int32_t duration_s;
int32_t steps;
int32_t distance_m;
// Pace
int32_t active_calories;
int32_t current_bpm;
time_t current_bpm_timestamp_ts; // Time since boot
HRZone current_hr_zone;
int32_t hr_zone_time_s[HRZoneCount];
int32_t hr_samples_sum;
int32_t hr_samples_count;
// Step count total from the last HealthEventMovementUpdate
int32_t last_event_step_count;
time_t last_movement_event_time_ts;
// Whether or not the current workout is paused
bool paused;
EventedTimerID workout_abandoned_timer;
} CurrentWorkoutData;
//! Persisted statically in RAM
typedef struct WorkoutServiceData {
PebbleRecursiveMutex *s_workout_mutex;
RegularTimerInfo second_timer;
time_t last_workout_end_ts;
time_t frontend_last_opened_ts;
HRMSessionRef hrm_session;
CurrentWorkoutData *current_workout;
} WorkoutServiceData;
static WorkoutServiceData s_workout_data;
static void prv_lock(void) {
mutex_lock_recursive(s_workout_data.s_workout_mutex);
}
static void prv_unlock(void) {
mutex_unlock_recursive(s_workout_data.s_workout_mutex);
}
static void prv_put_event(PebbleWorkoutEventType e_type) {
PebbleEvent event = {
.type = PEBBLE_WORKOUT_EVENT,
.workout = {
.type = e_type,
}
};
event_put(&event);
}
static int32_t prv_get_avg_hr(void) {
if (!s_workout_data.current_workout->hr_samples_count) {
return 0;
}
return ROUND(s_workout_data.current_workout->hr_samples_sum,
s_workout_data.current_workout->hr_samples_count);
}
static void prv_update_duration(void) {
// We can't just increment the time on a second callback because of the inaccuracy of our timer
// system. PBL-32523
// Instead, we keep track of a start_utc, paused_time, and last_paused_utc. With these
// we can accurately keep track of the total duration of the workout.
if (!workout_service_is_workout_ongoing()) {
return;
}
time_t now_utc = rtc_get_time();
time_t total_paused_time_s = s_workout_data.current_workout->duration_completed_pauses_s;
if (workout_service_is_paused()) {
const time_t duration_current_pause = now_utc - s_workout_data.current_workout->last_paused_utc;
total_paused_time_s += duration_current_pause;
}
s_workout_data.current_workout->duration_s =
now_utc - s_workout_data.current_workout->start_utc - total_paused_time_s;
}
static void prv_reset_hr_data(void) {
const time_t now_ts = time_get_uptime_seconds();
s_workout_data.current_workout->current_bpm = 0;
s_workout_data.current_workout->current_hr_zone = HRZone_Zone0;
s_workout_data.current_workout->current_bpm_timestamp_ts = now_ts;
}
// ---------------------------------------------------------------------------------------
static void prv_handle_movement_update(HealthEventMovementUpdateData *event) {
CurrentWorkoutData *wrkt_data = s_workout_data.current_workout;
const int32_t new_event_steps = event->steps;
const time_t now_ts = time_get_uptime_seconds();
if (new_event_steps < wrkt_data->last_event_step_count) {
PBL_LOG(LOG_LEVEL_WARNING, "Working out through midnight, resetting last_event_step_count");
wrkt_data->last_event_step_count = 0;
}
if (!workout_service_is_paused()) {
// Calculate the step delta
const uint32_t delta_steps = new_event_steps - wrkt_data->last_event_step_count;
wrkt_data->steps += delta_steps;
// Calculate the distance delta
const time_t delta_ms = (now_ts - wrkt_data->last_movement_event_time_ts) * MS_PER_SECOND;
const int32_t delta_distance_mm = activity_private_compute_distance_mm(delta_steps, delta_ms);
wrkt_data->distance_m += (delta_distance_mm / MM_PER_METER);
// Calculate active calories
const int32_t active_calories = activity_private_compute_active_calories(delta_distance_mm,
delta_ms);
wrkt_data->active_calories += active_calories;
}
// Reset the last event count regardless of whether we are paused
wrkt_data->last_event_step_count = new_event_steps;
wrkt_data->last_movement_event_time_ts = now_ts;
}
static void prv_handle_heart_rate_update(HealthEventHeartRateUpdateData *event) {
CurrentWorkoutData *wrkt_data = s_workout_data.current_workout;
if (event->is_filtered) {
// We don't care about median heart rate updates
return;
}
if (event->quality == HRMQuality_OffWrist) {
// Reset to zero for OffWrist readings
prv_reset_hr_data();
} else if (event->quality >= HRMQuality_Worst) {
const int prev_bpm_timestamp_ts = wrkt_data->current_bpm_timestamp_ts;
wrkt_data->current_bpm = event->current_bpm;
wrkt_data->current_hr_zone = hr_util_get_hr_zone(wrkt_data->current_bpm);
wrkt_data->current_bpm_timestamp_ts = time_get_uptime_seconds();
if (!workout_service_is_paused()) {
// TODO: Maybe apply smoothing
wrkt_data->hr_zone_time_s[wrkt_data->current_hr_zone] +=
wrkt_data->current_bpm_timestamp_ts - prev_bpm_timestamp_ts;
wrkt_data->hr_samples_count++;
wrkt_data->hr_samples_sum += event->current_bpm;
}
}
return;
}
// ---------------------------------------------------------------------------------------
bool workout_service_is_workout_type_supported(ActivitySessionType type) {
return type == ActivitySessionType_Walk ||
type == ActivitySessionType_Run ||
type == ActivitySessionType_Open;
}
// ---------------------------------------------------------------------------------------
T_STATIC void prv_abandon_workout_timer_callback(void *unused) {
workout_service_stop_workout();
}
// ---------------------------------------------------------------------------------------
T_STATIC void prv_abandoned_notification_timer_callback(void *unused) {
workout_utils_send_abandoned_workout_notification();
s_workout_data.current_workout->workout_abandoned_timer =
evented_timer_register(WORKOUT_ABANDON_WORKOUT_TIMEOUT_MS, false,
prv_abandon_workout_timer_callback, NULL);
}
// ---------------------------------------------------------------------------------------
T_STATIC void prv_workout_timer_cb(void *unused) {
if (!workout_service_is_workout_ongoing()) {
return;
}
prv_lock();
{
// Update the duration
prv_update_duration();
// Check to make sure our HR sample is still valid
const time_t now_ts = time_get_uptime_seconds();
const time_t age_hr_s = now_ts - s_workout_data.current_workout->current_bpm_timestamp_ts;
if (s_workout_data.current_workout->current_bpm != 0 &&
age_hr_s >= WORKOUT_HR_READING_TS_EXPIRE) {
// Reset HR reading. It has expired
prv_reset_hr_data();
}
}
prv_unlock();
}
// ---------------------------------------------------------------------------------------
void workout_service_health_event_handler(PebbleHealthEvent *event) {
if (!workout_service_is_workout_ongoing()) {
return;
}
prv_lock();
{
if (event->type == HealthEventMovementUpdate) {
prv_handle_movement_update(&event->data.movement_update);
} else if (event->type == HealthEventHeartRateUpdate) {
prv_handle_heart_rate_update(&event->data.heart_rate_update);
}
}
prv_unlock();
}
// ---------------------------------------------------------------------------------------
void workout_service_activity_event_handler(PebbleActivityEvent *event) {
if (!workout_service_is_workout_ongoing()) {
return;
}
if (event->type == PebbleActivityEvent_TrackingStopped) {
workout_service_pause_workout(true);
}
}
// ---------------------------------------------------------------------------------------
void workout_service_workout_event_handler(PebbleWorkoutEvent *event) {
if (!workout_service_is_workout_ongoing()) {
return;
}
// Handling this with an event because the timer needs to be called from KernelMain
if (event->type == PebbleWorkoutEvent_FrontendOpened) {
evented_timer_cancel(s_workout_data.current_workout->workout_abandoned_timer);
} else if (event->type == PebbleWorkoutEvent_FrontendClosed) {
s_workout_data.current_workout->workout_abandoned_timer =
evented_timer_register(WORKOUT_ABANDONED_NOTIFICATION_TIMEOUT_MS, false,
prv_abandoned_notification_timer_callback, NULL);
}
}
// ---------------------------------------------------------------------------------------
void workout_service_init(void) {
s_workout_data.s_workout_mutex = mutex_create_recursive();
}
// ---------------------------------------------------------------------------------------
// FIXME: We should probably handle this on KernelBG and not use the official app subscription
void workout_service_frontend_opened(void) {
PBL_ASSERT_TASK(PebbleTask_App);
prv_lock();
{
#if CAPABILITY_HAS_BUILTIN_HRM
s_workout_data.hrm_session =
sys_hrm_manager_app_subscribe(app_get_app_id(), 1, 0, HRMFeature_BPM);
#endif // CAPABILITY_HAS_BUILTIN_HRM
s_workout_data.frontend_last_opened_ts = time_get_uptime_seconds();
prv_put_event(PebbleWorkoutEvent_FrontendOpened);
}
prv_unlock();
}
// ---------------------------------------------------------------------------------------
// FIXME: We should probably handle this on KernelBG and not use the official app subscription
void workout_service_frontend_closed(void) {
PBL_ASSERT_TASK(PebbleTask_App);
prv_lock();
{
int32_t hr_time_left;
if (workout_service_is_workout_ongoing()) {
// The workout app can be closed without stopping the workout. In this scenario keep
// collecting HR data until so much time has passed that it is assumed the user has forgotten
// about the workout
hr_time_left = WORKOUT_ACTIVE_HR_SUBSCRIPTION_TS_EXPIRE;
} else if (s_workout_data.frontend_last_opened_ts >= s_workout_data.last_workout_end_ts) {
// If the app was opened and closed without starting a workout, turn the HR sensor off
hr_time_left = 0;
} else {
// We have ended a workout while the app was open. Make sure to keep the HR sensor on for at
// least a little bit after the workout is finished
const time_t now_ts = time_get_uptime_seconds();
const time_t time_since_workout = (now_ts - s_workout_data.last_workout_end_ts);
// After a workout has finished, keep the HR sensor on for a bit to capture the user's HR
// returning to a normal level.
hr_time_left = WORKOUT_ENDED_HR_SUBSCRIPTION_TS_EXPIRE - time_since_workout;
}
#if CAPABILITY_HAS_BUILTIN_HRM
if (hr_time_left > 0) {
// Still some time left. Set a subscription with an expiration
s_workout_data.hrm_session =
sys_hrm_manager_app_subscribe(app_get_app_id(), 1, hr_time_left, HRMFeature_BPM);
} else {
// No time left. Kill the subscription
sys_hrm_manager_unsubscribe(s_workout_data.hrm_session);
}
#endif // CAPABILITY_HAS_BUILTIN_HRM
prv_put_event(PebbleWorkoutEvent_FrontendClosed);
}
prv_unlock();
}
// ---------------------------------------------------------------------------------------
bool workout_service_start_workout(ActivitySessionType type) {
bool rv = true;
prv_lock();
{
if (!workout_service_is_workout_type_supported(type)) {
rv = false;
goto unlock;
}
if (workout_service_is_workout_ongoing()) {
PBL_LOG(LOG_LEVEL_WARNING, "Only 1 workout at a time is supported");
rv = false;
goto unlock;
}
// Before starting this new session we need to deal with any in progress sessions
uint32_t num_sessions = 0;
ActivitySession *sessions = kernel_zalloc_check(sizeof(ActivitySession) *
ACTIVITY_MAX_ACTIVITY_SESSIONS_COUNT);
activity_get_sessions(&num_sessions, sessions);
for (unsigned i = 0; i < num_sessions; i++) {
// End and save any automatically detected ongoing sessions
if (sessions[i].ongoing) {
sessions[i].ongoing = false;
activity_sessions_prv_add_activity_session(&sessions[i]);
}
}
kernel_free(sessions);
s_workout_data.current_workout = kernel_zalloc_check(sizeof(CurrentWorkoutData));
s_workout_data.current_workout->type = type;
s_workout_data.current_workout->start_utc = rtc_get_time();
s_workout_data.current_workout->current_bpm_timestamp_ts = time_get_uptime_seconds();
// FIXME: This probably doesn't need to be on a timer. We can just flush out a new time on each
// API function call
s_workout_data.second_timer = (RegularTimerInfo) {
.cb = prv_workout_timer_cb,
};
// Initialize all of our initial values for keeping track of metrics
activity_get_metric(ActivityMetricStepCount, 1,
&s_workout_data.current_workout->last_event_step_count);
s_workout_data.current_workout->last_movement_event_time_ts = time_get_uptime_seconds();
regular_timer_add_seconds_callback(&s_workout_data.second_timer);
// Finally tell our algorithm it should stop automatically tracking activities
activity_algorithm_enable_activity_tracking(false /* disable */);
PBL_LOG(LOG_LEVEL_INFO, "Starting a workout with type: %d", type);
prv_put_event(PebbleWorkoutEvent_Started);
}
unlock:
prv_unlock();
return rv;
}
// ---------------------------------------------------------------------------------------
bool workout_service_pause_workout(bool should_be_paused) {
if (workout_service_is_paused() == should_be_paused) {
// If no change in state, return early and successful
return true;
}
if (!workout_service_is_workout_ongoing()) {
PBL_LOG(LOG_LEVEL_WARNING, "Workout (un)pause requested but no workout in progress");
return false;
}
prv_lock();
{
CurrentWorkoutData *wrkt_data = s_workout_data.current_workout;
if (workout_service_is_paused()) {
// We are paused and want to unpause. Add the in progress pause time to the total
wrkt_data->duration_completed_pauses_s += (rtc_get_time() - wrkt_data->last_paused_utc);
} else {
// We are unpaused and want to pause. Set the last_paused_utc timestamp
wrkt_data->last_paused_utc = rtc_get_time();
}
s_workout_data.current_workout->paused = should_be_paused;
// Update the global duration since we have changed the pause state
prv_update_duration();
PBL_LOG(LOG_LEVEL_INFO, "Paused a workout with type: %d", wrkt_data->type);
prv_put_event(PebbleWorkoutEvent_Paused);
}
prv_unlock();
return true;
}
// ---------------------------------------------------------------------------------------
bool workout_service_stop_workout(void) {
bool rv = true;
prv_lock();
{
if (!workout_service_is_workout_ongoing()) {
PBL_LOG(LOG_LEVEL_WARNING, "No workout in progress");
rv = false;
goto unlock;
}
// Create an activity session for this workout if it was long enough
if (s_workout_data.current_workout->duration_s >= SECONDS_PER_MINUTE) {
const time_t len_min =
MIN(ACTIVITY_SESSION_MAX_LENGTH_MIN,
s_workout_data.current_workout->duration_s / SECONDS_PER_MINUTE);
ActivitySession session = {
.type = s_workout_data.current_workout->type,
.start_utc = s_workout_data.current_workout->start_utc,
.length_min = len_min,
.ongoing = false,
.manual = true,
.step_data.steps = s_workout_data.current_workout->steps,
.step_data.distance_meters = s_workout_data.current_workout->distance_m,
.step_data.active_kcalories = ROUND(s_workout_data.current_workout->active_calories,
ACTIVITY_CALORIES_PER_KCAL),
.step_data.resting_kcalories = ROUND(activity_private_compute_resting_calories(len_min),
ACTIVITY_CALORIES_PER_KCAL),
};
activity_sessions_prv_add_activity_session(&session);
activity_insights_push_activity_session_notification(rtc_get_time(), &session,
prv_get_avg_hr(), s_workout_data.current_workout->hr_zone_time_s);
s_workout_data.last_workout_end_ts = time_get_uptime_seconds();
}
regular_timer_remove_callback(&s_workout_data.second_timer);
// Re-enable automatic activity tracking
activity_algorithm_enable_activity_tracking(true /* enable */);
PBL_LOG(LOG_LEVEL_INFO, "Stopping a workout with type: %d",
s_workout_data.current_workout->type);
prv_put_event(PebbleWorkoutEvent_Stopped);
kernel_free(s_workout_data.current_workout);
s_workout_data.current_workout = NULL;
}
unlock:
prv_unlock();
return rv;
}
// ---------------------------------------------------------------------------------------
bool workout_service_is_workout_ongoing(void) {
prv_lock();
bool rv = (s_workout_data.current_workout != NULL);
prv_unlock();
return rv;
}
// ---------------------------------------------------------------------------------------
bool workout_service_takeover_activity_session(ActivitySession *session) {
bool rv = true;
prv_lock();
{
if (!workout_service_is_workout_type_supported(session->type)) {
rv = false;
goto unlock;
}
ActivitySession session_copy = *session;
// Remove the session from out list of sessions so it doesn't get counted twice
activity_sessions_prv_delete_activity_session(session);
// Start a new workout
if (!workout_service_start_workout(session_copy.type)) {
rv = false;
goto unlock;
}
// Update the new workout to mirror the session we took over
s_workout_data.current_workout->start_utc = session_copy.start_utc;
s_workout_data.current_workout->duration_s = session_copy.length_min * SECONDS_PER_MINUTE;
s_workout_data.current_workout->steps = session_copy.step_data.steps;
s_workout_data.current_workout->distance_m = session_copy.step_data.distance_meters;
s_workout_data.current_workout->active_calories =
session_copy.step_data.active_kcalories * ACTIVITY_CALORIES_PER_KCAL;
}
unlock:
prv_unlock();
return rv;
}
// ---------------------------------------------------------------------------------------
bool workout_service_is_paused(void) {
prv_lock();
bool rv = (workout_service_is_workout_ongoing() && s_workout_data.current_workout->paused);
prv_unlock();
return rv;
}
// ---------------------------------------------------------------------------------------
bool workout_service_get_current_workout_type(ActivitySessionType *type_out) {
bool rv = true;
prv_lock();
if (!type_out || !workout_service_is_workout_ongoing()) {
rv = false;
} else {
if (type_out) {
*type_out = s_workout_data.current_workout->type;
}
}
prv_unlock();
return rv;
}
// ---------------------------------------------------------------------------------------
bool workout_service_get_current_workout_info(int32_t *steps_out, int32_t *duration_s_out,
int32_t *distance_m_out, int32_t *current_bpm_out,
HRZone *current_hr_zone_out) {
bool rv = true;
prv_lock();
{
if (!workout_service_is_workout_ongoing()) {
rv = false;
} else {
if (steps_out) {
*steps_out = s_workout_data.current_workout->steps;
}
if (duration_s_out) {
*duration_s_out = s_workout_data.current_workout->duration_s;
}
if (distance_m_out) {
*distance_m_out = s_workout_data.current_workout->distance_m;
}
if (current_bpm_out) {
*current_bpm_out = s_workout_data.current_workout->current_bpm;
}
if (current_hr_zone_out) {
*current_hr_zone_out = s_workout_data.current_workout->current_hr_zone;
}
}
}
prv_unlock();
return rv;
}
#if UNITTEST
bool workout_service_get_avg_hr(int32_t *avg_hr_out) {
if (!avg_hr_out || !workout_service_is_workout_ongoing()) {
return false;
}
*avg_hr_out = prv_get_avg_hr();
return true;
}
bool workout_service_get_current_workout_hr_zone_time(int32_t *hr_zone_time_s_out) {
if (!hr_zone_time_s_out || !workout_service_is_workout_ongoing()) {
return false;
}
prv_lock();
{
hr_zone_time_s_out[HRZone_Zone0] = s_workout_data.current_workout->hr_zone_time_s[HRZone_Zone0];
hr_zone_time_s_out[HRZone_Zone1] = s_workout_data.current_workout->hr_zone_time_s[HRZone_Zone1];
hr_zone_time_s_out[HRZone_Zone2] = s_workout_data.current_workout->hr_zone_time_s[HRZone_Zone2];
hr_zone_time_s_out[HRZone_Zone3] = s_workout_data.current_workout->hr_zone_time_s[HRZone_Zone3];
}
prv_unlock();
return true;
}
void workout_service_get_active_kcalories(int32_t *active) {
if (workout_service_is_workout_ongoing()) {
*active = ROUND(s_workout_data.current_workout->active_calories, ACTIVITY_CALORIES_PER_KCAL);
}
}
void workout_service_reset(void) {
if (s_workout_data.current_workout) {
kernel_free(s_workout_data.current_workout);
}
s_workout_data = (WorkoutServiceData) {};
}
#endif