Selaa lähdekoodia

fw: drivers: add AS7000 HRM driver code/demo app

Signed-off-by: Joshua Jun <joshuajun@proton.me>
Liam McLoughlin 5 kuukautta sitten
vanhempi
commit
8e50888b10

+ 6 - 1
platform/wscript

@@ -149,7 +149,12 @@ def has_touch(ctx):
 
 @conf
 def get_hrm(ctx):
-    return None
+    if is_robert(ctx):
+        return "AS7000"
+    elif is_silk(ctx):
+        return "AS7000"
+    else:
+        return None
 
 
 @conf

+ 395 - 0
src/fw/apps/system_apps/hrm_demo.c

@@ -0,0 +1,395 @@
+/*
+ * 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 <stdio.h>
+
+#include "applib/app.h"
+#include "applib/app_message/app_message.h"
+#include "applib/ui/app_window_stack.h"
+#include "applib/ui/text_layer.h"
+#include "applib/ui/window.h"
+#include "apps/system_app_ids.h"
+#include "drivers/hrm/as7000.h"
+#include "kernel/pbl_malloc.h"
+#include "mfg/mfg_info.h"
+#include "mfg/mfg_serials.h"
+#include "process_state/app_state/app_state.h"
+#include "services/common/hrm/hrm_manager.h"
+#include "system/passert.h"
+
+#define BPM_STRING_LEN 10
+
+typedef enum {
+  AppMessageKey_Status = 1,
+
+  AppMessageKey_HeartRate = 10,
+  AppMessageKey_Confidence = 11,
+  AppMessageKey_Current = 12,
+  AppMessageKey_TIA = 13,
+  AppMessageKey_PPG = 14,
+  AppMessageKey_AccelData = 15,
+  AppMessageKey_SerialNumber = 16,
+  AppMessageKey_Model = 17,
+  AppMessageKey_HRMProtocolVersionMajor = 18,
+  AppMessageKey_HRMProtocolVersionMinor = 19,
+  AppMessageKey_HRMSoftwareVersionMajor = 20,
+  AppMessageKey_HRMSoftwareVersionMinor = 21,
+  AppMessageKey_HRMApplicationID = 22,
+  AppMessageKey_HRMHardwareRevision = 23,
+} AppMessageKey;
+
+typedef enum {
+  AppStatus_Stopped = 0,
+  AppStatus_Enabled_1HZ = 1,
+} AppStatus;
+
+typedef struct {
+  HRMSessionRef session;
+  EventServiceInfo hrm_event_info;
+
+  Window window;
+  TextLayer bpm_text_layer;
+  TextLayer quality_text_layer;
+
+  char bpm_string[BPM_STRING_LEN];
+
+  bool ready_to_send;
+  DictionaryIterator *out_iter;
+} AppData;
+
+static char *prv_get_quality_string(HRMQuality quality) {
+  switch (quality) {
+    case HRMQuality_NoAccel:
+      return "No Accel Data";
+    case HRMQuality_OffWrist:
+      return "Off Wrist";
+    case HRMQuality_NoSignal:
+      return "No Signal";
+    case HRMQuality_Worst:
+      return "Worst";
+    case HRMQuality_Poor:
+      return "Poor";
+    case HRMQuality_Acceptable:
+      return "Acceptable";
+    case HRMQuality_Good:
+      return "Good";
+    case HRMQuality_Excellent:
+      return "Excellent";
+  }
+  WTF;
+}
+
+static char *prv_translate_error(AppMessageResult result) {
+  switch (result) {
+    case APP_MSG_OK: return "APP_MSG_OK";
+    case APP_MSG_SEND_TIMEOUT: return "APP_MSG_SEND_TIMEOUT";
+    case APP_MSG_SEND_REJECTED: return "APP_MSG_SEND_REJECTED";
+    case APP_MSG_NOT_CONNECTED: return "APP_MSG_NOT_CONNECTED";
+    case APP_MSG_APP_NOT_RUNNING: return "APP_MSG_APP_NOT_RUNNING";
+    case APP_MSG_INVALID_ARGS: return "APP_MSG_INVALID_ARGS";
+    case APP_MSG_BUSY: return "APP_MSG_BUSY";
+    case APP_MSG_BUFFER_OVERFLOW: return "APP_MSG_BUFFER_OVERFLOW";
+    case APP_MSG_ALREADY_RELEASED: return "APP_MSG_ALREADY_RELEASED";
+    case APP_MSG_CALLBACK_ALREADY_REGISTERED: return "APP_MSG_CALLBACK_ALREADY_REGISTERED";
+    case APP_MSG_CALLBACK_NOT_REGISTERED: return "APP_MSG_CALLBACK_NOT_REGISTERED";
+    case APP_MSG_OUT_OF_MEMORY: return "APP_MSG_OUT_OF_MEMORY";
+    case APP_MSG_CLOSED: return "APP_MSG_CLOSED";
+    case APP_MSG_INTERNAL_ERROR: return "APP_MSG_INTERNAL_ERROR";
+    default: return "UNKNOWN ERROR";
+  }
+}
+
+static void prv_send_msg(void) {
+  AppData *app_data = app_state_get_user_data();
+
+  AppMessageResult result = app_message_outbox_send();
+  if (result == APP_MSG_OK) {
+    app_data->ready_to_send = false;
+  } else {
+    PBL_LOG(LOG_LEVEL_DEBUG, "Error sending message: %s", prv_translate_error(result));
+  }
+}
+
+static void prv_send_status_and_version(void) {
+  AppData *app_data = app_state_get_user_data();
+  PBL_LOG(LOG_LEVEL_DEBUG, "Sending status and version to mobile app");
+
+  AppMessageResult result = app_message_outbox_begin(&app_data->out_iter);
+  if (result != APP_MSG_OK) {
+    PBL_LOG(LOG_LEVEL_DEBUG, "Failed to begin outbox - reason %i %s",
+            result, prv_translate_error(result));
+    return;
+  }
+
+  dict_write_uint8(app_data->out_iter, AppMessageKey_Status, AppStatus_Enabled_1HZ);
+
+#if CAPABILITY_HAS_BUILTIN_HRM
+  if (mfg_info_is_hrm_present()) {
+    AS7000InfoRecord hrm_info = {};
+    as7000_get_version_info(HRM, &hrm_info);
+    dict_write_uint8(app_data->out_iter, AppMessageKey_HRMProtocolVersionMajor,
+                     hrm_info.protocol_version_major);
+    dict_write_uint8(app_data->out_iter, AppMessageKey_HRMProtocolVersionMinor,
+                     hrm_info.protocol_version_minor);
+    dict_write_uint8(app_data->out_iter, AppMessageKey_HRMSoftwareVersionMajor,
+                     hrm_info.sw_version_major);
+    dict_write_uint8(app_data->out_iter, AppMessageKey_HRMSoftwareVersionMinor,
+                     hrm_info.sw_version_minor);
+    dict_write_uint8(app_data->out_iter, AppMessageKey_HRMApplicationID,
+                     hrm_info.application_id);
+    dict_write_uint8(app_data->out_iter, AppMessageKey_HRMHardwareRevision,
+                     hrm_info.hw_revision);
+  }
+#endif
+
+  char serial_number_buffer[MFG_SERIAL_NUMBER_SIZE + 1];
+  mfg_info_get_serialnumber(serial_number_buffer, sizeof(serial_number_buffer));
+  dict_write_data(app_data->out_iter, AppMessageKey_SerialNumber,
+                  (uint8_t*) serial_number_buffer, sizeof(serial_number_buffer));
+
+#if IS_BIGBOARD
+  WatchInfoColor watch_color = WATCH_INFO_MODEL_UNKNOWN;
+#else
+  WatchInfoColor watch_color = mfg_info_get_watch_color();
+#endif // IS_BIGBOARD
+  dict_write_uint32(app_data->out_iter, AppMessageKey_Model, watch_color);
+
+  prv_send_msg();
+}
+
+static void prv_handle_hrm_data(PebbleEvent *e, void *context) {
+  AppData *app_data = app_state_get_user_data();
+
+  if (e->type == PEBBLE_HRM_EVENT) {
+    PebbleHRMEvent *hrm = &e->hrm;
+
+    // Save HRMEventBPM data and send when we get the current into.
+    static uint8_t bpm = 0;
+    static uint8_t bpm_quality = 0;
+    static uint16_t led_current = 0;
+
+    if (hrm->event_type == HRMEvent_BPM) {
+      snprintf(app_data->bpm_string, sizeof(app_data->bpm_string), "%"PRIu8" BPM", hrm->bpm.bpm);
+      text_layer_set_text(&app_data->quality_text_layer, prv_get_quality_string(hrm->bpm.quality));
+      layer_mark_dirty(&app_data->window.layer);
+
+      bpm = hrm->bpm.bpm;
+      bpm_quality = hrm->bpm.quality;
+    } else if (hrm->event_type == HRMEvent_LEDCurrent) {
+      led_current = hrm->led.current_ua;
+    } else if (hrm->event_type == HRMEvent_Diagnostics) {
+      if (!app_data->ready_to_send) {
+        return;
+      }
+
+      AppMessageResult result = app_message_outbox_begin(&app_data->out_iter);
+      PBL_ASSERTN(result == APP_MSG_OK);
+
+      if (bpm) {
+        dict_write_uint8(app_data->out_iter, AppMessageKey_HeartRate, bpm);
+        dict_write_uint8(app_data->out_iter, AppMessageKey_Confidence, bpm_quality);
+      }
+
+      if (led_current) {
+        dict_write_uint16(app_data->out_iter, AppMessageKey_Current, led_current);
+      }
+
+      if (hrm->debug->ppg_data.num_samples) {
+        HRMPPGData *d = &hrm->debug->ppg_data;
+        dict_write_data(app_data->out_iter, AppMessageKey_TIA,
+                        (uint8_t *)d->tia, d->num_samples * sizeof(d->tia[0]));
+        dict_write_data(app_data->out_iter, AppMessageKey_PPG,
+                        (uint8_t *)d->ppg, d->num_samples * sizeof(d->ppg[0]));
+      }
+
+      if (hrm->debug->ppg_data.tia[hrm->debug->ppg_data.num_samples - 1] == 0) {
+        PBL_LOG_COLOR(LOG_LEVEL_DEBUG, LOG_COLOR_CYAN, "last PPG TIA sample is 0!");
+      }
+
+      if (hrm->debug->ppg_data.num_samples != 20) {
+        PBL_LOG_COLOR(LOG_LEVEL_DEBUG, LOG_COLOR_CYAN, "Only got %"PRIu16" samples!",
+                      hrm->debug->ppg_data.num_samples);
+      }
+
+      if (hrm->debug->accel_data.num_samples) {
+        HRMAccelData *d = &hrm->debug->accel_data;
+        dict_write_data(app_data->out_iter, AppMessageKey_AccelData,
+                        (uint8_t *)d->data, d->num_samples * sizeof(d->data[0]));
+      }
+
+      PBL_LOG(LOG_LEVEL_DEBUG,
+              "Sending message - bpm:%u quality:%u current:%u "
+              "ppg_readings:%u accel_readings %"PRIu32,
+              bpm,
+              bpm_quality,
+              led_current,
+              hrm->debug->ppg_data.num_samples,
+              hrm->debug->accel_data.num_samples);
+
+      led_current = bpm = bpm_quality = 0;
+
+      prv_send_msg();
+    } else if (hrm->event_type == HRMEvent_SubscriptionExpiring) {
+      PBL_LOG(LOG_LEVEL_INFO, "Got subscription expiring event");
+      // Subscribe again if our subscription is expiring
+      const uint32_t update_time_s = 1;
+      app_data->session = sys_hrm_manager_app_subscribe(APP_ID_HRM_DEMO, update_time_s,
+                                                        SECONDS_PER_HOUR, HRMFeature_BPM);
+    }
+  }
+}
+
+static void prv_enable_hrm(void) {
+  AppData *app_data = app_state_get_user_data();
+
+  app_data->hrm_event_info = (EventServiceInfo) {
+    .type = PEBBLE_HRM_EVENT,
+    .handler = prv_handle_hrm_data,
+  };
+  event_service_client_subscribe(&app_data->hrm_event_info);
+
+  // TODO: Let the mobile app control this?
+  const uint32_t update_time_s = 1;
+  app_data->session = sys_hrm_manager_app_subscribe(
+      APP_ID_HRM_DEMO, update_time_s, SECONDS_PER_HOUR,
+      HRMFeature_BPM | HRMFeature_LEDCurrent | HRMFeature_Diagnostics);
+}
+
+static void prv_disable_hrm(void) {
+  AppData *app_data = app_state_get_user_data();
+
+  event_service_client_unsubscribe(&app_data->hrm_event_info);
+  sys_hrm_manager_unsubscribe(app_data->session);
+}
+
+static void prv_handle_mobile_status_request(AppStatus status) {
+  AppData *app_data = app_state_get_user_data();
+
+  if (status == AppStatus_Stopped) {
+    text_layer_set_text(&app_data->bpm_text_layer, "Paused");
+    text_layer_set_text(&app_data->quality_text_layer, "Paused by mobile");
+    prv_disable_hrm();
+  } else {
+    app_data->bpm_string[0] = '\0';
+    text_layer_set_text(&app_data->bpm_text_layer, app_data->bpm_string);
+    text_layer_set_text(&app_data->quality_text_layer, "Loading...");
+    prv_enable_hrm();
+  }
+}
+
+static void prv_message_received_cb(DictionaryIterator *iterator, void *context) {
+  Tuple *status_tuple = dict_find(iterator, AppMessageKey_Status);
+
+  if (status_tuple) {
+    prv_handle_mobile_status_request(status_tuple->value->uint8);
+  }
+}
+
+static void prv_message_sent_cb(DictionaryIterator *iterator, void *context) {
+  AppData *app_data = app_state_get_user_data();
+
+  app_data->ready_to_send = true;
+}
+
+static void prv_message_failed_cb(DictionaryIterator *iterator,
+                               AppMessageResult reason, void *context) {
+  PBL_LOG(LOG_LEVEL_DEBUG, "Out message send failed - reason %i %s",
+          reason, prv_translate_error(reason));
+  AppData *app_data = app_state_get_user_data();
+  app_data->ready_to_send = true;
+}
+
+static void prv_remote_notify_timer_cb(void *data) {
+  prv_send_status_and_version();
+}
+
+static void prv_init(void) {
+  AppData *app_data = app_malloc_check(sizeof(*app_data));
+  *app_data = (AppData) {
+    .session = (HRMSessionRef)app_data, // Use app data as session ref
+    .ready_to_send = false,
+  };
+  app_state_set_user_data(app_data);
+
+  Window *window = &app_data->window;
+  window_init(window, "");
+  window_set_fullscreen(window, true);
+
+  GRect bounds = window->layer.bounds;
+
+  bounds.origin.y += 40;
+  TextLayer *bpm_tl = &app_data->bpm_text_layer;
+  text_layer_init(bpm_tl, &bounds);
+  text_layer_set_font(bpm_tl, fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD));
+  text_layer_set_text_alignment(bpm_tl, GTextAlignmentCenter);
+  text_layer_set_text(bpm_tl, app_data->bpm_string);
+  layer_add_child(&window->layer, &bpm_tl->layer);
+
+  bounds.origin.y += 35;
+  TextLayer *quality_tl = &app_data->quality_text_layer;
+  text_layer_init(quality_tl, &bounds);
+  text_layer_set_font(quality_tl, fonts_get_system_font(FONT_KEY_GOTHIC_18));
+  text_layer_set_text_alignment(quality_tl, GTextAlignmentCenter);
+  text_layer_set_text(quality_tl, "Loading...");
+  layer_add_child(&window->layer, &quality_tl->layer);
+
+  const uint32_t inbox_size = 64;
+  const uint32_t outbox_size = 256;
+  AppMessageResult result = app_message_open(inbox_size, outbox_size);
+  if (result != APP_MSG_OK) {
+    PBL_LOG(LOG_LEVEL_ERROR, "Unable to open app message! %i %s",
+            result, prv_translate_error(result));
+  } else {
+    PBL_LOG(LOG_LEVEL_DEBUG, "Successfully opened app message");
+  }
+
+  if (!sys_hrm_manager_is_hrm_present()) {
+    text_layer_set_text(quality_tl, "No HRM Present");
+  } else {
+    text_layer_set_text(quality_tl, "Loading...");
+    prv_enable_hrm();
+  }
+
+  app_message_register_inbox_received(prv_message_received_cb);
+  app_message_register_outbox_sent(prv_message_sent_cb);
+  app_message_register_outbox_failed(prv_message_failed_cb);
+
+  app_timer_register(1000, prv_remote_notify_timer_cb, NULL);
+
+  app_window_stack_push(window, true);
+}
+
+static void prv_deinit(void) {
+  AppData *app_data = app_state_get_user_data();
+  sys_hrm_manager_unsubscribe(app_data->session);
+}
+
+static void prv_main(void) {
+  prv_init();
+  app_event_loop();
+  prv_deinit();
+}
+
+const PebbleProcessMd* hrm_demo_get_app_info(void) {
+  static const PebbleProcessMdSystem s_hrm_demo_app_info = {
+    .name = "HRM Demo",
+    .common.uuid = { 0xf8, 0x1b, 0x2a, 0xf8, 0x13, 0x0a, 0x11, 0xe6,
+                     0x86, 0x9f, 0xa4, 0x5e, 0x60, 0xb9, 0x77, 0x3d },
+    .common.main_func = &prv_main,
+  };
+  // Only show in launcher if HRM is present
+  return (sys_hrm_manager_is_hrm_present()) ? (const PebbleProcessMd*)&s_hrm_demo_app_info : NULL;
+}

+ 4 - 5
src/fw/apps/system_apps/settings/settings_certifications.h

@@ -168,12 +168,11 @@ static const CertificationIds * prv_get_certification_ids(void) {
 #elif defined(BOARD_SPALDING) || defined(BOARD_SPALDING_EVT)
   return &s_certification_ids_spalding;
 #elif PLATFORM_SILK
-// TODO: remove force-false
-//  if (mfg_info_is_hrm_present()) {
-//    return &s_certification_ids_silk_hr;
-//  } else {
+  if (mfg_info_is_hrm_present()) {
+    return &s_certification_ids_silk_hr;
+  } else {
     return &s_certification_ids_silk;
-//  }
+  }
 #elif PLATFORM_ASTERIX
   // TODO: add real certification ids
   return &s_certification_ids_fallback;

+ 73 - 0
src/fw/board/boards/board_robert.c

@@ -19,6 +19,7 @@
 #include "drivers/display/ice40lp/ice40lp_definitions.h"
 #include "drivers/exti.h"
 #include "drivers/flash/qspi_flash_definitions.h"
+#include "drivers/hrm/as7000.h"
 #include "drivers/i2c_definitions.h"
 #include "drivers/pmic.h"
 #include "drivers/qspi_definitions.h"
@@ -291,6 +292,51 @@ static const I2CBus I2C_TOUCH_ALS_BUS = {
 };
 #endif
 
+static I2CBusState I2C_HRM_BUS_STATE = {};
+
+static const I2CBusHal I2C_HRM_BUS_HAL = {
+  .i2c = I2C2,
+  .clock_ctrl = RCC_APB1Periph_I2C2,
+  .bus_mode = I2CBusMode_FastMode,
+  .clock_speed = 400000,
+#if BOARD_ROBERT_BB || BOARD_CUTTS_BB
+  // TODO: These need to be measured. Just using PMIC_MAG values for now.
+  .rise_time_ns = 150,
+  .fall_time_ns = 6,
+#elif BOARD_ROBERT_BB2
+  // TODO: These need to be measured. Just using PMIC_MAG values for now.
+  .rise_time_ns = 150,
+  .fall_time_ns = 6,
+#elif BOARD_ROBERT_EVT
+  // TODO: These need to be measured. Just using PMIC_MAG values for now.
+  .rise_time_ns = 70,
+  .fall_time_ns = 5,
+#else
+#error "Unknown board"
+#endif
+  .ev_irq_channel = I2C2_EV_IRQn,
+  .er_irq_channel = I2C2_ER_IRQn,
+};
+
+static const I2CBus I2C_HRM_BUS = {
+  .state = &I2C_HRM_BUS_STATE,
+  .hal = &I2C_HRM_BUS_HAL,
+  .scl_gpio = {
+    .gpio = GPIOF,
+    .gpio_pin = GPIO_Pin_1,
+    .gpio_pin_source = GPIO_PinSource1,
+    .gpio_af = GPIO_AF4_I2C2
+  },
+  .sda_gpio = {
+    .gpio = GPIOF,
+    .gpio_pin = GPIO_Pin_0,
+    .gpio_pin_source = GPIO_PinSource0,
+    .gpio_af = GPIO_AF4_I2C2
+  },
+  .stop_mode_inhibitor = InhibitorI2C2,
+  .name = "I2C_HRM"
+};
+
 #if BOARD_CUTTS_BB
 static I2CBusState I2C_NFC_BUS_STATE = {};
 
@@ -387,9 +433,17 @@ static const I2CSlavePort I2C_SLAVE_MAG3110 = {
   .address = 0x0e << 1
 };
 
+static const I2CSlavePort I2C_SLAVE_AS7000 = {
+  .bus = &I2C_HRM_BUS,
+  .address = 0x60
+};
+
 I2CSlavePort * const I2C_MAX14690 = &I2C_SLAVE_MAX14690;
 I2CSlavePort * const I2C_MAG3110 = &I2C_SLAVE_MAG3110;
+I2CSlavePort * const I2C_AS7000 = &I2C_SLAVE_AS7000;
 
+IRQ_MAP(I2C2_EV, i2c_hal_event_irq_handler, &I2C_HRM_BUS);
+IRQ_MAP(I2C2_ER, i2c_hal_error_irq_handler, &I2C_HRM_BUS);
 IRQ_MAP(I2C4_EV, i2c_hal_event_irq_handler, &I2C_PMIC_MAG_BUS);
 IRQ_MAP(I2C4_ER, i2c_hal_error_irq_handler, &I2C_PMIC_MAG_BUS);
 #if BOARD_CUTTS_BB
@@ -398,6 +452,24 @@ IRQ_MAP(I2C1_ER, i2c_hal_error_irq_handler, &I2C_TOUCH_ALS_BUS);
 #endif
 
 
+// HRM DEVICE
+static HRMDeviceState s_hrm_state;
+static HRMDevice HRM_DEVICE = {
+  .state = &s_hrm_state,
+  .handshake_int = { EXTI_PortSourceGPIOI, 10 },
+  .int_gpio = {
+    .gpio = GPIOI,
+    .gpio_pin = GPIO_Pin_10
+  },
+  .en_gpio = {
+    .gpio = GPIOF,
+    .gpio_pin = GPIO_Pin_3,
+    .active_high = false,
+  },
+  .i2c_slave = &I2C_SLAVE_AS7000,
+};
+HRMDevice * const HRM = &HRM_DEVICE;
+
 #if BOARD_CUTTS_BB
 static const TouchSensor EWD1000_DEVICE = {
   .i2c = &I2C_SLAVE_EWD1000,
@@ -716,6 +788,7 @@ void board_init(void) {
   i2c_init(&I2C_TOUCH_ALS_BUS);
   i2c_init(&I2C_NFC_BUS);
 #endif
+  i2c_init(&I2C_HRM_BUS);
   i2c_init(&I2C_PMIC_MAG_BUS);
   spi_slave_port_init(BMI160_SPI);
 

+ 5 - 0
src/fw/board/boards/board_robert.h

@@ -293,6 +293,7 @@ extern SPISlavePort * const BMI160_SPI;
 
 extern I2CSlavePort * const I2C_MAX14690;
 extern I2CSlavePort * const I2C_MAG3110;
+extern I2CSlavePort * const I2C_AS7000;
 
 extern VoltageMonitorDevice * const VOLTAGE_MONITOR_ALS;
 extern VoltageMonitorDevice * const VOLTAGE_MONITOR_BATTERY;
@@ -305,6 +306,10 @@ extern QSPIFlash * const QSPI_FLASH;
 extern ICE40LPDevice * const ICE40LP;
 extern SPISlavePort * const DIALOG_SPI;
 
+extern MicDevice * const MIC;
+
+extern HRMDevice * const HRM;
+
 #if BOARD_CUTTS_BB
 extern TouchSensor * const EWD1000;
 #endif

+ 26 - 0
src/fw/board/boards/board_silk.c

@@ -18,6 +18,7 @@
 
 #include "drivers/exti.h"
 #include "drivers/flash/qspi_flash_definitions.h"
+#include "drivers/hrm/as7000.h"
 #include "drivers/i2c_definitions.h"
 #include "drivers/qspi_definitions.h"
 #include "drivers/stm32f2/dma_definitions.h"
@@ -266,7 +267,13 @@ static const I2CSlavePort I2C_SLAVE_AS3701B = {
   .address = 0x80
 };
 
+static const I2CSlavePort I2C_SLAVE_AS7000 = {
+  .bus = &I2C_PMIC_HRM_BUS,
+  .address = 0x60
+};
+
 I2CSlavePort * const I2C_AS3701B = &I2C_SLAVE_AS3701B;
+I2CSlavePort * const I2C_AS7000 = &I2C_SLAVE_AS7000;
 
 IRQ_MAP(I2C3_EV, i2c_hal_event_irq_handler, &I2C_PMIC_HRM_BUS);
 IRQ_MAP(I2C3_ER, i2c_hal_error_irq_handler, &I2C_PMIC_HRM_BUS);
@@ -355,6 +362,25 @@ static SPISlavePort DIALOG_SPI_SLAVE_PORT = {
 SPISlavePort * const DIALOG_SPI = &DIALOG_SPI_SLAVE_PORT;
 
 
+// HRM DEVICE
+static HRMDeviceState s_hrm_state;
+static HRMDevice HRM_DEVICE = {
+  .state = &s_hrm_state,
+  .handshake_int = { EXTI_PortSourceGPIOA, 15 },
+  .int_gpio = {
+    .gpio = GPIOA,
+    .gpio_pin = GPIO_Pin_15
+  },
+  .en_gpio = {
+    .gpio = GPIOC,
+    .gpio_pin = GPIO_Pin_1,
+    .active_high = false,
+  },
+  .i2c_slave = &I2C_SLAVE_AS7000,
+};
+HRMDevice * const HRM = &HRM_DEVICE;
+
+
 // QSPI
 static QSPIPortState s_qspi_port_state;
 static QSPIPort QSPI_PORT = {

+ 3 - 0
src/fw/board/boards/board_silk.h

@@ -229,12 +229,15 @@ extern UARTDevice * const BT_TX_BOOTROM_UART;
 extern UARTDevice * const BT_RX_BOOTROM_UART;
 
 extern I2CSlavePort * const I2C_AS3701B;
+extern I2CSlavePort * const I2C_AS7000;
 
 extern const VoltageMonitorDevice * VOLTAGE_MONITOR_ALS;
 extern const VoltageMonitorDevice * VOLTAGE_MONITOR_BATTERY;
 
 extern const TemperatureSensor * const TEMPERATURE_SENSOR;
 
+extern HRMDevice * const HRM;
+
 extern QSPIPort * const QSPI;
 extern QSPIFlash * const QSPI_FLASH;
 

+ 14 - 0
src/fw/console/prompt_commands.h

@@ -268,6 +268,8 @@ extern void command_low_power_debug(char *enable_arg);
 extern void command_audit_delay_us(void);
 extern void command_enter_stop(void);
 
+extern void dialog_test_cmds(void);
+
 extern void command_dump_notif_pref_db(void);
 
 extern void command_bt_conn_param_set(
@@ -284,6 +286,12 @@ extern void command_btle_pa_set(char *option);
 extern void command_btle_unmod_tx_start(char *tx_channel);
 extern void command_btle_unmod_tx_stop(void);
 
+#if CAPABILITY_HAS_BUILTIN_HRM
+extern void command_hrm_read(void);
+extern void command_hrm_wipe(void);
+extern void command_hrm_freeze(void);
+#endif
+
 #if MFG_INFO_RECORDS_TEST_RESULTS
 extern void command_mfg_info_test_results(void);
 #endif
@@ -467,6 +475,12 @@ static const Command s_prompt_commands[] = {
 #endif // PLATFORM_TINTIN
 #endif // RECOVERY_FW
 
+#if CAPABILITY_HAS_BUILTIN_HRM
+  { "hrm read", command_hrm_read, 0},
+  { "hrm wipe", command_hrm_wipe, 0},
+  { "hrm freeze", command_hrm_freeze, 0},
+#endif
+
 #if CAPABILITY_HAS_ACCESSORY_CONNECTOR
   { "accessory power", command_accessory_power_set, 1 },
   { "accessory stress", command_accessory_stress_test, 0 },

+ 130 - 0
src/fw/drivers/hrm/README_AS7000.md

@@ -0,0 +1,130 @@
+Some Information About the AMS AS7000
+=====================================
+
+The documentation about the AS7000 can be a bit hard to follow and a bit
+incomplete at times. This document aims to fill in the gaps, using
+knowledge gleaned from the datasheet, Communication Protocol document
+and the SDK docs and sources.
+
+What is the AS7000 exactly?
+---------------------------
+
+The AS7000 is a Cortex-M0 SoC with some very specialized peripherals.
+It can be programmed with a firmware to have it function as a heart-rate
+monitor. It doesn't necessarily come with the HRM firmware preloaded, so
+it is not one of those devices that you can treat as a black-box piece
+of hardware that you just power up and talk to.
+
+There is 32 kB of flash for the main application, and some "reserved"
+flash containing a loader application. There is also a ROM first-stage
+bootloader.
+
+Boot Process
+------------
+
+The chip is woken from shutdown by pulling the chip's GPIO8 pin low. The
+CPU core executes the ROM bootloader after powerup or wake from
+shutdown. The bootloader's logic is apparently as follows:
+
+    if loader is available:
+        jump to loader
+    elif application is valid:
+        remap flash to address 0
+        jump to application
+    else:
+        wait on UART?? (not documented further)
+
+We will probably always be using parts with the loader application
+preprogrammed by AMS so the existence of the bootloader just makes the
+communication protocol document a bit more confusing to understand. It
+can be safely ignored and we can pretend that the loader application is
+the only bootloader.
+
+Once control is passed from the ROM bootloader to the loader, the loader
+performs the following:
+
+    wait 30 ms
+    if application is valid and GPIO8 is low:
+        remap flash to address 0
+        jump to application
+    else:
+        run loader application
+
+The reason for the 30 ms wait and check for GPIO8 is to provide an
+escape hatch for getting into the loader if a misbehaving application
+is programmed which doesn't allow for a command to be used to enter the
+loader.
+
+The "Application is valid" check is apparently to check that address
+0x7FFC (top of main flash) contains the bytes 72 75 6C 75.
+
+The Loader
+----------
+
+The loader allows new applications to be programmed into the main flash
+over I2C by sending Intel HEX records(!). The communication protocol
+document describes the protocol well enough, though it is a bit lacking
+in detail as to what HEX records it accepts. Luckily the SDK comes with
+a header file which fills in some of the details, and the SDK Getting
+Started document has some more details.
+
+  - The supported record types are 00 (Data), 01 (EOF), 04 (Extended
+    Linear Address) and 05 (Start Linear Address).
+  - The HEX records must specify addresses in the aliased range 0-0x7FFF
+  - HEX records must be sent in order of strictly increasing addresses.
+
+A comment in loader.h from the SDK states that the maximum record size
+supported by the loader can be configured as 256, 128, 64 or 32. The
+`#define` in the same header which sets the max record length is set
+to 256, strongly implying that this is the record length limit for the
+loader firmware which is already programmed into the parts. Programming
+an application firmware is successful when using records of length 203,
+which seems to confirm that the max record length is indeed 256.
+
+The HEX files provided by AMS appear to be standard HEX files generated
+by the Keil MDK-ARM toolchain. http://www.keil.com/support/docs/1584.htm
+
+The Start Linear Address record contains the address of the "pre-main"
+function, which is not the reset vector. Since the reset vector is
+already written as data in the application's vector table, the record is
+unnecessary for flash loading and is likely just thrown away by the
+loader.
+
+Empirical testing has confirmed that neither the Start Linear Address
+nor Extended Linear Address records need to be sent for flashing to
+succeed. It is acceptable to send only a series of Data records followed
+by an EOF record.
+
+The loader will exit if one of the following conditions is true:
+
+  - An EOF record is sent
+  - A valid HEX record is sent which the loader doesn't understand
+  - Data is sent which is not a valid HEX record
+  - No records are sent for approximately ten seconds
+
+The loader exits by waiting one second for the host controller to read
+out the exit code register, then performs a system reset.
+
+The Application
+---------------
+
+Due to the simplicity of the CM0 and the limited resources available on
+the chip, the application firmware takes full control of the system. The
+applications in the SDK are written as a straightforward mainloop with
+no OS. Think of it as a very simple form of cooperative multitasking.
+
+The "mandatory" I2C register map mentioned in the Communication Protocol
+document is merely a part of the protocol; it is entirely up to the
+application firmware to properly implement it. The Application ID
+register doesn't do anything special on its own: the mainloop simply
+polls the register value at each iteration to see if it needs to start
+or stop any "apps" (read: modules). The application firmware could
+misbehave, resulting in writes to that register having no effect.
+
+Writing 0x01 to the Application ID register isn't special either; it is
+up to the application to recognize that value and reset into the loader.
+That's why the escape hatch is necessary, and the fundamental reason why
+the loader application cannot run at the same time as any other
+application.
+
+GPIO8 isn't special while the firmware is running, either.

+ 1120 - 0
src/fw/drivers/hrm/as7000.c

@@ -0,0 +1,1120 @@
+/*
+ * 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.
+ */
+
+#define FILE_LOG_COLOR LOG_COLOR_GREEN
+#include "as7000.h"
+
+#include "board/board.h"
+#include "drivers/backlight.h"
+#include "drivers/exti.h"
+#include "drivers/gpio.h"
+#include "drivers/i2c.h"
+#include "kernel/events.h"
+#include "kernel/util/sleep.h"
+#include "kernel/util/interval_timer.h"
+#include "mfg/mfg_info.h"
+#include "resource/resource.h"
+#include "resource/resource_ids.auto.h"
+#include "services/common/analytics/analytics.h"
+#include "services/common/system_task.h"
+#include "services/common/hrm/hrm_manager.h"
+#include "system/logging.h"
+#include "system/passert.h"
+#include "system/profiler.h"
+#include "util/attributes.h"
+#include "util/ihex.h"
+#include "util/math.h"
+#include "util/net.h"
+
+#define STM32F4_COMPATIBLE
+#define STM32F7_COMPATIBLE
+#include <mcu.h>
+
+// Enable this to get some very verbose logs about collecting PPG data from the HRM
+// Bump this up to 2 to get very verbose logs
+#define PPG_DEBUG 0
+
+#if PPG_DEBUG
+#define PPG_DBG(...) \
+  do { \
+    PBL_LOG(LOG_LEVEL_DEBUG, __VA_ARGS__); \
+  } while (0);
+#else
+#define PPG_DBG(...)
+#endif
+
+#if PPG_DEBUG == 2
+#define PPG_DBG_VERBOSE(...) \
+  do { \
+    PBL_LOG_VERBOSE(LOG_LEVEL_DEBUG, __VA_ARGS__); \
+  } while (0);
+#else
+#define PPG_DBG_VERBOSE(...)
+#endif
+
+
+
+// The datasheet recommends waiting for 250ms for the chip to boot
+#define NORMAL_BOOT_DELAY_MS (250)
+// We need to wait an extra second for the loader to time-out
+#define LOADER_REBOOT_DELAY_MS (NORMAL_BOOT_DELAY_MS + 1000)
+// Usually takes a couple ms after writing a record, but spikes of ~20ms have been observed. Let's
+// be conservative.
+#define LOADER_READY_MAX_DELAY_MS (50)
+// Give the sensor this much time to tear down the current app and go back to the idle mode
+#define SHUT_DOWN_DELAY_MS (1000)
+// Number of handshakes before samples are expected
+#define WARMUP_HANDSHAKES (2)
+
+#define EXPECTED_PROTOCOL_VERSION_MAJOR (2)
+
+// White Threshold is 5000
+// Black Threshold is 3500
+// Value stored in the register is in units of 64 ADC counts
+// e.g. 78 * 64 = 4992 ADC-counts
+// Refer to AS7000 SW Communication Protocol section 6.7
+#define PRES_DETECT_THRSH_WHITE 78 // (5000 / 64)
+#define PRES_DETECT_THRSH_BLACK 54 // (3500 / 64)
+
+// register addresses
+#define ADDR_LOADER_STATUS (0x00)
+#define ADDR_INFO_START (0x00)
+#define ADDR_APP_IDS (0x04)
+
+#define ADDR_ACCEL_SAMPLE_FREQ_MSB (0x08)
+#define ADDR_ACCEL_SAMPLE_FREQ_LSB (0x09)
+
+// Register that allows us to compensate for clock skew between us (the host) and the sensor. The
+// sensor doesn't track time accurately, and gives us a heart rate value that's in the sensors
+// time domain, which will need to be translated into "real time" according to our time domain.
+// If we use these registers to tell the sensor how frequently it's handshaking with us in our
+// time domain, this will let the sensor do this compensation for us.
+// The value programmed in here is in units of 0.1ms (value of 10000 = 1 second).
+#define ADDR_HOST_ONE_SECOND_TIME_MSB (0x0a)
+#define ADDR_HOST_ONE_SECOND_TIME_LSB (0x0b)
+
+#define ADDR_NUM_ACCEL_SAMPLES (0x0e)
+#define ADDR_NUM_PPG_SAMPLES (0x0f)
+
+#define ADDR_ACCEL_SAMPLE_IDX (0x14)
+#define ADDR_ACCEL_X_MSB (0x15)
+#define ADDR_ACCEL_Y_MSB (0x17)
+#define ADDR_ACCEL_Z_MSB (0x19)
+
+#define ADDR_PPG_IDX (0x1b)
+#define ADDR_PPG_MSB (0x1c)
+#define ADDR_PPG_LSB (0x1d)
+#define ADDR_TIA_MSB (0x1e)
+#define ADDR_TIA_LSB (0x1f)
+
+#define ADDR_PRES_DETECT_THRSH (0x26)
+
+#define ADDR_LED_CURRENT_MSB (0x34)
+#define ADDR_LED_CURRENT_LSB (0x35)
+#define ADDR_HRM_STATUS (0x36)
+#define ADDR_HRM_BPM    (0x37)
+#define ADDR_HRM_SQI    (0x38)
+
+#define ADDR_SYNC (0x39)
+
+// The AS7000 wants Accel Frequency given in 0.001Hz increments, this can be used to scale
+#define AS7000_ACCEL_FREQUENCY_SCALE (1000)
+
+//! Thresholds for quality conversion. These are upper bounds on readings.
+enum AS7000SQIThreshold {
+  AS7000SQIThreshold_Excellent = 2,
+  AS7000SQIThreshold_Good = 5,
+  AS7000SQIThreshold_Acceptable = 8,
+  AS7000SQIThreshold_Poor = 10,
+  AS7000SQIThreshold_Worst = 20,
+
+  AS7000SQIThreshold_OffWrist = 254,
+
+  AS7000SQIThresholdInvalid,
+};
+
+enum AS7000Status {
+  AS7000Status_OK = 0,
+  AS7000Status_IllegalParameter = 1,
+  AS7000Status_LostData = 2,
+  AS7000Status_NoAccel = 4,
+};
+
+typedef enum AS7000AppId {
+  AS7000AppId_Idle = 0x00,
+  AS7000AppId_Loader = 0x01,
+  AS7000AppId_HRM = 0x02,
+  AS7000AppId_PRV = 0x04,
+  AS7000AppId_GSR = 0x08,
+  AS7000AppId_NTC = 0x10,
+} AS7000AppId;
+
+typedef enum AS7000LoaderStatus {
+  AS7000LoaderStatus_Ready = 0x00,
+  AS7000LoaderStatus_Busy1 = 0x3A,
+  AS7000LoaderStatus_Busy2 = 0xFF,
+  // all other values indicate an error
+} AS7000LoaderStatus;
+
+typedef struct PACKED AS7000FWUpdateHeader {
+  uint8_t sw_version_major;
+  uint8_t sw_version_minor;
+} AS7000FWUpdateHeader;
+
+typedef struct PACKED AS7000FWSegmentHeader {
+  uint16_t address;
+  uint16_t len_minus_1;
+} AS7000FWSegmentHeader;
+
+//! The maximum number of data bytes to include in a reconstituted
+//! Intel HEX Data record when updating the HRM firmware.
+//! This is the size of the binary data encoded in the record, __NOT__
+//! the size of the HEX record encoding the data. The HEX record itself
+//! will be IHEX_RECORD_LENGTH(MAX_HEX_DATA_BYTES)
+//! (MAX_HEX_DATA_BYTES*2 + 11) bytes in size.
+#define MAX_HEX_DATA_BYTES (96)
+
+// The AS7000 loader cannot accept HEX records longer than 256 bytes.
+_Static_assert(IHEX_RECORD_LENGTH(MAX_HEX_DATA_BYTES) <= 256,
+               "The value of MAX_HEX_DATA_BYTES will result in HEX records "
+               "which are longer than the AS7000 loader can handle.");
+
+
+// The sw_version_major field is actually a bitfield encoding both the
+// major and minor components of the SDK version number. Define macros
+// to extract the components for logging purposes.
+#define HRM_SW_VERSION_PART_MAJOR(v) (v >> 6)
+#define HRM_SW_VERSION_PART_MINOR(v) (v & 0x3f)
+
+// If this many watchdog interrupts occur before we receive an interrupt from the sensor,
+// we assume the sensor requires a reset
+#define AS7000_MAX_WATCHDOG_INTERRUPTS 5
+
+// We use this regular timer as a watchdog for the sensor. We have seen cases where the sensor
+// becomes unresponsive (PBL-40008). This timer watches to see if we have stopped receiving
+// sensor interrupts and will trigger logic to reset the sensor if necessary.
+static RegularTimerInfo s_as7000_watchdog_timer;
+
+// Incremented by s_as7000_watchdog_timer. Reset to 0 by our interrupt handler.
+static uint8_t s_missing_interrupt_count;
+
+//! Interval timer to track how frequently the as7000 is handshaking with us
+static IntervalTimer s_handshake_interval_timer;
+
+static void prv_enable_timer_cb(void *context);
+static void prv_disable_watchdog(HRMDevice *dev);
+
+static bool prv_write_register(HRMDevice *dev, uint8_t register_address, uint8_t value) {
+  i2c_use(dev->i2c_slave);
+  bool rv = i2c_write_register(dev->i2c_slave, register_address, value);
+  i2c_release(dev->i2c_slave);
+  return rv;
+}
+
+static bool prv_write_register_block(HRMDevice *dev, uint8_t register_address,
+                                     const void *buffer, uint32_t length) {
+  i2c_use(dev->i2c_slave);
+  bool rv = i2c_write_register_block(dev->i2c_slave, register_address, length, buffer);
+  i2c_release(dev->i2c_slave);
+  return rv;
+}
+
+static bool prv_read_register(HRMDevice *dev, uint8_t register_address, uint8_t *value) {
+  i2c_use(dev->i2c_slave);
+  bool rv = i2c_read_register(dev->i2c_slave, register_address, value);
+  i2c_release(dev->i2c_slave);
+  return rv;
+}
+
+static bool prv_read_register_block(HRMDevice *dev, uint8_t register_address, void *buffer,
+                                    uint32_t length) {
+  i2c_use(dev->i2c_slave);
+  bool rv = i2c_read_register_block(dev->i2c_slave, register_address, length, buffer);
+  i2c_release(dev->i2c_slave);
+  return rv;
+}
+
+static bool prv_set_host_one_second_time_register(HRMDevice *dev, uint32_t average_ms) {
+  PPG_DBG("host one second time: %"PRIu32" ms", average_ms);
+
+  // Register takes a reading in 0.1ms increments
+  uint16_t value = average_ms * 10;
+
+  const uint8_t msb = (value >> 8) & 0xff;
+  const uint8_t lsb = value & 0xff;
+  return prv_write_register(dev, ADDR_HOST_ONE_SECOND_TIME_MSB, msb)
+      && prv_write_register(dev, ADDR_HOST_ONE_SECOND_TIME_LSB, lsb);
+}
+
+static void prv_read_ppg_data(HRMDevice *dev, HRMPPGData *data_out) {
+  uint8_t num_ppg_samples;
+  prv_read_register(HRM, ADDR_NUM_PPG_SAMPLES, &num_ppg_samples);
+  num_ppg_samples = MIN(num_ppg_samples, MAX_PPG_SAMPLES);
+
+  for (int i = 0; i < num_ppg_samples; ++i) {
+    struct PACKED {
+      uint8_t idx;
+      uint16_t ppg;
+      uint16_t tia;
+    } ppg_reading;
+
+    // Reading PPG data from the chip is a little weird. We need to read the PPG block of registers
+    // which maps to the ppg_reading struct above. We then need to verify that the index that we
+    // read matches the one that we expect. If we attempt to read the registers too quickly back to
+    // back that means that the AS7000 failed to update the value in time and we just need to try
+    // again. Limit this to a fixed number of attempts to make sure we don't infinite loop.
+    const int NUM_ATTEMPTS = 3;
+    bool success = false;
+    for (int j = 0; j < NUM_ATTEMPTS; ++j) {
+      prv_read_register_block(HRM, ADDR_PPG_IDX, &ppg_reading, sizeof(ppg_reading));
+      if (ppg_reading.idx == i + 1) {
+        data_out->indexes[i] = ppg_reading.idx;
+        data_out->ppg[i] = ntohs(ppg_reading.ppg);
+        data_out->tia[i] = ntohs(ppg_reading.tia);
+
+        success = true;
+        break;
+      }
+
+      PPG_DBG_VERBOSE("FAIL: got %"PRIu16" expected %u tia %"PRIu16,
+                      ppg_reading.idx, i + 1, ntohs(ppg_reading.tia));
+      // Keep trying...
+    }
+
+    if (!success) {
+      // We didn't find a sample, just give up on reading PPG for this handshake
+      break;
+    }
+
+    data_out->num_samples++;
+  }
+
+  PPG_DBG("num_samples reg: %"PRIu8" read: %u",
+          num_ppg_samples, data_out->num_samples);
+}
+
+static void prv_write_accel_sample(HRMDevice *dev, uint8_t sample_idx, AccelRawData *data) {
+  struct PACKED {
+    uint8_t sample_idx;
+    net16 accel_x;
+    net16 accel_y;
+    net16 accel_z;
+  } sample_data = {
+    .sample_idx = sample_idx,
+    .accel_x = hton16(data->x * 2), // Accel service supplies mGs, AS7000 expects lsb = 0.5 mG
+    .accel_y = hton16(data->y * 2),
+    .accel_z = hton16(data->z * 2)
+  };
+  prv_write_register_block(dev, ADDR_ACCEL_SAMPLE_IDX, &sample_data, sizeof(sample_data));
+}
+
+static void prv_read_hrm_data(HRMDevice *dev, HRMData *data) {
+  struct PACKED {
+    uint16_t led_current;
+    uint8_t hrm_status;
+    uint8_t bpm;
+    uint8_t sqi;
+  } hrm_data_regs;
+
+  prv_read_register_block(dev, ADDR_LED_CURRENT_MSB, &hrm_data_regs, sizeof(hrm_data_regs));
+
+  data->led_current_ua = ntohs(hrm_data_regs.led_current);
+  data->hrm_status = hrm_data_regs.hrm_status;
+  data->hrm_bpm = hrm_data_regs.bpm;
+
+  if (data->hrm_status & AS7000Status_NoAccel) {
+    data->hrm_quality = HRMQuality_NoAccel;
+  } else if (hrm_data_regs.sqi <= AS7000SQIThreshold_Excellent) {
+    data->hrm_quality = HRMQuality_Excellent;
+  } else if (hrm_data_regs.sqi <= AS7000SQIThreshold_Good) {
+    data->hrm_quality = HRMQuality_Good;
+  } else if (hrm_data_regs.sqi <= AS7000SQIThreshold_Acceptable) {
+    data->hrm_quality = HRMQuality_Acceptable;
+  } else if (hrm_data_regs.sqi <= AS7000SQIThreshold_Poor) {
+    data->hrm_quality = HRMQuality_Poor;
+  } else if (hrm_data_regs.sqi <= AS7000SQIThreshold_Worst) {
+    data->hrm_quality = HRMQuality_Worst;
+  } else if (hrm_data_regs.sqi == AS7000SQIThreshold_OffWrist) {
+    data->hrm_quality = HRMQuality_OffWrist;
+  } else {
+    data->hrm_quality = HRMQuality_NoSignal;
+  }
+}
+
+// Sequence of events for handshake pulse (when in one-second burst mode):
+//    - [optional] Host writes the one-second time (registers 10,11) measured for the last 20
+//      samples (about one second).
+//    - Host reads any data/HRV-result/LED-current, as needed (see registers [14...53])
+//    - Host reads the HRM-result/SYNC-byte (registers [54...57]).
+//      If not in HRM-mode, the host can just read the SYNC-byte (register 57).
+//      Reading the SYNC-byte causes the AS7000 to release the handshake-signal
+//      and allows deep-sleep mode (if the AS7000 is configured for this).
+//      This step must be the last read for this handshake-pulse.
+static void prv_handle_handshake_pulse(void *unused_data) {
+  PPG_DBG("Handshake handle");
+
+  mutex_lock(HRM->state->lock);
+  if (!hrm_is_enabled(HRM)) {
+    mutex_unlock(HRM->state->lock);
+    return;
+  }
+
+  // We keep track of the number of handshakes so that we know when to expect samples
+  const bool should_expect_samples = (HRM->state->handshake_count > WARMUP_HANDSHAKES);
+
+  HRMData data = (HRMData) {};
+
+  // Immediately read the PPG data. The timing constraints are pretty tight (we need to read this
+  // within 30ms~ of getting the handshake or else we'll lose PPG data). The other registers can
+  // be read at anytime before the next handshake, so it's ok to do this first.
+  prv_read_ppg_data(HRM, &data.ppg_data);
+
+  if (should_expect_samples) {
+    interval_timer_take_sample(&s_handshake_interval_timer);
+  }
+
+  // Send the accel data out to the AS7000
+  HRMAccelData *accel_data = hrm_manager_get_accel_data();
+  const uint8_t num_samples = accel_data->num_samples;
+  prv_write_register(HRM, ADDR_NUM_ACCEL_SAMPLES, num_samples);
+  for (uint32_t i = 0; i < num_samples; ++i) {
+    prv_write_accel_sample(HRM, i + 1, &accel_data->data[i]);
+  }
+  data.accel_data = *accel_data;
+  hrm_manager_release_accel_data();
+
+  // Read the rest of the HRM data fields.
+  prv_read_hrm_data(HRM, &data);
+
+  // Handle the clock skew register
+  uint32_t average_handshake_interval_ms;
+  uint32_t num_intervals = interval_timer_get(&s_handshake_interval_timer,
+                                              &average_handshake_interval_ms);
+  // Try to write the register frequently early on, and then every half second to accommodate
+  // changes over time.
+  if (num_intervals == 2 ||
+      num_intervals == 10 ||
+      (num_intervals % 30) == 0) {
+    prv_set_host_one_second_time_register(HRM, average_handshake_interval_ms);
+  }
+
+  // Read the SYNC byte to release handshake signal and enter deep sleep mode.
+  uint8_t unused;
+  prv_read_register(HRM, ADDR_SYNC, &unused);
+
+
+  PPG_DBG("Handshake handle done");
+  HRM->state->handshake_count++;
+
+
+  PROFILER_NODE_STOP(hrm_handling);
+  mutex_unlock(HRM->state->lock);
+
+
+  // PPG_DBG log out each PPG data sample that we recorded
+  for (int i = 0; i < data.ppg_data.num_samples; i++) {
+    PPG_DBG_VERBOSE("idx %-2"PRIu8" ppg %-6"PRIu16" tia %-6"PRIu16,
+                    data.ppg_data.indexes[i], data.ppg_data.ppg[i], data.ppg_data.tia[i]);
+  }
+
+  hrm_manager_new_data_cb(&data);
+
+  if (num_samples == 0 && should_expect_samples) {
+    analytics_inc(ANALYTICS_DEVICE_METRIC_HRM_ACCEL_DATA_MISSING, AnalyticsClient_System);
+    PBL_LOG(LOG_LEVEL_WARNING, "Falling behind: HRM got 0 accel samples");
+  }
+
+}
+
+static void prv_as7000_interrupt_handler(bool *should_context_switch) {
+  PPG_DBG("Handshake interrupt");
+
+  PROFILER_NODE_START(hrm_handling); // Starting to respond to handshake toggle
+
+  // Reset the watchdog counter
+  s_missing_interrupt_count = 0;
+
+  *should_context_switch = new_timer_add_work_callback_from_isr(prv_handle_handshake_pulse, NULL);
+}
+
+static void prv_interrupts_enable(HRMDevice *dev, bool enable) {
+  mutex_assert_held_by_curr_task(dev->state->lock, true);
+  exti_configure_pin(dev->handshake_int, ExtiTrigger_Falling, prv_as7000_interrupt_handler);
+  exti_enable(dev->handshake_int);
+}
+
+static void prv_log_running_apps(HRMDevice *dev) {
+  uint8_t app_ids = 0;
+  if (!prv_read_register(dev, ADDR_APP_IDS, &app_ids)) {
+    PBL_LOG(LOG_LEVEL_ERROR, "Failed to get running apps");
+    return;
+  }
+  PBL_LOG(LOG_LEVEL_DEBUG, "Running applications:");
+  if (app_ids == AS7000AppId_Idle) {
+    PBL_LOG(LOG_LEVEL_DEBUG, " - None (idle)");
+  } else {
+    if (app_ids & AS7000AppId_Loader) {
+      PBL_LOG(LOG_LEVEL_DEBUG, " - Loader");
+    }
+    if (app_ids & AS7000AppId_HRM) {
+      PBL_LOG(LOG_LEVEL_DEBUG, " - HRM");
+    }
+    if (app_ids & AS7000AppId_PRV) {
+      PBL_LOG(LOG_LEVEL_DEBUG, " - PRV");
+    }
+    if (app_ids & AS7000AppId_GSR) {
+      PBL_LOG(LOG_LEVEL_DEBUG, " - GSR");
+    }
+    if (app_ids & AS7000AppId_NTC) {
+      PBL_LOG(LOG_LEVEL_DEBUG, " - NTC");
+    }
+  }
+}
+
+static bool prv_get_and_log_device_info(HRMDevice *dev, AS7000InfoRecord *info,
+                                        bool log_version) {
+  // get the device info
+  if (!prv_read_register_block(dev, ADDR_INFO_START, info, sizeof(AS7000InfoRecord))) {
+    return false;
+  }
+
+  if (log_version) {
+    // print out the version information
+    PBL_LOG(LOG_LEVEL_INFO, "AS7000 enabled! Protocol v%" PRIu8 ".%" PRIu8
+      ", SW v%" PRIu8 ".%" PRIu8 ".%" PRIu8 ", HW Rev %" PRIu8,
+            info->protocol_version_major, info->protocol_version_minor,
+            HRM_SW_VERSION_PART_MAJOR(info->sw_version_major),
+            HRM_SW_VERSION_PART_MINOR(info->sw_version_major),
+            info->sw_version_minor, info->hw_revision);
+  }
+  prv_log_running_apps(dev);
+  return true;
+}
+
+static bool prv_is_app_running(HRMDevice *dev, AS7000AppId app) {
+  uint8_t running_apps = 0;
+  if (!prv_read_register(dev, ADDR_APP_IDS, &running_apps)) {
+    return false;
+  }
+  PBL_LOG(LOG_LEVEL_DEBUG, "Apps running: 0x%"PRIx8, running_apps);
+  if (app == AS7000AppId_Idle) {
+    // no apps should be running
+    return running_apps == AS7000AppId_Idle;
+  }
+  return running_apps & app;
+}
+
+//! Set the applications that should be running on the HRM.
+//!
+//! This commands the HRM to start or continue running any apps whose
+//! flags are set, and to stop all apps whose flags are unset. Depending
+//! on the firmware loaded onto the HRM, multiple apps can be run
+//! concurrently by setting the logical OR of the App IDs.
+static bool prv_set_running_apps(HRMDevice *dev, AS7000AppId apps) {
+  return prv_write_register(dev, ADDR_APP_IDS, apps);
+}
+
+// Wait for the INT line to go low. Return true if it went low before timing out
+static bool prv_wait_int_low(HRMDevice *dev) {
+  const int max_attempts = 2000;
+  int attempt;
+  for (attempt = 0; attempt < max_attempts; attempt++) {
+    if (!gpio_input_read(&dev->int_gpio)) {
+      break;
+    }
+    system_task_watchdog_feed();
+    psleep(1);
+  }
+  return (attempt < max_attempts);
+}
+
+// Wait for the INT line to go high. Return true if it went high before timing out
+static bool prv_wait_int_high(HRMDevice *dev) {
+  const int max_attempts = 300;
+  int attempt;
+  for (attempt = 0; attempt < max_attempts; attempt++) {
+    if (gpio_input_read(&dev->int_gpio)) {
+      break;
+    }
+    system_task_watchdog_feed();
+    psleep(1);
+  }
+  return (attempt < max_attempts);
+}
+
+// NOTE: the caller must hold the device's state lock
+static void prv_disable(HRMDevice *dev) {
+  mutex_assert_held_by_curr_task(dev->state->lock, true);
+
+  // Turn off our watchdog timer
+  prv_disable_watchdog(dev);
+
+  // Make sure interrupts are fully disabled before changing state
+  prv_interrupts_enable(dev, false);
+  // Put the INT pin back into a low power state that won't interfere with jtag using the pin
+  gpio_analog_init(&dev->int_gpio);
+
+  PBL_LOG(LOG_LEVEL_DEBUG, "Shutting down device.");
+  switch (dev->state->enabled_state) {
+    case HRMEnabledState_PoweringOn:
+      new_timer_stop(dev->state->timer);
+      // Delay a bit so that we don't deassert the enable GPIO while in
+      // the loader and unintentionally activate force loader mode.
+      psleep(LOADER_READY_MAX_DELAY_MS);
+      // fallthrough
+    case HRMEnabledState_Enabled:
+      gpio_output_set(&dev->en_gpio, false);
+      dev->state->enabled_state = HRMEnabledState_Disabled;
+      break;
+    case HRMEnabledState_Disabled:
+      // nothing to do
+      break;
+    case HRMEnabledState_Uninitialized:
+      // the lock isn't even created yet - should never get here
+      // fallthrough
+    default:
+      WTF;
+  }
+  led_disable(LEDEnablerHRM);
+  analytics_stopwatch_stop(ANALYTICS_DEVICE_METRIC_HRM_ON_TIME);
+}
+
+// NOTE: the caller must hold the device's state lock
+static void prv_enable(HRMDevice *dev) {
+  mutex_assert_held_by_curr_task(dev->state->lock, true);
+  if (dev->state->enabled_state == HRMEnabledState_Uninitialized) {
+    PBL_LOG(LOG_LEVEL_ERROR, "Trying to enable HRM before initialization.");
+
+  } else if (dev->state->enabled_state == HRMEnabledState_Disabled) {
+    led_enable(LEDEnablerHRM);
+    analytics_stopwatch_start(ANALYTICS_DEVICE_METRIC_HRM_ON_TIME, AnalyticsClient_System);
+
+    // Enable the device and schedule a timer callback for when we can start communicating with it.
+    gpio_output_set(&dev->en_gpio, true);
+    dev->state->enabled_state = HRMEnabledState_PoweringOn;
+    dev->state->handshake_count = 0;
+    new_timer_start(dev->state->timer, NORMAL_BOOT_DELAY_MS, prv_enable_timer_cb, (void *)dev,
+                    0 /* flags */);
+
+    interval_timer_init(&s_handshake_interval_timer, 900, 1100, 8);
+
+    PBL_LOG(LOG_LEVEL_DEBUG, "Enabling AS7000...");
+  }
+}
+
+// This system task callback is triggered by the watchdog interrupt handler when we detect
+// a frozen sensor
+static void prv_watchdog_timer_system_cb(void *data) {
+  HRMDevice *dev = (HRMDevice *)data;
+  mutex_lock(dev->state->lock);
+  if (dev->state->enabled_state != HRMEnabledState_Enabled) {
+    goto exit;
+  }
+
+  // If we have gone too long without getting an interrupt, let's reset the device
+  if (s_missing_interrupt_count >= AS7000_MAX_WATCHDOG_INTERRUPTS) {
+    PBL_LOG(LOG_LEVEL_ERROR, "Watchdog logic detected frozen sensor. Resetting now.");
+    analytics_inc(ANALYTICS_DEVICE_METRIC_HRM_WATCHDOG_TIMEOUT, AnalyticsClient_System);
+    prv_disable(dev);
+    psleep(SHUT_DOWN_DELAY_MS);
+    prv_enable(dev);
+  }
+exit:
+  mutex_unlock(dev->state->lock);
+}
+
+// This regular timer callback executes once a second. It is part of the watchdog logic used to
+// detect if the sensor becomes unresponsive.
+static void prv_watchdog_timer_cb(void *data) {
+  HRMDevice *dev = (HRMDevice *)data;
+  if (++s_missing_interrupt_count >= AS7000_MAX_WATCHDOG_INTERRUPTS) {
+    system_task_add_callback(prv_watchdog_timer_system_cb, (void *)dev);
+  }
+  if (s_missing_interrupt_count > 1) {
+    PBL_LOG(LOG_LEVEL_DEBUG, "Missing interrupt count: %"PRIu8" ", s_missing_interrupt_count);
+  }
+}
+
+// Enable the watchdog timer. This gets enabled when we enable the sensor and detects if
+// the sensor stops generating interrupts.
+static void prv_enable_watchdog(HRMDevice *dev) {
+  mutex_assert_held_by_curr_task(dev->state->lock, true);
+  s_as7000_watchdog_timer = (RegularTimerInfo) {
+    .cb = prv_watchdog_timer_cb,
+    .cb_data = (void *)dev,
+  };
+  s_missing_interrupt_count = 0;
+  regular_timer_add_seconds_callback(&s_as7000_watchdog_timer);
+}
+
+static void prv_disable_watchdog(HRMDevice *dev) {
+  mutex_assert_held_by_curr_task(dev->state->lock, true);
+  regular_timer_remove_callback(&s_as7000_watchdog_timer);
+  s_missing_interrupt_count = 0;
+}
+
+static bool prv_start_loader(HRMDevice *dev) {
+  // check if the loader is already running
+  if (!prv_is_app_running(dev, AS7000AppId_Loader)) {
+    PBL_LOG(LOG_LEVEL_DEBUG, "Switching to loader");
+    // we need to start the loader
+    if (!prv_set_running_apps(dev, AS7000AppId_Loader)) {
+      return false;
+    }
+    psleep(35);
+
+    // make sure the loader is running
+    if (!prv_is_app_running(dev, AS7000AppId_Loader)) {
+      return false;
+    }
+  }
+  prv_log_running_apps(dev);
+  return true;
+}
+
+static uint64_t prv_get_time_ms(void) {
+  time_t time_s;
+  uint16_t time_ms;
+  rtc_get_time_ms(&time_s, &time_ms);
+  return ((uint64_t)time_s) * 1000 + time_ms;
+}
+
+static bool prv_wait_for_loader_ready(HRMDevice *dev) {
+  uint64_t end_time_ms = prv_get_time_ms() + LOADER_READY_MAX_DELAY_MS;
+
+  do {
+    uint8_t status = 0;
+    if (!prv_read_register(dev, ADDR_LOADER_STATUS, &status)) {
+      PBL_LOG(LOG_LEVEL_ERROR, "Failed reading status");
+      return false;
+    }
+
+    if (status == AS7000LoaderStatus_Ready) {
+      // ready
+      return true;
+    } else if ((status != AS7000LoaderStatus_Busy1) && (status != AS7000LoaderStatus_Busy2)) {
+      // error
+      PBL_LOG(LOG_LEVEL_ERROR, "Error status: %"PRIx8, status);
+      return false;
+    }
+    psleep(1);
+  } while (prv_get_time_ms() < end_time_ms);
+
+  PBL_LOG(LOG_LEVEL_ERROR, "Timed out waiting for the loader to be ready!");
+  return false;
+}
+
+static bool prv_flash_fw(HRMDevice *dev) {
+  // switch to the loader
+  if (!prv_start_loader(dev)) {
+    PBL_LOG(LOG_LEVEL_ERROR, "Failed to start loader");
+    return false;
+  }
+
+  // wait for the loader to be ready
+  if (!prv_wait_for_loader_ready(dev)) {
+    PBL_LOG(LOG_LEVEL_ERROR, "Loader not ready");
+    return false;
+  }
+
+  const uint32_t image_length =
+    resource_size(SYSTEM_APP, RESOURCE_ID_AS7000_FW_IMAGE);
+  PBL_ASSERTN(image_length);
+  PBL_LOG(LOG_LEVEL_DEBUG,
+          "Loading FW image (%"PRIu32" bytes encoded)", image_length);
+  // Skip over the image header.
+  uint32_t cursor = sizeof(AS7000FWUpdateHeader);
+  while (cursor < image_length) {
+    // Make sure we can load enough data for a valid segment. There is
+    // always at least one data byte in each segment, so there must be
+    // strictly more data to read past the end of the header.
+    PBL_ASSERTN((image_length - cursor) > sizeof(AS7000FWSegmentHeader));
+    // Read the header.
+    AS7000FWSegmentHeader segment_header;
+    if (resource_load_byte_range_system(
+          SYSTEM_APP, RESOURCE_ID_AS7000_FW_IMAGE, cursor,
+          (uint8_t *)&segment_header, sizeof(segment_header)) !=
+        sizeof(segment_header)) {
+      PBL_LOG(LOG_LEVEL_ERROR, "Failed to read FW image! "
+              "(segment header @ 0x%" PRIx32 ")", cursor);
+      return false;
+    }
+    cursor += sizeof(segment_header);
+    // Write all the data bytes in the segment to the HRM.
+    uint16_t write_address = segment_header.address;
+    uint32_t bytes_remaining = segment_header.len_minus_1 + 1;
+    while (bytes_remaining) {
+      uint8_t chunk[MAX_HEX_DATA_BYTES];
+      const size_t load_length = MIN(MAX_HEX_DATA_BYTES, bytes_remaining);
+      if (resource_load_byte_range_system(
+            SYSTEM_APP, RESOURCE_ID_AS7000_FW_IMAGE, cursor, chunk, load_length)
+          != load_length) {
+        PBL_LOG(LOG_LEVEL_ERROR, "Failed to read FW image! "
+                "(segment data @ 0x%" PRIx32 ")", cursor);
+        return false;
+      }
+
+      // Encode the chunk into an Intel HEX record and send it to the
+      // AS7000 loader.
+      uint8_t data_record[IHEX_RECORD_LENGTH(MAX_HEX_DATA_BYTES)];
+      ihex_encode(data_record, IHEX_TYPE_DATA, write_address,
+                  chunk, load_length);
+      if (!prv_write_register_block(dev, ADDR_LOADER_STATUS, data_record,
+                                    IHEX_RECORD_LENGTH(load_length))) {
+        PBL_LOG(LOG_LEVEL_ERROR, "Failed to write hex record");
+        return false;
+      }
+
+      // Wait for the loader to be ready, indicating that the last
+      // record was successfully written.
+      if (!prv_wait_for_loader_ready(dev)) {
+        PBL_LOG(LOG_LEVEL_ERROR, "Loader not ready");
+        return false;
+      }
+
+      system_task_watchdog_feed();
+
+      cursor += load_length;
+      write_address += load_length;
+      bytes_remaining -= load_length;
+    }
+  }
+
+  // Write the EOF record, telling the loader that the image has been
+  // fully written.
+  uint8_t eof_record[IHEX_RECORD_LENGTH(0)];
+  ihex_encode(eof_record, IHEX_TYPE_EOF, 0, NULL, 0);
+  if (!prv_write_register_block(dev, ADDR_LOADER_STATUS,
+                                eof_record, IHEX_RECORD_LENGTH(0))) {
+    PBL_LOG(LOG_LEVEL_ERROR, "Failed to write EOF record");
+    return false;
+  }
+
+  return true;
+}
+
+static bool prv_set_accel_sample_frequency(HRMDevice *dev, uint16_t freq) {
+  const uint8_t msb = (freq >> 8) & 0xff;
+  const uint8_t lsb = freq & 0xff;
+  return prv_write_register(dev, ADDR_ACCEL_SAMPLE_FREQ_MSB, msb)
+      && prv_write_register(dev, ADDR_ACCEL_SAMPLE_FREQ_LSB, lsb);
+}
+
+static void prv_enable_system_task_cb(void *context) {
+  HRMDevice *dev = context;
+  mutex_lock(dev->state->lock);
+  if (dev->state->enabled_state == HRMEnabledState_Disabled) {
+    // Enable was cancelled before this callback fired.
+    goto done;
+  } else if (dev->state->enabled_state != HRMEnabledState_PoweringOn) {
+    PBL_LOG(LOG_LEVEL_ERROR, "Enable KernelBG callback fired while HRM was in "
+            "an unexpected state: %u", (unsigned int)dev->state->enabled_state);
+    WTF;
+  }
+
+  AS7000InfoRecord info;
+  if (!prv_get_and_log_device_info(dev, &info, false /* log_version */)) {
+    PBL_LOG(LOG_LEVEL_ERROR, "Failed to query AS7000 device info");
+    goto failed;
+  }
+
+  if (info.application_id == AS7000AppId_Loader) {
+    // This shouldn't happen. The application firmware should have been
+    // flashed during boot.
+    PBL_LOG(LOG_LEVEL_ERROR,
+            "AS7000 booted into loader! Something is very wrong.");
+    goto failed;
+  }
+
+  // check that we can communicate with this chip
+  if (info.protocol_version_major != EXPECTED_PROTOCOL_VERSION_MAJOR) {
+    // we don't know how to talk with this chip, so bail
+    PBL_LOG(LOG_LEVEL_ERROR, "Unexpected protocol version!");
+    goto failed;
+  }
+
+  if (info.application_id != AS7000AppId_Idle) {
+    PBL_LOG(LOG_LEVEL_ERROR,
+            "Unexpected application running: 0x%" PRIx8, info.application_id);
+    goto failed;
+  }
+
+  // the INT line should be low
+  if (gpio_input_read(&dev->int_gpio)) {
+    PBL_LOG(LOG_LEVEL_ERROR, "INT line is not low!");
+    goto failed;
+  }
+
+  // Set the accelerometer sample frequency
+  PBL_LOG(LOG_LEVEL_DEBUG, "Setting accel frequency");
+  PBL_ASSERTN(HRM_MANAGER_ACCEL_RATE_MILLIHZ >= 10000 && HRM_MANAGER_ACCEL_RATE_MILLIHZ <= 20000);
+  if (!prv_set_accel_sample_frequency(dev, HRM_MANAGER_ACCEL_RATE_MILLIHZ)) {
+    PBL_LOG(LOG_LEVEL_ERROR, "Failed to set accel frequency");
+    goto failed;
+  }
+
+  // Set the presence detection threshold
+  uint8_t pres_detect_thrsh;
+  WatchInfoColor model_color = mfg_info_get_watch_color();
+  switch (model_color) {
+    case WATCH_INFO_COLOR_PEBBLE_2_HR_BLACK:
+    case WATCH_INFO_COLOR_PEBBLE_2_HR_FLAME:
+      pres_detect_thrsh = PRES_DETECT_THRSH_BLACK;
+      break;
+    case WATCH_INFO_COLOR_PEBBLE_2_HR_WHITE:
+    case WATCH_INFO_COLOR_PEBBLE_2_HR_LIME:
+    case WATCH_INFO_COLOR_PEBBLE_2_HR_AQUA:
+      pres_detect_thrsh = PRES_DETECT_THRSH_WHITE;
+      break;
+    default:
+      pres_detect_thrsh = 1;
+      break;
+  }
+  if (!prv_write_register(dev, ADDR_PRES_DETECT_THRSH, pres_detect_thrsh)) {
+    PBL_LOG(LOG_LEVEL_ERROR, "Failed to set presence detection threshold");
+    goto failed;
+  }
+
+  // start the HRM app
+  PBL_LOG(LOG_LEVEL_DEBUG, "Starting HRM app");
+  if (!prv_set_running_apps(dev, AS7000AppId_HRM)) {
+    PBL_LOG(LOG_LEVEL_ERROR, "Failed to start HRM app!");
+    goto failed;
+  }
+
+  // Configure the int_gpio pin only when we're going to use it, as this pin is shared with
+  // the jtag pins and therefore can cause issues when flashing firmwares onto bigboards.
+  gpio_input_init_pull_up_down(&dev->int_gpio, GPIO_PuPd_UP);
+
+  // wait for the INT line to go high indicating the Idle app has ended
+  if (!prv_wait_int_high(dev)) {
+    PBL_LOG(LOG_LEVEL_ERROR, "Timed-out waiting for the Idle app to end but we "
+            "probably just missed it");
+    // TODO: The line only goes high for a few ms. If there is any kind of context switch while we
+    // wait for the line to go high we will miss this. Let's fix this the right way in PBL-41812
+    // (check for this change via an ISR) for 4.2 but just go with the smallest change for 4.1
+  }
+
+  // wait for the INT line to go low indicating the HRM app is ready
+  if (!prv_wait_int_low(dev)) {
+    PBL_LOG(LOG_LEVEL_ERROR, "Timed-out waiting for the HRM app to be ready");
+    goto failed;
+  }
+
+  // get the running apps (also triggers the app to start)
+  prv_log_running_apps(dev);
+
+  // HRM app is ready, enable handshake interrupts
+  prv_interrupts_enable(dev, true);
+
+  // We are now fully enabled
+  dev->state->enabled_state = HRMEnabledState_Enabled;
+
+  // Enable the watchdog
+  prv_enable_watchdog(dev);
+
+  goto done;
+
+failed:
+  prv_disable(dev);
+done:
+  mutex_unlock(dev->state->lock);
+}
+
+static void prv_enable_timer_cb(void *context) {
+  system_task_add_callback(prv_enable_system_task_cb, context);
+}
+
+void hrm_init(HRMDevice *dev) {
+  PBL_ASSERTN(dev->state->enabled_state == HRMEnabledState_Uninitialized);
+
+  dev->state->lock = mutex_create();
+  dev->state->timer = new_timer_create();
+  dev->state->enabled_state = HRMEnabledState_Disabled;
+
+  // Boot up the HRM so that we can read off the firmware version to see
+  // if it needs to be updated.
+
+  // First, read the version from the firmware update resource.
+  const uint32_t update_length = resource_size(
+      SYSTEM_APP, RESOURCE_ID_AS7000_FW_IMAGE);
+  if (update_length == 0) {
+    // We don't have a firmware to write so there's no point in booting
+    // the HRM.
+    PBL_LOG(LOG_LEVEL_DEBUG, "No HRM FW update available");
+    return;
+  }
+
+  AS7000FWUpdateHeader image_header;
+  if (resource_load_byte_range_system(
+        SYSTEM_APP, RESOURCE_ID_AS7000_FW_IMAGE, 0, (uint8_t *)&image_header,
+        sizeof(image_header)) != sizeof(image_header)) {
+    PBL_LOG(LOG_LEVEL_ERROR, "Failed to read HRM FW image header!");
+    return;
+  }
+  PBL_LOG(LOG_LEVEL_DEBUG, "FW update image is v%" PRIu8 ".%" PRIu8 ".%" PRIu8,
+          HRM_SW_VERSION_PART_MAJOR(image_header.sw_version_major),
+          HRM_SW_VERSION_PART_MINOR(image_header.sw_version_major),
+          image_header.sw_version_minor);
+
+  // Now that we know what version the image is, actually boot up the
+  // HRM so we can read off the version.
+
+  PBL_LOG(LOG_LEVEL_DEBUG, "Booting AS7000...");
+
+  gpio_output_init(&dev->en_gpio, GPIO_OType_PP, GPIO_Speed_2MHz);
+#if HRM_FORCE_FLASH
+  // Force the HRM into loader mode which will cause the firmware to be
+  // reflashed on every boot. If the HRM is loaded with a broken
+  // firmware which doesn't enter standby when the enable pin is high,
+  // the board will need to be power-cycled (entering standby/shutdown
+  // is sufficient) in order to get force-flashing to succeed.
+  gpio_output_set(&dev->en_gpio, false);
+  psleep(50);
+  gpio_output_set(&dev->en_gpio, true);
+  psleep(20);
+  gpio_output_set(&dev->en_gpio, false);
+  psleep(20);
+#else
+  gpio_output_set(&dev->en_gpio, true);
+  psleep(NORMAL_BOOT_DELAY_MS);
+#endif
+
+  AS7000InfoRecord hrm_info;
+  if (!prv_get_and_log_device_info(dev, &hrm_info, true /* log_version */)) {
+    PBL_LOG(LOG_LEVEL_ERROR, "Failed to read AS7000 version info!");
+    goto cleanup;
+  }
+
+  if (hrm_info.application_id == AS7000AppId_Loader ||
+      hrm_info.sw_version_major != image_header.sw_version_major ||
+      hrm_info.sw_version_minor != image_header.sw_version_minor) {
+    // We technically could leave the firmware on the HRM alone if the
+    // minor version in the chip is newer than in the update image, but
+    // for sanity's sake let's always make sure the HRM firmware is in
+    // sync with the version shipped with the Pebble firmware.
+    PBL_LOG(LOG_LEVEL_DEBUG, "AS7000 firmware version mismatch. Flashing...");
+    if (!prv_flash_fw(dev)) {
+      PBL_LOG(LOG_LEVEL_ERROR, "Failed to flash firmware");
+      goto cleanup;
+    }
+    // We need to wait for the HRM to reboot into the application before
+    // releasing the enable GPIO. If the loader sees the GPIO released
+    // during boot, it will activate "force loader mode" and fall back
+    // into the loader. Since we're waiting anyway, we might as well
+    // query the version info again to make sure the update took.
+    PBL_LOG(LOG_LEVEL_DEBUG, "Firmware flashed! Waiting for reboot...");
+    gpio_output_set(&dev->en_gpio, true);
+    psleep(LOADER_REBOOT_DELAY_MS);
+    if (!prv_get_and_log_device_info(dev, &hrm_info, true /* log_version */)) {
+      PBL_LOG(LOG_LEVEL_ERROR,
+              "Failed to read AS7000 version info after flashing!");
+      goto cleanup;
+    }
+  } else {
+    PBL_LOG(LOG_LEVEL_DEBUG, "AS7000 firmware is up to date.");
+  }
+
+cleanup:
+  // At this point the HRM should either be booted and running the
+  // application firmware, at which point deasserting the enable GPIO
+  // will signal it to shut down, or the firmware update failed and the
+  // loader is running, where deasserting the GPIO will not do much.
+  gpio_output_set(&dev->en_gpio, false);
+}
+
+void hrm_enable(HRMDevice *dev) {
+  if (!dev->state->lock) {
+    PBL_LOG(LOG_LEVEL_DEBUG, "Not an HRM Device.");
+    return;
+  }
+
+  mutex_lock(dev->state->lock);
+  prv_enable(dev);
+  mutex_unlock(dev->state->lock);
+}
+
+void hrm_disable(HRMDevice *dev) {
+  if (!dev->state->lock) {
+    PBL_LOG(LOG_LEVEL_DEBUG, "Not an HRM Device.");
+    return;
+  }
+
+  mutex_lock(dev->state->lock);
+  prv_disable(dev);
+  mutex_unlock(dev->state->lock);
+}
+
+bool hrm_is_enabled(HRMDevice *dev) {
+  return (dev->state->enabled_state == HRMEnabledState_Enabled
+         || dev->state->enabled_state == HRMEnabledState_PoweringOn);
+}
+
+void as7000_get_version_info(HRMDevice *dev, AS7000InfoRecord *info_out) {
+  if (!dev->state->lock) {
+    PBL_LOG(LOG_LEVEL_DEBUG, "Not an HRM Device.");
+    return;
+  }
+
+  mutex_lock(dev->state->lock);
+  if (!prv_get_and_log_device_info(dev, info_out, true /* log_version */)) {
+    PBL_LOG(LOG_LEVEL_WARNING, "Failed to read AS7000 version info");
+  }
+  mutex_unlock(dev->state->lock);
+}
+
+// Prompt Commands
+// ===============
+
+#include "console/prompt.h"
+#include <string.h>
+
+void command_hrm_wipe(void) {
+  // HEX records to write 0xFFFFFFFF to the magic number region.
+  const char *erase_magic_record = ":047FFC00FFFFFFFF85";
+  const char *eof_record = ":00000001FF";
+
+  mutex_lock(HRM->state->lock);
+  gpio_output_set(&HRM->en_gpio, true);
+  psleep(NORMAL_BOOT_DELAY_MS);
+
+  bool success = prv_start_loader(HRM) &&
+                 prv_wait_for_loader_ready(HRM) &&
+                 prv_write_register_block(HRM, ADDR_LOADER_STATUS,
+                                          erase_magic_record,
+                                          strlen(erase_magic_record)) &&
+                 prv_wait_for_loader_ready(HRM) &&
+                 prv_write_register_block(HRM, ADDR_LOADER_STATUS,
+                                          eof_record, strlen(eof_record)) &&
+                 prv_wait_for_loader_ready(HRM);
+
+  gpio_output_set(&HRM->en_gpio, false);
+  mutex_unlock(HRM->state->lock);
+
+  prompt_send_response(success? "HRM Firmware invalidated" : "ERROR");
+}
+
+// Simulate a frozen sensor for testing the watchdog recovery logic
+void command_hrm_freeze(void) {
+  HRMDevice *dev = HRM;
+  mutex_lock(dev->state->lock);
+  if (dev->state->enabled_state == HRMEnabledState_Enabled) {
+    prv_interrupts_enable(dev, false);
+    gpio_analog_init(&dev->int_gpio);
+    led_disable(LEDEnablerHRM);
+  }
+  mutex_unlock(dev->state->lock);
+}

