time sync

This commit is contained in:
2025-10-19 19:54:24 +02:00
parent ecbcce12ea
commit 7c741c42dc
15 changed files with 1084 additions and 2 deletions

View File

@@ -10,6 +10,7 @@ idf_component_register(
"src/i2c_global.cpp"
"src/shutdowner.cpp"
"src/spi_global.cpp"
"src/time_sync_service.cpp"
INCLUDE_DIRS
"include"
PRIV_REQUIRES
@@ -19,6 +20,7 @@ idf_component_register(
esp_driver_spi
littlefs
nvs_flash
bt
)
add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../sdk/utils" cardboy_utils_esp)

View File

@@ -0,0 +1,20 @@
#pragma once
namespace cardboy::backend::esp {
/**
* Ensure the BLE time synchronisation service is running.
*
* Safe to call multiple times; subsequent calls become no-ops once the
* service has been started successfully.
*/
void ensure_time_sync_service_started();
/**
* Stop the BLE time synchronisation service if it is running.
* A no-op on platforms that do not support the BLE implementation.
*/
void shutdown_time_sync_service();
} // namespace cardboy::backend::esp

View File

@@ -9,6 +9,7 @@
#include "cardboy/backend/esp/i2c_global.hpp"
#include "cardboy/backend/esp/shutdowner.hpp"
#include "cardboy/backend/esp/spi_global.hpp"
#include "cardboy/backend/esp/time_sync_service.hpp"
#include "cardboy/sdk/display_spec.hpp"
@@ -150,7 +151,7 @@ EspRuntime::EspRuntime() : framebuffer(), input(), clock() {
Buttons::get().setEventBus(eventBus.get());
}
EspRuntime::~EspRuntime() = default;
EspRuntime::~EspRuntime() { shutdown_time_sync_service(); }
cardboy::sdk::Services& EspRuntime::serviceRegistry() { return services; }
@@ -180,6 +181,8 @@ void EspRuntime::initializeHardware() {
Buzzer::get().init();
FsHelper::get().mount();
ensure_time_sync_service_started();
}
void EspFramebuffer::clear_impl(bool on) {

View File

@@ -0,0 +1,304 @@
#include "cardboy/backend/esp/time_sync_service.hpp"
#include "sdkconfig.h"
#include <sys/time.h>
#include <time.h>
#include <unistd.h>
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "host/ble_gatt.h"
#include "host/ble_hs.h"
#include "host/util/util.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
#include <cerrno>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <esp_bt.h>
#include <esp_err.h>
namespace cardboy::backend::esp {
namespace {
constexpr char kLogTag[] = "TimeSyncBLE";
constexpr char kDeviceName[] = "Cardboy";
// 128-bit UUIDs (little-endian order for NimBLE macros)
static const ble_uuid128_t kTimeServiceUuid = BLE_UUID128_INIT(0x30, 0xF2, 0xD3, 0xF4, 0xC3, 0x10, 0xA6, 0xB5, 0xFD,
0x4E, 0x7B, 0xCA, 0x01, 0x00, 0x00, 0x00);
static const ble_uuid128_t kTimeWriteCharUuid = BLE_UUID128_INIT(0x31, 0xF2, 0xD3, 0xF4, 0xC3, 0x10, 0xA6, 0xB5, 0xFD,
0x4E, 0x7B, 0xCA, 0x02, 0x00, 0x00, 0x00);
struct [[gnu::packed]] TimeSyncPayload {
std::uint64_t epochSeconds; // Unix time in seconds (UTC)
std::int16_t timezoneOffsetMinutes; // Minutes east of UTC
std::uint8_t daylightSavingActive; // Non-zero if DST active at source
std::uint8_t reserved; // Reserved for alignment / future use
};
static_assert(sizeof(TimeSyncPayload) == 12, "Unexpected payload size");
static bool g_started = false;
static uint8_t g_ownAddrType = BLE_OWN_ADDR_PUBLIC;
static TaskHandle_t g_hostTaskHandle = nullptr;
int gapEventHandler(struct ble_gap_event* event, void* arg);
void startAdvertising();
void setSystemTimeFromPayload(const TimeSyncPayload& payload) {
timeval tv{};
tv.tv_sec = static_cast<time_t>(payload.epochSeconds);
tv.tv_usec = 0;
if (settimeofday(&tv, nullptr) != 0) {
ESP_LOGW(kLogTag, "Failed to set system time (errno=%d)", errno);
} else {
ESP_LOGI(kLogTag, "Wall time updated: epoch=%llu dst=%u offset=%dmin",
static_cast<unsigned long long>(payload.epochSeconds), payload.daylightSavingActive,
static_cast<int>(payload.timezoneOffsetMinutes));
}
// Apply timezone offset by updating TZ environment variable.
// POSIX TZ strings invert the sign relative to the offset from UTC.
const int offsetMin = static_cast<int>(payload.timezoneOffsetMinutes);
const int absOffset = std::abs(offsetMin);
const int hours = absOffset / 60;
const int minutes = absOffset % 60;
char tzString[16];
const char signChar = (offsetMin >= 0) ? '-' : '+';
if (minutes == 0) {
std::snprintf(tzString, sizeof(tzString), "GMT%c%d", signChar, hours);
} else {
std::snprintf(tzString, sizeof(tzString), "GMT%c%d:%02d", signChar, hours, minutes);
}
setenv("TZ", tzString, 1);
tzset();
ESP_LOGI(kLogTag, "Timezone updated to %s", tzString);
}
int timeSyncWriteAccess(uint16_t /*conn_handle*/, uint16_t /*attr_handle*/, ble_gatt_access_ctxt* ctxt, void* /*arg*/) {
if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) {
return BLE_ATT_ERR_READ_NOT_PERMITTED;
}
const std::uint16_t incomingLen = OS_MBUF_PKTLEN(ctxt->om);
if (incomingLen != sizeof(TimeSyncPayload)) {
ESP_LOGW(kLogTag, "Invalid payload length: %u", incomingLen);
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
TimeSyncPayload payload{};
const int rc = os_mbuf_copydata(ctxt->om, 0, sizeof(TimeSyncPayload), &payload);
if (rc != 0) {
ESP_LOGW(kLogTag, "Failed to read payload (rc=%d)", rc);
return BLE_ATT_ERR_UNLIKELY;
}
setSystemTimeFromPayload(payload);
return 0;
}
const ble_gatt_svc_def kGattServices[] = {
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &kTimeServiceUuid.u,
.characteristics =
(ble_gatt_chr_def[]) {
{
.uuid = &kTimeWriteCharUuid.u,
.access_cb = timeSyncWriteAccess,
.flags = static_cast<uint8_t>(BLE_GATT_CHR_F_WRITE |
BLE_GATT_CHR_F_WRITE_NO_RSP),
},
{
0,
},
},
},
{
0,
},
};
void startAdvertising() {
ble_hs_adv_fields fields{};
std::memset(&fields, 0, sizeof(fields));
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
const char* name = ble_svc_gap_device_name();
fields.name = reinterpret_cast<const std::uint8_t*>(name);
fields.name_len = static_cast<std::uint8_t>(std::strlen(name));
fields.name_is_complete = 1;
fields.uuids128 = const_cast<ble_uuid128_t*>(&kTimeServiceUuid);
fields.num_uuids128 = 1;
fields.uuids128_is_complete = 1;
int rc = ble_gap_adv_set_fields(&fields);
if (rc != 0) {
ESP_LOGE(kLogTag, "ble_gap_adv_set_fields failed: %d", rc);
return;
}
ble_hs_adv_fields rspFields{};
std::memset(&rspFields, 0, sizeof(rspFields));
rspFields.tx_pwr_lvl_is_present = 1;
rspFields.tx_pwr_lvl = BLE_HS_ADV_TX_PWR_LVL_AUTO;
rc = ble_gap_adv_rsp_set_fields(&rspFields);
if (rc != 0) {
ESP_LOGE(kLogTag, "ble_gap_adv_rsp_set_fields failed: %d", rc);
return;
}
ble_gap_adv_params advParams{};
std::memset(&advParams, 0, sizeof(advParams));
advParams.conn_mode = BLE_GAP_CONN_MODE_UND;
advParams.disc_mode = BLE_GAP_DISC_MODE_GEN;
rc = ble_gap_adv_start(g_ownAddrType, nullptr, BLE_HS_FOREVER, &advParams, gapEventHandler, nullptr);
if (rc != 0) {
ESP_LOGE(kLogTag, "ble_gap_adv_start failed: %d", rc);
} else {
ESP_LOGI(kLogTag, "Advertising started");
}
}
void onReset(int reason) { ESP_LOGW(kLogTag, "Resetting state; reason=%d", reason); }
void onSync() {
int rc = ble_hs_id_infer_auto(0, &g_ownAddrType);
if (rc != 0) {
ESP_LOGE(kLogTag, "ble_hs_id_infer_auto failed: %d", rc);
return;
}
std::uint8_t addrVal[6];
rc = ble_hs_id_copy_addr(g_ownAddrType, addrVal, nullptr);
if (rc == 0) {
ESP_LOGI(kLogTag, "Device address: %02X:%02X:%02X:%02X:%02X:%02X", addrVal[5], addrVal[4], addrVal[3],
addrVal[2], addrVal[1], addrVal[0]);
}
startAdvertising();
}
int gapEventHandler(struct ble_gap_event* event, void* /*arg*/) {
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
if (event->connect.status == 0) {
ESP_LOGI(kLogTag, "Connected; handle=%d", event->connect.conn_handle);
} else {
ESP_LOGW(kLogTag, "Connection attempt failed; status=%d", event->connect.status);
startAdvertising();
}
break;
case BLE_GAP_EVENT_DISCONNECT:
ESP_LOGI(kLogTag, "Disconnected; reason=%d", event->disconnect.reason);
startAdvertising();
break;
case BLE_GAP_EVENT_ADV_COMPLETE:
ESP_LOGI(kLogTag, "Advertising complete; restarting");
startAdvertising();
break;
default:
break;
}
return 0;
}
void hostTask(void* /*param*/) {
g_hostTaskHandle = xTaskGetCurrentTaskHandle();
nimble_port_run(); // This call blocks until NimBLE stops
nimble_port_freertos_deinit();
g_hostTaskHandle = nullptr;
vTaskDelete(nullptr);
}
void configureGap() {
ble_svc_gap_init();
ble_svc_gap_device_name_set(kDeviceName);
ble_svc_gatt_init();
}
bool initController() {
ble_hs_cfg.reset_cb = onReset;
ble_hs_cfg.sync_cb = onSync;
ble_hs_cfg.gatts_register_cb = nullptr;
ble_hs_cfg.store_status_cb = ble_store_util_status_rr;
ble_hs_cfg.sm_io_cap = BLE_HS_IO_NO_INPUT_OUTPUT;
ble_hs_cfg.sm_bonding = 0;
ble_hs_cfg.sm_mitm = 0;
ble_hs_cfg.sm_sc = 0;
ESP_ERROR_CHECK(nimble_port_init());
configureGap();
int gattRc = ble_gatts_count_cfg(kGattServices);
if (gattRc != 0) {
ESP_LOGE(kLogTag, "ble_gatts_count_cfg failed (rc=%d)", gattRc);
return false;
}
gattRc = ble_gatts_add_svcs(kGattServices);
if (gattRc != 0) {
ESP_LOGE(kLogTag, "ble_gatts_add_svcs failed (rc=%d)", gattRc);
return false;
}
return true;
}
} // namespace
void ensure_time_sync_service_started() {
if (g_started) {
return;
}
if (!initController()) {
ESP_LOGE(kLogTag, "Unable to initialise BLE time sync service");
return;
}
nimble_port_freertos_init(hostTask);
g_started = true;
ESP_LOGI(kLogTag, "BLE time sync service initialised");
}
void shutdown_time_sync_service() {
if (!g_started) {
return;
}
int rc = nimble_port_stop();
if (rc == 0) {
// Wait for host task to exit
while (g_hostTaskHandle != nullptr) {
vTaskDelay(pdMS_TO_TICKS(10));
}
}
nimble_port_deinit();
// esp_nimble_hci_and_controller_deinit();
esp_bt_controller_disable();
esp_bt_controller_deinit();
g_started = false;
ESP_LOGI(kLogTag, "BLE time sync service stopped");
}
} // namespace cardboy::backend::esp