From 297c048092cdad01f17dc80213a303df260490ab Mon Sep 17 00:00:00 2001 From: Leonardo Bispo <34199302+ldab@users.noreply.github.com> Date: Thu, 16 Nov 2023 12:19:26 +0100 Subject: [PATCH] iOS Apple Media Service (#111) * ios Apple Media Service --- .github/workflows/build.yml | 7 + .gitignore | 1 + .vscode/settings.json | 1 + app/CMakeLists.txt | 1 + app/Kconfig | 16 + .../music_control/music_control_app.c | 28 ++ app/src/ble/ble_ams.c | 338 ++++++++++++++++++ app/src/ble/ble_ams.h | 14 + app/src/ble/ble_comm.c | 9 +- app/src/ble/ble_comm.h | 2 +- app/src/main.c | 8 + app/west.yml | 2 +- 12 files changed, 424 insertions(+), 3 deletions(-) create mode 100644 app/src/ble/ble_ams.c create mode 100644 app/src/ble/ble_ams.h diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1c0e01f0..761ed491 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,6 +43,13 @@ jobs: west build app --build-dir ${{ matrix.board }}_${{ matrix.built_type }} -p -b ${{ matrix.board }} -- -DOVERLAY_CONFIG=boards/${{ matrix.built_type }}.conf cd ${{ matrix.board }}_${{ matrix.built_type }}/zephyr mv zephyr.hex ${{ matrix.board }}_${{ matrix.built_type }}.hex || true + + - name: Build firmware for iOS + working-directory: ZSWatch + run: | + west build app --build-dir ${{ matrix.board }}_${{ matrix.built_type }}_ios -p -b ${{ matrix.board }} -- -DOVERLAY_CONFIG=boards/${{ matrix.built_type }}.conf -DCONFIG_BLE_USES_AMS="y" + cd ${{ matrix.board }}_${{ matrix.built_type }}/zephyr + mv zephyr.hex ${{ matrix.board }}_${{ matrix.built_type }}.hex || true - name : Upload Firmware uses: actions/upload-artifact@v3 diff --git a/.gitignore b/.gitignore index 1d651145..1a5d5012 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ build build* *.code-workspace *__pycache__* +nrf/ # Zephyr workspace /.west diff --git a/.vscode/settings.json b/.vscode/settings.json index 2d4a704a..0eb36367 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,4 +10,5 @@ "nrf-connect.debugging.bindings": { "${workspaceFolder}/app/build": "Launch build" }, + "editor.defaultFormatter": "chiehyu.vscode-astyle" } diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 99f6deb1..12e03bc9 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -60,6 +60,7 @@ target_sources(app PRIVATE src/ble/ble_aoa.c) target_sources(app PRIVATE src/ble/ble_comm.c) target_sources(app PRIVATE src/ble/ble_transport.c) target_sources(app PRIVATE src/ble/zsw_gatt_sensor_server.c) +target_sources_ifdef(CONFIG_BLE_USES_AMS app PRIVATE src/ble/ble_ams.c) target_sources(app PRIVATE src/sensors/zsw_imu.c) target_sources(app PRIVATE src/sensors/zsw_pressure_sensor.c) diff --git a/app/Kconfig b/app/Kconfig index 74d15d88..8bcef118 100644 --- a/app/Kconfig +++ b/app/Kconfig @@ -160,6 +160,22 @@ menu "ZSWatch" default n help "Disable encryption for BLE connection (pairing/bonding). Used only for debugging purposes." + + choice BLE_SMARTPHONE_INTERFACE + bool + prompt "Select which BLE smartphone interface to use." + def_bool BLE_USES_GADGETBRIDGE + help + "If using with iOS you likely use AMS" + + config BLE_USES_GADGETBRIDGE + prompt "Smartphone interaction and notifications uses Gadgetbridge" + config BLE_USES_AMS + prompt "Smartphone uses Apple Media Service Client" + select BT_GATT_DM + select BT_AMS_CLIENT + endchoice + endmenu menu "SPI RTT Flash Loader" diff --git a/app/src/applications/music_control/music_control_app.c b/app/src/applications/music_control/music_control_app.c index ab1683cb..b357734e 100644 --- a/app/src/applications/music_control/music_control_app.c +++ b/app/src/applications/music_control/music_control_app.c @@ -3,6 +3,10 @@ #include #include +#ifdef CONFIG_BLE_USES_AMS +#include "ble/ble_ams.h" +#endif + #include "music_control_ui.h" #include "ble/ble_comm.h" #include "events/ble_data_event.h" @@ -58,6 +62,8 @@ static void on_music_ui_evt_music(music_control_ui_evt_type_t evt_type) uint8_t buf[50]; int msg_len = 0; +#if defined(CONFIG_BLE_USES_GADGETBRIDGE) + switch (evt_type) { case MUSIC_CONTROL_UI_CLOSE: zsw_app_manager_app_close_request(&app); @@ -78,6 +84,28 @@ static void on_music_ui_evt_music(music_control_ui_evt_type_t evt_type) if (msg_len > 0) { ble_comm_send(buf, msg_len); } + +#elif defined(CONFIG_BLE_USES_AMS) + + switch (evt_type) { + case MUSIC_CONTROL_UI_CLOSE: + zsw_app_manager_app_close_request(&app); + break; + case MUSIC_CONTROL_UI_PLAY: + ble_ams_play_pause(); + break; + case MUSIC_CONTROL_UI_PAUSE: + ble_ams_play_pause(); + break; + case MUSIC_CONTROL_UI_NEXT_TRACK: + ble_ams_next_track(); + break; + case MUSIC_CONTROL_UI_PREV_TRACK: + ble_ams_previous_track(); + break; + } + +#endif // CONFIG_BLE_USES_AMS } static void zbus_ble_comm_data_callback(const struct zbus_channel *chan) diff --git a/app/src/ble/ble_ams.c b/app/src/ble/ble_ams.c new file mode 100644 index 00000000..82139e06 --- /dev/null +++ b/app/src/ble/ble_ams.c @@ -0,0 +1,338 @@ +/** + * @file ble.ams.c + * @author Leonardo Bispo + * + * @brief Implements Apple Media Service (AMS), the native iOS GATT server allows the client + * to control and retrieve media information as artist, etc. + * + * RC - Remote Control + * EU - Entity Update + * EA - Entity Attribute + * + * @see https://developer.apple.com/library/archive/documentation/CoreBluetooth/Reference/AppleMediaService_Reference/Specification/Specification.html + */ + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "ble/ble_ams.h" +#include "ble/ble_comm.h" +#include "events/ble_data_event.h" + +LOG_MODULE_REGISTER(ble_ams, LOG_LEVEL_INF); + +enum { + IS_UPDATE_TRACK, + HAS_NEXT_TRACK, + HAS_PREVIOUS_TRACK +}; + +static const enum bt_ams_player_attribute_id entity_update_player[] = { + BT_AMS_PLAYER_ATTRIBUTE_ID_NAME, + BT_AMS_PLAYER_ATTRIBUTE_ID_PLAYBACK_INFO, + BT_AMS_PLAYER_ATTRIBUTE_ID_VOLUME +}; + +static const enum bt_ams_track_attribute_id entity_update_track[] = { + BT_AMS_TRACK_ATTRIBUTE_ID_ARTIST, + BT_AMS_TRACK_ATTRIBUTE_ID_ALBUM, + BT_AMS_TRACK_ATTRIBUTE_ID_TITLE, + BT_AMS_TRACK_ATTRIBUTE_ID_DURATION +}; + +static struct bt_ams_client ams_c; +static char msg_buff[MAX_MUSIC_FIELD_LENGTH + 1]; + +static void ble_ams_delayed_write_handle(struct k_work *item); + +K_WORK_DELAYABLE_DEFINE(ble_ams_delayed_write, ble_ams_delayed_write_handle); +ZBUS_CHAN_DECLARE(ble_comm_data_chan); + +static void notify_rc_cb(struct bt_ams_client *ams_c, + const uint8_t *data, size_t len) +{ + char str_hex[4]; + bool has_next_track = false; + bool has_previous_track = false; + enum bt_ams_remote_command_id cmd_id; + + if (len > 0) { + /* Each data byte is converted to hexadecimal string, which takes 2 bytes. + * A comma is added to the hexadecimal except the first data byte. + * The first byte converted takes 2 bytes buffer and subsequent byte + * converted takes 3 bytes each. + */ + if (len * 3 - 1 > MAX_MUSIC_FIELD_LENGTH) { + LOG_WRN("AMS RC data size is too big\n"); + } else { + /* Print the accepted Remote Command values. */ + sprintf(msg_buff, "%02X", data[0]); + + for (size_t i = 1; i < len; i++) { + sprintf(str_hex, ",%02X", data[i]); + strcat(msg_buff, str_hex); + } + + LOG_DBG("AMS RC: %s", msg_buff); + } + } + + /* Check if track commands are available. */ + for (size_t i = 0; i < len; i++) { + cmd_id = data[i]; + if (cmd_id == BT_AMS_REMOTE_COMMAND_ID_NEXT_TRACK) { + has_next_track = true; + } else if (cmd_id == BT_AMS_REMOTE_COMMAND_ID_PREVIOUS_TRACK) { + has_previous_track = true; + } + } +} + +static void notify_eu_cb(struct bt_ams_client *ams_c, + const struct bt_ams_entity_update_notif *notif, + int err) +{ + uint8_t attr_val; + char str_hex[9]; + + if (!err) { + switch (notif->ent_attr.entity) { + case BT_AMS_ENTITY_ID_PLAYER: + attr_val = notif->ent_attr.attribute.player; + break; + case BT_AMS_ENTITY_ID_QUEUE: + attr_val = notif->ent_attr.attribute.queue; + break; + case BT_AMS_ENTITY_ID_TRACK: + attr_val = notif->ent_attr.attribute.track; + break; + default: + err = -EINVAL; + } + } + + if (err) { + LOG_ERR("AMS EU invalid\n"); + } else if (notif->len > MAX_MUSIC_FIELD_LENGTH) { + LOG_WRN("AMS EU data size is too big\n"); + } else { + sprintf(str_hex, "%02X,%02X,%02X", + notif->ent_attr.entity, attr_val, notif->flags); + memcpy(msg_buff, notif->data, notif->len); + msg_buff[notif->len] = '\0'; + LOG_DBG("AMS EU: %s %s", str_hex, msg_buff); + + static struct ble_data_event evt_music_inf = { 0 }; + + if (notif->ent_attr.entity == BT_AMS_ENTITY_ID_TRACK && + attr_val == BT_AMS_TRACK_ATTRIBUTE_ID_ARTIST) { + evt_music_inf.data.type = BLE_COMM_DATA_TYPE_MUSTIC_INFO; + memcpy(&evt_music_inf.data.data.music_info.artist, msg_buff, notif->len); + } + + if (notif->ent_attr.entity == BT_AMS_ENTITY_ID_TRACK && + attr_val == BT_AMS_TRACK_ATTRIBUTE_ID_DURATION) { + evt_music_inf.data.type = BLE_COMM_DATA_TYPE_MUSTIC_INFO; + // A string containing the floating point value of the total duration of the track in seconds. + evt_music_inf.data.data.music_info.duration = (int)atof(msg_buff); + } + + if (notif->ent_attr.entity == BT_AMS_ENTITY_ID_TRACK && + attr_val == BT_AMS_TRACK_ATTRIBUTE_ID_TITLE) { + evt_music_inf.data.type = BLE_COMM_DATA_TYPE_MUSTIC_INFO; + memcpy(&evt_music_inf.data.data.music_info.track_name, msg_buff, notif->len); + + // Only publish when all music information is received, otherwise values are overwritten + zbus_chan_pub(&ble_comm_data_chan, &evt_music_inf, K_MSEC(250)); + + memset(&evt_music_inf, 0, sizeof(evt_music_inf)); + } + + if (notif->ent_attr.entity == BT_AMS_ENTITY_ID_PLAYER && + attr_val == BT_AMS_PLAYER_ATTRIBUTE_ID_PLAYBACK_INFO) { + struct ble_data_event evt_music_state; + + evt_music_state.data.type = BLE_COMM_DATA_TYPE_MUSTIC_STATE; + + // A concatenation of three comma-separated values, i.e 0,0.0,0.000 + // where first value is status + evt_music_state.data.data.music_state.playing = ((msg_buff[0] - '0') == 1) ? true : false; + + // the last is the elapsed time in seconds as double + char elapsed_time[sizeof("9999.999")] = { '\0' }; + memcpy(elapsed_time, &msg_buff[6], notif->len - 6); + evt_music_state.data.data.music_state.position = (int)atof(elapsed_time); + + zbus_chan_pub(&ble_comm_data_chan, &evt_music_state, K_MSEC(250)); + } + } +} + +static void rc_write_cb(struct bt_ams_client *ams_c, uint8_t err) +{ + if (err) { + LOG_ERR("AMS RC write error 0x%02X", err); + } +} + +static void eu_write_cb(struct bt_ams_client *ams_c, uint8_t err) +{ + if (err) { + LOG_ERR("AMS EU write error 0x%02X", err); + } +} + +static void enable_notifications(struct bt_ams_client *ams_c) +{ + int err; + struct bt_ams_entity_attribute_list entity_attribute_list; + + err = bt_ams_subscribe_remote_command(ams_c, notify_rc_cb); + if (err) { + LOG_ERR("Cannot subscribe to Remote Command notification (err %d)", err); + } + + err = bt_ams_subscribe_entity_update(ams_c, notify_eu_cb); + if (err) { + LOG_ERR("Cannot subscribe to Entity Update notification (err %d)", err); + } + + entity_attribute_list.entity = BT_AMS_ENTITY_ID_PLAYER; + entity_attribute_list.attribute.player = entity_update_player; + entity_attribute_list.attribute_count = ARRAY_SIZE(entity_update_player); + + err = bt_ams_write_entity_update(ams_c, &entity_attribute_list, eu_write_cb); + if (err) { + LOG_ERR("Cannot write to Entity Update (err %d)", err); + } + + // Enable track information delayed, GATT write fails if too quick + k_work_schedule(&ble_ams_delayed_write, K_MSEC(250)); +} + +static void discover_completed_cb(struct bt_gatt_dm *dm, void *ctx) +{ + int err; + struct bt_ams_client *ams_c = (struct bt_ams_client *)ctx; + + LOG_INF("The discovery procedure succeeded\n"); + + bt_gatt_dm_data_print(dm); + + err = bt_ams_handles_assign(dm, ams_c); + if (err) { + LOG_ERR("Could not assign AMS client handles, error: %d", err); + } else { + enable_notifications(ams_c); + } + + err = bt_gatt_dm_data_release(dm); + if (err) { + LOG_ERR("Could not release the discovery data, error " + "code: %d", + err); + } +} + +static void discover_service_not_found_cb(struct bt_conn *conn, void *ctx) +{ + LOG_WRN("The service could not be found during the discovery\n"); +} + +static void discover_error_found_cb(struct bt_conn *conn, int err, void *ctx) +{ + LOG_ERR("The discovery procedure failed, err %d", err); +} + +static const struct bt_gatt_dm_cb discover_cb = { + .completed = discover_completed_cb, + .service_not_found = discover_service_not_found_cb, + .error_found = discover_error_found_cb, +}; + +static void security_changed(struct bt_conn *conn, bt_security_t level, + enum bt_security_err err) +{ + int dm_err; + char addr[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr)); + + if (!err) { + LOG_INF("Security changed: %s level %u", addr, level); + + if (bt_conn_get_security(conn) >= BT_SECURITY_L2) { + dm_err = bt_gatt_dm_start(conn, BT_UUID_AMS, &discover_cb, &ams_c); + if (dm_err) { + LOG_ERR("Failed to start discovery (err %d)", dm_err); + } + } + } else { + LOG_ERR("Security failed: %s level %u err %d", addr, level, err); + } +} + +BT_CONN_CB_DEFINE(conn_callbacks) = { + .security_changed = security_changed, +}; + +static void ble_ams_delayed_write_handle(struct k_work *item) +{ + int err; + struct bt_ams_entity_attribute_list entity_attribute_list; + + entity_attribute_list.entity = BT_AMS_ENTITY_ID_TRACK; + entity_attribute_list.attribute.track = entity_update_track; + entity_attribute_list.attribute_count = ARRAY_SIZE(entity_update_track); + + err = bt_ams_write_entity_update(&ams_c, &entity_attribute_list, eu_write_cb); + if (err) { + LOG_ERR("Cannot write to Entity Update (err %d)", err); + } +} + +int ble_ams_init(void) +{ + int err = bt_ams_client_init(&ams_c); + + if (err) { + LOG_ERR("Failed to start Apple Media Service: 0x%x", err); + return err; + } + + LOG_INF("Start Apple Media Service"); + + return err; +} + +int ble_ams_play_pause() +{ + return bt_ams_write_remote_command(&ams_c, + BT_AMS_REMOTE_COMMAND_ID_TOGGLE_PLAY_PAUSE, + rc_write_cb); +} + +int ble_ams_next_track() +{ + return bt_ams_write_remote_command(&ams_c, + BT_AMS_REMOTE_COMMAND_ID_NEXT_TRACK, + rc_write_cb); +} + +int ble_ams_previous_track() +{ + return bt_ams_write_remote_command(&ams_c, + BT_AMS_REMOTE_COMMAND_ID_PREVIOUS_TRACK, + rc_write_cb); +} \ No newline at end of file diff --git a/app/src/ble/ble_ams.h b/app/src/ble/ble_ams.h new file mode 100644 index 00000000..5343e647 --- /dev/null +++ b/app/src/ble/ble_ams.h @@ -0,0 +1,14 @@ +#ifndef __BLE_AMS_H +#define __BLE_AMS_H + +#include + +int ble_ams_init(void); + +int ble_ams_play_pause(void); + +int ble_ams_next_track(void); + +int ble_ams_previous_track(void); + +#endif \ No newline at end of file diff --git a/app/src/ble/ble_comm.c b/app/src/ble/ble_comm.c index 7a741020..70cc6fa3 100644 --- a/app/src/ble/ble_comm.c +++ b/app/src/ble/ble_comm.c @@ -32,6 +32,10 @@ #include "ble/ble_transport.h" #include "events/ble_data_event.h" +#ifdef CONFIG_BT_AMS_CLIENT +#include +#endif + LOG_MODULE_REGISTER(ble_comm, LOG_LEVEL_DBG); #define BLE_COMM_LONG_INT_MIN_MS (400 / 1.25) @@ -73,7 +77,10 @@ static const struct bt_data ad[] = { (CONFIG_BT_DEVICE_APPEARANCE >> 8) & 0xff), BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)), BT_DATA_BYTES(BT_DATA_UUID16_ALL, - BT_UUID_16_ENCODE(BT_UUID_DIS_VAL)) + BT_UUID_16_ENCODE(BT_UUID_DIS_VAL)), +#ifdef CONFIG_BT_AMS_CLIENT + BT_DATA_BYTES(BT_DATA_SOLICIT128, BT_UUID_AMS_VAL) +#endif }; static const struct bt_data ad_nus[] = { diff --git a/app/src/ble/ble_comm.h b/app/src/ble/ble_comm.h index 6dcf7f99..f907e077 100644 --- a/app/src/ble/ble_comm.h +++ b/app/src/ble/ble_comm.h @@ -20,7 +20,7 @@ #include -#define MAX_MUSIC_FIELD_LENGTH 25 +#define MAX_MUSIC_FIELD_LENGTH 100 #define MAX_WEATHER_REPORT_TEXT_LENGTH 25 typedef enum ble_comm_data_type { diff --git a/app/src/main.c b/app/src/main.c index b28b3d22..3bc4583e 100644 --- a/app/src/main.c +++ b/app/src/main.c @@ -54,6 +54,10 @@ #include "applications/watchface/watchface_app.h" #include +#ifdef CONFIG_BLE_USES_AMS +#include "ble/ble_ams.h" +#endif + LOG_MODULE_REGISTER(main, LOG_LEVEL_WRN); #define TASK_WDT_FEED_INTERVAL_MS 3000 @@ -314,6 +318,10 @@ static void enable_bluetoth(void) __ASSERT_NO_MSG(ble_comm_init(on_ble_data_callback) == 0); bleAoaInit(); + +#ifdef CONFIG_BLE_USES_AMS + ble_ams_init(); +#endif } static bool load_retention_ram(void) diff --git a/app/west.yml b/app/west.yml index 0bf5ae1a..cf8afb9d 100644 --- a/app/west.yml +++ b/app/west.yml @@ -31,4 +31,4 @@ manifest: clone-depth: 1 self: - west-commands: scripts/west-commands.yml + west-commands: scripts/west-commands.yml \ No newline at end of file