+ 13 - 0
src/fw/drivers/hrm/as7000.h

@@ -48,3 +48,16 @@ typedef const struct HRMDevice {
   OutputConfig en_gpio;
   I2CSlavePort *i2c_slave;
 } HRMDevice;
+
+typedef struct PACKED AS7000InfoRecord {
+  uint8_t protocol_version_major;
+  uint8_t protocol_version_minor;
+  uint8_t sw_version_major;
+  uint8_t sw_version_minor;
+  uint8_t application_id;
+  uint8_t hw_revision;
+} AS7000InfoRecord;
+
+//! Fills a struct which contains version info about the AS7000
+//! This should probably only be used by the HRM Demo app
+void as7000_get_version_info(HRMDevice *dev, AS7000InfoRecord *info_out);

+ 14 - 0
src/fw/drivers/wscript_build

@@ -1539,4 +1539,18 @@ elif mcu_family == 'NRF52840':
         ],
     )
 
+if bld.get_hrm() == 'AS7000':
+    bld.objects(
+        name='driver_hrm',
+        source=[
+            'hrm/as7000.c',
+        ],
+        use=[
+            'driver_gpio',
+            'driver_i2c',
+            'fw_includes',
+            'stm32_stlib'
+        ],
+    )
+
 # vim:filetype=python

+ 6 - 0
src/fw/shell/normal/system_app_registry_list.json

@@ -533,6 +533,12 @@
         "spalding"
       ]
     },
+    {
+      "id": -89,
+      "enum": "HRM_DEMO",
+      "md_fn": "hrm_demo_get_app_info",
+      "ifdefs": ["SHOW_ACTIVITY_DEMO", "CAPABILITY_HAS_BUILTIN_HRM=1"]
+    },
     {
       "id": -90,
       "enum": "REMINDERS",

+ 1 - 0
tests/overrides/default/resources/robert/resource/resource_ids.auto.h

@@ -491,6 +491,7 @@ typedef enum {
   RESOURCE_ID_STORED_APP_GOLF = 464,
   RESOURCE_ID_BT_BOOT_IMAGE = 465,
   RESOURCE_ID_BT_FW_IMAGE = 466,
+  RESOURCE_ID_AS7000_FW_IMAGE = 467,
   RESOURCE_ID_TIMEZONE_DATABASE = 468,
   RESOURCE_ID_ACTION_BAR_ICON_CHECK = 469,
   RESOURCE_ID_GENERIC_WARNING_LARGE = 470,

+ 1 - 0
tests/overrides/default/resources/silk/resource/resource_ids.auto.h

@@ -490,6 +490,7 @@ typedef enum {
   RESOURCE_ID_STORED_APP_GOLF = 463,
   RESOURCE_ID_BT_BOOT_IMAGE = 464,
   RESOURCE_ID_BT_FW_IMAGE = 465,
+  RESOURCE_ID_AS7000_FW_IMAGE = 466,
   RESOURCE_ID_TIMEZONE_DATABASE = 467,
   RESOURCE_ID_FONT_FALLBACK_INTERNAL = 468,
   RESOURCE_ID_ARROW_DOWN = 469,