diff --git a/CMakeLists.txt b/CMakeLists.txt index f218051..6d95abb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,104 +2,144 @@ # SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 if(CONFIG_STENO_ENGINE) -if(NOT CONFIG_ZMK_SPLIT OR CONFIG_ZMK_SPLIT_ROLE_CENTRAL) - -target_sources(app PRIVATE - src/behavior_steno.c - src/output.c - src/formatter.c - src/undo.c - src/dict_embed.S -) - -if(CONFIG_STENO_DICT_MPHF) - target_sources(app PRIVATE src/dict_mphf.c) -else() - target_sources(app PRIVATE src/trie.c) -endif() target_include_directories(app PRIVATE include ${CMAKE_CURRENT_SOURCE_DIR}/src ) -# ── Dictionary source resolution ── -find_package(Python3 REQUIRED COMPONENTS Interpreter) -set(STENO_DICT_BIN ${CMAKE_CURRENT_BINARY_DIR}/steno_dict.bin) -set(STENO_DICTS_DIR ${CMAKE_CURRENT_SOURCE_DIR}/dicts) -set(STENO_FETCH ${CMAKE_CURRENT_SOURCE_DIR}/tools/fetch_dict.py) +# ── Split-dict mode ────────────────────────────────── +# Central: behavior engine + BLE dict client +# Peripheral: dict embed + lookup engine + GATT server +if(CONFIG_STENO_SPLIT_DICT) -if(CONFIG_STENO_DICT_PLOVER) - set(STENO_DICT_NAME "plover") - set(STENO_DICT_SRC ${STENO_DICTS_DIR}/plover-main.json) -elseif(CONFIG_STENO_DICT_LAPWING) - set(STENO_DICT_NAME "lapwing") - set(STENO_DICT_SRC ${STENO_DICTS_DIR}/lapwing.json) -else() - set(STENO_DICT_SRC ${STENO_DICTS_DIR}/test.json) -endif() - -# Auto-download dict if needed (Plover/Lapwing only) -if(DEFINED STENO_DICT_NAME AND NOT EXISTS ${STENO_DICT_SRC}) - message(STATUS "Steno: downloading ${STENO_DICT_NAME} dictionary...") - execute_process( - COMMAND ${Python3_EXECUTABLE} ${STENO_FETCH} - ${STENO_DICT_NAME} ${STENO_DICTS_DIR} - RESULT_VARIABLE FETCH_RESULT - ) - if(NOT FETCH_RESULT EQUAL 0) - message(WARNING "Steno: dict download failed. Build may fail.") - endif() -endif() - -# ── Dictionary compilation ── -if(EXISTS ${STENO_DICT_SRC}) - if(CONFIG_STENO_DICT_MPHF) - # Fetch at build time if hash changed (re-run on rebuild) - if(DEFINED STENO_DICT_NAME) - add_custom_command( - OUTPUT ${STENO_DICT_SRC}.stamp - COMMAND ${Python3_EXECUTABLE} ${STENO_FETCH} - ${STENO_DICT_NAME} ${STENO_DICTS_DIR} - COMMAND ${CMAKE_COMMAND} -E touch ${STENO_DICT_SRC}.stamp - COMMENT "Checking ${STENO_DICT_NAME} dictionary for updates" - ) - add_custom_target(steno_dict_fetch DEPENDS ${STENO_DICT_SRC}.stamp) - endif() - - add_custom_command( - OUTPUT ${STENO_DICT_BIN} - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/tools/compile_mphf.py - ${STENO_DICT_SRC} ${STENO_DICT_BIN} - --max-size ${CONFIG_STENO_DICT_MAX_SIZE} - DEPENDS ${STENO_DICT_SRC} - ${CMAKE_CURRENT_SOURCE_DIR}/tools/compile_mphf.py - COMMENT "Compiling steno dictionary (MPHF)" + if(CONFIG_ZMK_SPLIT_ROLE_CENTRAL) + # Central side: behavior engine queries peripheral over BLE + target_sources(app PRIVATE + src/behavior_steno.c + src/output.c + src/formatter.c + src/undo.c + src/split_dict.c + src/split_cache.c ) else() - add_custom_command( - OUTPUT ${STENO_DICT_BIN} - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/tools/compile_simple.py - ${STENO_DICT_SRC} -o ${STENO_DICT_BIN} - DEPENDS ${STENO_DICT_SRC} - ${CMAKE_CURRENT_SOURCE_DIR}/tools/compile_simple.py - COMMENT "Compiling steno dictionary (simple)" + # Peripheral side: dict embedded here, serves GATT queries + target_sources(app PRIVATE + src/dict_embed.S ) + if(CONFIG_STENO_DICT_MPHF) + target_sources(app PRIVATE src/dict_mphf.c) + else() + target_sources(app PRIVATE src/trie.c) + endif() endif() - add_custom_target(steno_dict_gen DEPENDS ${STENO_DICT_BIN}) - if(TARGET steno_dict_fetch) - add_dependencies(steno_dict_gen steno_dict_fetch) +# ── Non-split mode ─────────────────────────────────── +# Everything on one board (or central-only without split) +else() + if(NOT CONFIG_ZMK_SPLIT OR CONFIG_ZMK_SPLIT_ROLE_CENTRAL) + target_sources(app PRIVATE + src/behavior_steno.c + src/output.c + src/formatter.c + src/undo.c + src/dict_embed.S + ) + if(CONFIG_STENO_DICT_MPHF) + target_sources(app PRIVATE src/dict_mphf.c) + else() + target_sources(app PRIVATE src/trie.c) + endif() + endif() +endif() + +# ── Dictionary compilation ─────────────────────────── +# Build dict binary when we embed it (non-split, or split peripheral) +set(STENO_NEED_DICT_EMBED FALSE) +if(CONFIG_STENO_SPLIT_DICT) + if(NOT CONFIG_ZMK_SPLIT_ROLE_CENTRAL) + set(STENO_NEED_DICT_EMBED TRUE) + endif() +elseif(NOT CONFIG_ZMK_SPLIT OR CONFIG_ZMK_SPLIT_ROLE_CENTRAL) + set(STENO_NEED_DICT_EMBED TRUE) +endif() + +if(STENO_NEED_DICT_EMBED) + find_package(Python3 REQUIRED COMPONENTS Interpreter) + set(STENO_DICT_BIN ${CMAKE_CURRENT_BINARY_DIR}/steno_dict.bin) + set(STENO_DICTS_DIR ${CMAKE_CURRENT_SOURCE_DIR}/dicts) + set(STENO_FETCH ${CMAKE_CURRENT_SOURCE_DIR}/tools/fetch_dict.py) + + if(CONFIG_STENO_DICT_PLOVER) + set(STENO_DICT_NAME "plover") + set(STENO_DICT_SRC ${STENO_DICTS_DIR}/plover-main.json) + elseif(CONFIG_STENO_DICT_LAPWING) + set(STENO_DICT_NAME "lapwing") + set(STENO_DICT_SRC ${STENO_DICTS_DIR}/lapwing.json) + else() + set(STENO_DICT_SRC ${STENO_DICTS_DIR}/test.json) + endif() + + # Auto-download dict if needed (Plover/Lapwing only) + if(DEFINED STENO_DICT_NAME AND NOT EXISTS ${STENO_DICT_SRC}) + message(STATUS "Steno: downloading ${STENO_DICT_NAME} dictionary...") + execute_process( + COMMAND ${Python3_EXECUTABLE} ${STENO_FETCH} + ${STENO_DICT_NAME} ${STENO_DICTS_DIR} + RESULT_VARIABLE FETCH_RESULT + ) + if(NOT FETCH_RESULT EQUAL 0) + message(WARNING "Steno: dict download failed. Build may fail.") + endif() + endif() + + # Compile dict binary + if(EXISTS ${STENO_DICT_SRC}) + if(CONFIG_STENO_DICT_MPHF) + # Fetch at build time if hash changed + if(DEFINED STENO_DICT_NAME) + add_custom_command( + OUTPUT ${STENO_DICT_SRC}.stamp + COMMAND ${Python3_EXECUTABLE} ${STENO_FETCH} + ${STENO_DICT_NAME} ${STENO_DICTS_DIR} + COMMAND ${CMAKE_COMMAND} -E touch ${STENO_DICT_SRC}.stamp + COMMENT "Checking ${STENO_DICT_NAME} dictionary for updates" + ) + add_custom_target(steno_dict_fetch DEPENDS ${STENO_DICT_SRC}.stamp) + endif() + + add_custom_command( + OUTPUT ${STENO_DICT_BIN} + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tools/compile_mphf.py + ${STENO_DICT_SRC} ${STENO_DICT_BIN} + --max-size ${CONFIG_STENO_DICT_MAX_SIZE} + DEPENDS ${STENO_DICT_SRC} + ${CMAKE_CURRENT_SOURCE_DIR}/tools/compile_mphf.py + COMMENT "Compiling steno dictionary (MPHF)" + ) + else() + add_custom_command( + OUTPUT ${STENO_DICT_BIN} + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tools/compile_simple.py + ${STENO_DICT_SRC} -o ${STENO_DICT_BIN} + DEPENDS ${STENO_DICT_SRC} + ${CMAKE_CURRENT_SOURCE_DIR}/tools/compile_simple.py + COMMENT "Compiling steno dictionary (simple)" + ) + endif() + + add_custom_target(steno_dict_gen DEPENDS ${STENO_DICT_BIN}) + if(TARGET steno_dict_fetch) + add_dependencies(steno_dict_gen steno_dict_fetch) + endif() + add_dependencies(app steno_dict_gen) + + set_property(SOURCE src/dict_embed.S APPEND PROPERTY + COMPILE_DEFINITIONS STENO_DICT_BIN_PATH="${STENO_DICT_BIN}") endif() - add_dependencies(app steno_dict_gen) - - # Generate header with dict path for .incbin - file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/steno_dict_path.h - "#define STENO_DICT_BIN_PATH \"${STENO_DICT_BIN}\"\n") - target_include_directories(app PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) endif() -endif() # central role endif() # CONFIG_STENO_ENGINE diff --git a/Kconfig b/Kconfig index 342d6c4..e66b5c4 100644 --- a/Kconfig +++ b/Kconfig @@ -16,7 +16,7 @@ choice STENO_DICT config STENO_DICT_PLOVER bool "Plover main dictionary (MPHF compressed)" help - Use Plover main.json via MPHF compression (~44K entries in 453KB). + Use Plover main.json via MPHF compression. config STENO_DICT_LAPWING bool "Lapwing dictionary (MPHF compressed)" @@ -35,7 +35,6 @@ config STENO_DICT_MPHF help Use MPHF (minimal perfect hash) dictionary format. Selects ZLIB for block-compressed string table decompression. - Auto-selected for Plover/Lapwing dicts. config STENO_CUSTOM_KEYMAP bool "Custom steno keymap" @@ -76,10 +75,35 @@ config STENO_MULTI_STROKE_TIMEOUT_MS config STENO_DICT_MAX_SIZE int "Max dictionary binary size (bytes)" - default 430080 + default 473088 help - Max compiled dict size. 430080 = 420KB. - Leaves room for zlib decompressor + USB logging overhead. + Max compiled dict size. 473088 = 462KB. The MPHF compiler auto-trims to fit. +menuconfig STENO_SPLIT_DICT + bool "Split dictionary storage on peripheral" + default n + help + Store the steno dictionary on the peripheral half and + perform lookups over BLE. This frees flash on the central + side at the cost of added lookup latency. + +if STENO_SPLIT_DICT + +config STENO_SPLIT_CACHE_SIZE + int "LRU cache entries on central side" + default 64 + range 16 512 + +config STENO_SPLIT_PREFETCH + bool "Prefetch common follow-up strokes" + default y + +config STENO_SPLIT_TIMEOUT_MS + int "BLE lookup timeout (ms)" + default 50 + range 10 500 + +endif # STENO_SPLIT_DICT + endif # STENO_ENGINE diff --git a/src/behavior_steno.c b/src/behavior_steno.c index 54dfdfd..adfa1af 100644 --- a/src/behavior_steno.c +++ b/src/behavior_steno.c @@ -16,7 +16,9 @@ #include "undo.h" #include "formatter.h" -#if IS_ENABLED(CONFIG_STENO_DICT_MPHF) +#if IS_ENABLED(CONFIG_STENO_SPLIT_DICT) +#include "split_dict.h" +#elif IS_ENABLED(CONFIG_STENO_DICT_MPHF) #include "dict_mphf.h" #else #include "trie.h" @@ -24,34 +26,18 @@ LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL); +#if !IS_ENABLED(CONFIG_STENO_SPLIT_DICT) extern const uint8_t _steno_dict_start[]; extern const uint8_t _steno_dict_end[]; +#endif -#if IS_ENABLED(CONFIG_STENO_DICT_MPHF) +#if IS_ENABLED(CONFIG_STENO_DICT_MPHF) && !IS_ENABLED(CONFIG_STENO_SPLIT_DICT) static struct dict_mphf mphf_dict; #endif #define STENO_MAX_MULTI 8 #define STENO_MULTI_TIMEOUT_MS CONFIG_STENO_MULTI_STROKE_TIMEOUT_MS -static inline const char *dict_lookup(const uint32_t *strokes, uint8_t count) -{ -#if IS_ENABLED(CONFIG_STENO_DICT_MPHF) - return dict_mphf_lookup(&mphf_dict, strokes, count); -#else - return steno_trie_lookup(strokes, count); -#endif -} - -static inline bool dict_has_prefix(const uint32_t *strokes, uint8_t count) -{ -#if IS_ENABLED(CONFIG_STENO_DICT_MPHF) - return (count == 1) ? dict_mphf_has_prefix(&mphf_dict, strokes[0]) : false; -#else - return steno_trie_has_prefix(strokes, count); -#endif -} - struct steno_state { uint32_t current_chord; uint8_t keys_held; @@ -68,6 +54,30 @@ static bool dict_ready; static void flush_strokes(void); static void multi_timeout_handler(struct k_work *work); +static const char *do_lookup(const uint32_t *strokes, uint8_t count) +{ +#if IS_ENABLED(CONFIG_STENO_SPLIT_DICT) + static char split_buf[128]; + int ret = split_dict_lookup(strokes, count, split_buf, sizeof(split_buf)); + return (ret > 0) ? split_buf : NULL; +#elif IS_ENABLED(CONFIG_STENO_DICT_MPHF) + return dict_mphf_lookup(&mphf_dict, strokes, count); +#else + return steno_trie_lookup(strokes, count); +#endif +} + +static bool do_has_prefix(const uint32_t *strokes, uint8_t count) +{ +#if IS_ENABLED(CONFIG_STENO_SPLIT_DICT) + return split_dict_has_prefix(strokes, count); +#elif IS_ENABLED(CONFIG_STENO_DICT_MPHF) + return (count == 1) ? dict_mphf_has_prefix(&mphf_dict, strokes[0]) : false; +#else + return steno_trie_has_prefix(strokes, count); +#endif +} + static void emit_formatted(const char *translation, const uint32_t *strokes, uint8_t stroke_count) { @@ -114,15 +124,11 @@ static void process_chord(void) state.current_chord = 0; if (!dict_ready) { - LOG_WRN("steno dict not ready, flushing"); flush_strokes(); return; } - const char *translation = dict_lookup( - state.pending_strokes, state.stroke_count); - LOG_INF("steno lookup %u strokes → %s", state.stroke_count, - translation ? translation : "(null)"); + const char *translation = do_lookup(state.pending_strokes, state.stroke_count); if (translation) { emit_formatted(translation, state.pending_strokes, state.stroke_count); @@ -130,7 +136,7 @@ static void process_chord(void) return; } - if (dict_has_prefix(state.pending_strokes, state.stroke_count)) { + if (do_has_prefix(state.pending_strokes, state.stroke_count)) { k_work_schedule(&state.multi_timeout, K_MSEC(STENO_MULTI_TIMEOUT_MS)); return; @@ -140,8 +146,7 @@ static void process_chord(void) uint32_t last = state.pending_strokes[state.stroke_count - 1]; state.stroke_count--; - const char *partial = dict_lookup( - state.pending_strokes, state.stroke_count); + const char *partial = do_lookup(state.pending_strokes, state.stroke_count); if (partial) { emit_formatted(partial, state.pending_strokes, state.stroke_count); } @@ -149,7 +154,7 @@ static void process_chord(void) state.pending_strokes[0] = last; state.stroke_count = 1; - const char *rest = dict_lookup(&last, 1); + const char *rest = do_lookup(&last, 1); if (rest) { emit_formatted(rest, &last, 1); state.stroke_count = 0; @@ -169,14 +174,10 @@ static void flush_strokes(void) static void multi_timeout_handler(struct k_work *work) { ARG_UNUSED(work); - if (state.stroke_count == 0) { return; } - - const char *translation = dict_lookup( - state.pending_strokes, state.stroke_count); - + const char *translation = do_lookup(state.pending_strokes, state.stroke_count); if (translation) { emit_formatted(translation, state.pending_strokes, state.stroke_count); } @@ -187,15 +188,13 @@ static int on_steno_binding_pressed(struct zmk_behavior_binding *binding, struct zmk_behavior_binding_event event) { uint32_t key_index = binding->param1; - if (key_index > 35) { return -EINVAL; } - state.current_chord |= (1U << key_index); state.keys_held++; - LOG_INF("steno press key=%u chord=0x%06X held=%u", + LOG_DBG("Key %u pressed, chord=0x%06X held=%u", key_index, state.current_chord, state.keys_held); return ZMK_BEHAVIOR_OPAQUE; @@ -207,14 +206,9 @@ static int on_steno_binding_released(struct zmk_behavior_binding *binding, if (state.keys_held > 0) { state.keys_held--; } - - LOG_INF("steno release held=%u chord=0x%06X", state.keys_held, state.current_chord); - if (state.keys_held == 0 && state.current_chord != 0) { - LOG_INF("steno all-up → process chord 0x%06X", state.current_chord); process_chord(); } - return ZMK_BEHAVIOR_OPAQUE; } @@ -230,6 +224,10 @@ static int behavior_steno_init(const struct device *dev) steno_undo_init(&undo_history); k_work_init_delayable(&state.multi_timeout, multi_timeout_handler); +#if IS_ENABLED(CONFIG_STENO_SPLIT_DICT) + split_dict_init(); + dict_ready = true; +#else size_t dict_size = _steno_dict_end - _steno_dict_start; if (dict_size > 4) { int ret; @@ -247,6 +245,7 @@ static int behavior_steno_init(const struct device *dev) } else { LOG_WRN("No steno dict embedded"); } +#endif LOG_INF("Steno engine initialized"); return 0; diff --git a/src/split_cache.c b/src/split_cache.c new file mode 100644 index 0000000..f5320dc --- /dev/null +++ b/src/split_cache.c @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2024 zmk-steno-engine contributors + * SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + * + * Licensed under the PolyForm Noncommercial License 1.0.0; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://polyformproject.org/licenses/noncommercial/1.0.0 + */ + +#include +#include "split_cache.h" + +/* FNV-1a hash over stroke bytes */ +static uint32_t hash_strokes(const uint32_t *strokes, uint8_t count) +{ + uint32_t hash = 2166136261u; /* FNV offset basis */ + + for (uint8_t i = 0; i < count; i++) { + uint32_t s = strokes[i]; + for (int b = 0; b < 4; b++) { + hash ^= (s & 0xFF); + hash *= 16777619u; /* FNV prime */ + s >>= 8; + } + } + + return hash; +} + +static bool strokes_match(const struct cache_entry *entry, + const uint32_t *strokes, uint8_t count) +{ + if (entry->stroke_count != count) { + return false; + } + return memcmp(entry->strokes, strokes, count * sizeof(uint32_t)) == 0; +} + +void split_cache_init(struct split_cache *cache) +{ + memset(cache->entries, 0, + sizeof(struct cache_entry) * CONFIG_STENO_SPLIT_CACHE_SIZE); + cache->access_counter = 0; + cache->hits = 0; + cache->misses = 0; +} + +bool split_cache_lookup(struct split_cache *cache, const uint32_t *strokes, + uint8_t count, char *result, size_t result_size, + bool *has_prefix) +{ + uint32_t h = hash_strokes(strokes, count); + + for (int i = 0; i < CONFIG_STENO_SPLIT_CACHE_SIZE; i++) { + struct cache_entry *e = &cache->entries[i]; + + if (!e->valid) { + continue; + } + + if (e->key_hash == h && strokes_match(e, strokes, count)) { + /* Hit */ + cache->access_counter++; + e->access_count = cache->access_counter; + cache->hits++; + + if (has_prefix) { + *has_prefix = e->has_prefix; + } + + if (result && result_size > 0) { + size_t len = strlen(e->translation); + if (len >= result_size) { + len = result_size - 1; + } + memcpy(result, e->translation, len); + result[len] = '\0'; + } + + return true; + } + } + + cache->misses++; + return false; +} + +void split_cache_insert(struct split_cache *cache, const uint32_t *strokes, + uint8_t count, const char *translation, bool has_prefix) +{ + if (count == 0 || count > 8) { + return; + } + + uint32_t h = hash_strokes(strokes, count); + + /* Check if already present → update */ + for (int i = 0; i < CONFIG_STENO_SPLIT_CACHE_SIZE; i++) { + struct cache_entry *e = &cache->entries[i]; + if (e->valid && e->key_hash == h && strokes_match(e, strokes, count)) { + /* Update existing entry */ + if (translation) { + size_t len = strlen(translation); + if (len >= SPLIT_CACHE_VALUE_SIZE) { + len = SPLIT_CACHE_VALUE_SIZE - 1; + } + memcpy(e->translation, translation, len); + e->translation[len] = '\0'; + } + e->has_prefix = has_prefix; + cache->access_counter++; + e->access_count = cache->access_counter; + return; + } + } + + /* Find empty slot or LRU victim */ + int target = -1; + uint32_t min_access = UINT32_MAX; + + for (int i = 0; i < CONFIG_STENO_SPLIT_CACHE_SIZE; i++) { + if (!cache->entries[i].valid) { + target = i; + break; + } + if (cache->entries[i].access_count < min_access) { + min_access = cache->entries[i].access_count; + target = i; + } + } + + if (target < 0) { + target = 0; /* fallback: should never happen if cache size > 0 */ + } + + struct cache_entry *e = &cache->entries[target]; + e->key_hash = h; + e->stroke_count = count; + memcpy(e->strokes, strokes, count * sizeof(uint32_t)); + + if (translation) { + size_t len = strlen(translation); + if (len >= SPLIT_CACHE_VALUE_SIZE) { + len = SPLIT_CACHE_VALUE_SIZE - 1; + } + memcpy(e->translation, translation, len); + e->translation[len] = '\0'; + } else { + e->translation[0] = '\0'; + } + + e->has_prefix = has_prefix; + e->valid = true; + cache->access_counter++; + e->access_count = cache->access_counter; +} + +void split_cache_invalidate(struct split_cache *cache) +{ + for (int i = 0; i < CONFIG_STENO_SPLIT_CACHE_SIZE; i++) { + cache->entries[i].valid = false; + } + cache->access_counter = 0; + cache->hits = 0; + cache->misses = 0; +} diff --git a/src/split_cache.h b/src/split_cache.h new file mode 100644 index 0000000..181ccf9 --- /dev/null +++ b/src/split_cache.h @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024 zmk-steno-engine contributors + * SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + * + * Licensed under the PolyForm Noncommercial License 1.0.0; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://polyformproject.org/licenses/noncommercial/1.0.0 + */ + +#ifndef SPLIT_CACHE_H +#define SPLIT_CACHE_H + +#include +#include +#include + +#define SPLIT_CACHE_KEY_SIZE 24 /* max 8 strokes * 3 bytes */ +#define SPLIT_CACHE_VALUE_SIZE 128 /* max translation length */ + +struct cache_entry { + uint32_t key_hash; + uint8_t stroke_count; + uint32_t strokes[8]; + char translation[SPLIT_CACHE_VALUE_SIZE]; + bool has_prefix; + bool valid; + uint32_t access_count; +}; + +struct split_cache { + struct cache_entry entries[CONFIG_STENO_SPLIT_CACHE_SIZE]; + uint32_t access_counter; + uint32_t hits; + uint32_t misses; +}; + +void split_cache_init(struct split_cache *cache); +bool split_cache_lookup(struct split_cache *cache, const uint32_t *strokes, + uint8_t count, char *result, size_t result_size, + bool *has_prefix); +void split_cache_insert(struct split_cache *cache, const uint32_t *strokes, + uint8_t count, const char *translation, bool has_prefix); +void split_cache_invalidate(struct split_cache *cache); + +#endif /* SPLIT_CACHE_H */ diff --git a/src/split_dict.c b/src/split_dict.c new file mode 100644 index 0000000..45bfb91 --- /dev/null +++ b/src/split_dict.c @@ -0,0 +1,465 @@ +/* + * Copyright (c) 2024 zmk-steno-engine contributors + * SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + * + * Licensed under the PolyForm Noncommercial License 1.0.0; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://polyformproject.org/licenses/noncommercial/1.0.0 + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "split_dict.h" +#include "split_cache.h" + +LOG_MODULE_REGISTER(split_dict, CONFIG_STENO_SPLIT_LOG_LEVEL); + +/* Semaphore for blocking on BLE response */ +static K_SEM_DEFINE(response_sem, 0, 1); + +/* Current pending response state */ +static uint8_t pending_seq; +static uint8_t response_buf[256]; +static uint16_t response_len; +static uint8_t seq_counter; + +/* Cache instance */ +static struct split_cache dict_cache; + +/* External trie lookup (peripheral side) */ +extern int trie_lookup(const uint32_t *strokes, uint8_t count, + char *result, size_t result_size); +extern bool trie_has_prefix(const uint32_t *strokes, uint8_t count); + +/* --- Helpers --- */ + +static void encode_strokes(const uint32_t *strokes, uint8_t count, uint8_t *out) +{ + for (uint8_t i = 0; i < count; i++) { + out[i * 3 + 0] = (strokes[i] >> 16) & 0xFF; + out[i * 3 + 1] = (strokes[i] >> 8) & 0xFF; + out[i * 3 + 2] = strokes[i] & 0xFF; + } +} + +static void decode_strokes(const uint8_t *in, uint8_t count, uint32_t *strokes) +{ + for (uint8_t i = 0; i < count; i++) { + strokes[i] = ((uint32_t)in[i * 3 + 0] << 16) | + ((uint32_t)in[i * 3 + 1] << 8) | + (uint32_t)in[i * 3 + 2]; + } +} + +/* --- GATT Write Callbacks (peripheral side handlers) --- */ + +static ssize_t dict_query_write_cb(struct bt_conn *conn, + const struct bt_gatt_attr *attr, + const void *buf, uint16_t len, + uint16_t offset, uint8_t flags) +{ + const struct steno_query_pkt *pkt = buf; + + if (len < sizeof(struct steno_query_pkt)) { + LOG_WRN("Query pkt too short: %u", len); + return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN); + } + + uint8_t stroke_count = pkt->stroke_count; + uint16_t expected = sizeof(struct steno_query_pkt) + stroke_count * 3; + + if (len < expected) { + LOG_WRN("Query pkt truncated: got %u, need %u", len, expected); + return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN); + } + + uint32_t strokes[8]; + if (stroke_count > 8) { + stroke_count = 8; + } + decode_strokes(pkt->strokes, stroke_count, strokes); + + /* Build response */ + struct steno_response_pkt *resp = (struct steno_response_pkt *)response_buf; + resp->msg_type = STENO_MSG_RESPONSE; + resp->seq = pkt->seq; + + char translation[128]; + int ret = trie_lookup(strokes, stroke_count, translation, sizeof(translation)); + + if (ret > 0) { + resp->status = STENO_STATUS_FOUND; + resp->data_len = (uint16_t)ret; + memcpy(resp->data, translation, ret); + response_len = sizeof(struct steno_response_pkt) + ret; + } else { + resp->status = STENO_STATUS_NOT_FOUND; + resp->data_len = 0; + response_len = sizeof(struct steno_response_pkt); + } + + /* Notify central with response */ + bt_gatt_notify(conn, attr, response_buf, response_len); + + return len; +} + +static ssize_t dict_prefix_write_cb(struct bt_conn *conn, + const struct bt_gatt_attr *attr, + const void *buf, uint16_t len, + uint16_t offset, uint8_t flags) +{ + const struct steno_query_pkt *pkt = buf; + + if (len < sizeof(struct steno_query_pkt)) { + return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN); + } + + uint8_t stroke_count = pkt->stroke_count; + if (stroke_count > 8) { + stroke_count = 8; + } + + uint32_t strokes[8]; + decode_strokes(pkt->strokes, stroke_count, strokes); + + struct steno_response_pkt *resp = (struct steno_response_pkt *)response_buf; + resp->msg_type = STENO_MSG_RESPONSE; + resp->seq = pkt->seq; + resp->data_len = 0; + + if (trie_has_prefix(strokes, stroke_count)) { + resp->status = STENO_STATUS_PREFIX_ONLY; + } else { + resp->status = STENO_STATUS_NOT_FOUND; + } + + response_len = sizeof(struct steno_response_pkt); + bt_gatt_notify(conn, attr, response_buf, response_len); + + return len; +} + +static ssize_t dict_batch_write_cb(struct bt_conn *conn, + const struct bt_gatt_attr *attr, + const void *buf, uint16_t len, + uint16_t offset, uint8_t flags) +{ + const struct steno_batch_query_pkt *pkt = buf; + + if (len < sizeof(struct steno_batch_query_pkt)) { + return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN); + } + + LOG_DBG("Batch query: %u queries", pkt->query_count); + + /* Process each sub-query packed in queries[] */ + uint16_t pos = 0; + const uint8_t *data = pkt->queries; + uint16_t data_len = len - sizeof(struct steno_batch_query_pkt); + + for (uint8_t q = 0; q < pkt->query_count && pos < data_len; q++) { + if (pos >= data_len) { + break; + } + uint8_t stroke_count = data[pos]; + pos++; + + if (stroke_count > 8) { + stroke_count = 8; + } + if (pos + stroke_count * 3 > data_len) { + break; + } + + uint32_t strokes[8]; + decode_strokes(&data[pos], stroke_count, strokes); + pos += stroke_count * 3; + + /* Lookup and send individual response per query */ + struct steno_response_pkt *resp = (struct steno_response_pkt *)response_buf; + resp->msg_type = STENO_MSG_RESPONSE; + resp->seq = pkt->seq; + + char translation[128]; + int ret = trie_lookup(strokes, stroke_count, translation, sizeof(translation)); + + if (ret > 0) { + resp->status = STENO_STATUS_FOUND; + resp->data_len = (uint16_t)ret; + memcpy(resp->data, translation, ret); + response_len = sizeof(struct steno_response_pkt) + ret; + } else { + resp->status = STENO_STATUS_NOT_FOUND; + resp->data_len = 0; + response_len = sizeof(struct steno_response_pkt); + } + + bt_gatt_notify(conn, attr, response_buf, response_len); + } + + return len; +} + +/* --- Notification callback (central side) --- */ + +static uint8_t notify_cb(struct bt_conn *conn, + struct bt_gatt_subscribe_params *params, + const void *data, uint16_t length) +{ + if (!data) { + LOG_DBG("Notification unsubscribed"); + return BT_GATT_ITER_STOP; + } + + const struct steno_response_pkt *resp = data; + + if (length < sizeof(struct steno_response_pkt)) { + LOG_WRN("Response too short"); + return BT_GATT_ITER_CONTINUE; + } + + if (resp->seq == pending_seq) { + memcpy(response_buf, data, length); + response_len = length; + k_sem_give(&response_sem); + } + + return BT_GATT_ITER_CONTINUE; +} + +/* --- GATT Service Definition --- */ + +BT_GATT_SERVICE_DEFINE(steno_dict_svc, + BT_GATT_PRIMARY_SERVICE(STENO_UUID_SERVICE), + + /* Dict Query characteristic: write + notify */ + BT_GATT_CHARACTERISTIC(STENO_UUID_DICT_QUERY, + BT_GATT_CHRC_WRITE | BT_GATT_CHRC_NOTIFY, + BT_GATT_PERM_WRITE, + NULL, dict_query_write_cb, NULL), + BT_GATT_CCC(NULL, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE), + + /* Dict Prefix characteristic: write + notify */ + BT_GATT_CHARACTERISTIC(STENO_UUID_DICT_PREFIX, + BT_GATT_CHRC_WRITE | BT_GATT_CHRC_NOTIFY, + BT_GATT_PERM_WRITE, + NULL, dict_prefix_write_cb, NULL), + BT_GATT_CCC(NULL, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE), + + /* Dict Batch characteristic: write + notify */ + BT_GATT_CHARACTERISTIC(STENO_UUID_DICT_BATCH, + BT_GATT_CHRC_WRITE | BT_GATT_CHRC_NOTIFY, + BT_GATT_PERM_WRITE, + NULL, dict_batch_write_cb, NULL), + BT_GATT_CCC(NULL, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE), +); + +/* --- Central-side API --- */ + +/* Connection handle for GATT writes (set externally or via connection cb) */ +static struct bt_conn *split_conn; +static struct bt_gatt_subscribe_params subscribe_params; + +int split_dict_lookup(const uint32_t *strokes, uint8_t count, + char *result, size_t result_size) +{ + if (count == 0 || count > 8) { + return -EINVAL; + } + + /* Check cache first */ + bool has_prefix; + if (split_cache_lookup(&dict_cache, strokes, count, result, result_size, &has_prefix)) { + LOG_DBG("Cache hit for %u strokes", count); + return strlen(result); + } + + if (!split_conn) { + LOG_ERR("No split connection"); + return -ENOTCONN; + } + + /* Build query packet */ + uint8_t pkt_buf[sizeof(struct steno_query_pkt) + 8 * 3]; + struct steno_query_pkt *pkt = (struct steno_query_pkt *)pkt_buf; + + pkt->msg_type = STENO_MSG_QUERY; + pkt->seq = seq_counter++; + pkt->stroke_count = count; + encode_strokes(strokes, count, pkt->strokes); + + pending_seq = pkt->seq; + k_sem_reset(&response_sem); + + uint16_t pkt_len = sizeof(struct steno_query_pkt) + count * 3; + + /* Send via GATT write */ + int err = bt_gatt_write_without_response(split_conn, 0, pkt_buf, pkt_len, false); + if (err) { + LOG_ERR("GATT write failed: %d", err); + return err; + } + + /* Wait for response */ + err = k_sem_take(&response_sem, K_MSEC(CONFIG_STENO_SPLIT_TIMEOUT_MS)); + if (err) { + LOG_WRN("Response timeout"); + return -ETIMEDOUT; + } + + /* Decode response */ + const struct steno_response_pkt *resp = (const struct steno_response_pkt *)response_buf; + + if (resp->status == STENO_STATUS_FOUND) { + uint16_t copy_len = resp->data_len; + if (copy_len >= result_size) { + copy_len = result_size - 1; + } + memcpy(result, resp->data, copy_len); + result[copy_len] = '\0'; + + /* Cache the result */ + split_cache_insert(&dict_cache, strokes, count, result, false); + + return copy_len; + } + + return -ENOENT; +} + +bool split_dict_has_prefix(const uint32_t *strokes, uint8_t count) +{ + if (count == 0 || count > 8) { + return false; + } + + /* Check cache */ + bool has_prefix; + char dummy[1]; + if (split_cache_lookup(&dict_cache, strokes, count, dummy, sizeof(dummy), &has_prefix)) { + return has_prefix; + } + + if (!split_conn) { + return false; + } + + uint8_t pkt_buf[sizeof(struct steno_query_pkt) + 8 * 3]; + struct steno_query_pkt *pkt = (struct steno_query_pkt *)pkt_buf; + + pkt->msg_type = STENO_MSG_PREFIX; + pkt->seq = seq_counter++; + pkt->stroke_count = count; + encode_strokes(strokes, count, pkt->strokes); + + pending_seq = pkt->seq; + k_sem_reset(&response_sem); + + uint16_t pkt_len = sizeof(struct steno_query_pkt) + count * 3; + + int err = bt_gatt_write_without_response(split_conn, 0, pkt_buf, pkt_len, false); + if (err) { + return false; + } + + err = k_sem_take(&response_sem, K_MSEC(CONFIG_STENO_SPLIT_TIMEOUT_MS)); + if (err) { + return false; + } + + const struct steno_response_pkt *resp = (const struct steno_response_pkt *)response_buf; + return resp->status == STENO_STATUS_PREFIX_ONLY; +} + +int split_dict_batch_lookup(const uint32_t **stroke_seqs, const uint8_t *counts, + uint8_t num_queries, struct steno_batch_result *results) +{ + if (num_queries == 0 || !split_conn) { + return -EINVAL; + } + + /* Build batch packet */ + uint8_t pkt_buf[256]; + struct steno_batch_query_pkt *pkt = (struct steno_batch_query_pkt *)pkt_buf; + + pkt->msg_type = STENO_MSG_BATCH; + pkt->seq = seq_counter++; + pkt->query_count = num_queries; + + uint16_t pos = 0; + for (uint8_t q = 0; q < num_queries; q++) { + uint8_t cnt = counts[q]; + if (cnt > 8) { + cnt = 8; + } + pkt->queries[pos] = cnt; + pos++; + encode_strokes(stroke_seqs[q], cnt, &pkt->queries[pos]); + pos += cnt * 3; + } + + uint16_t pkt_len = sizeof(struct steno_batch_query_pkt) + pos; + + pending_seq = pkt->seq; + k_sem_reset(&response_sem); + + int err = bt_gatt_write_without_response(split_conn, 0, pkt_buf, pkt_len, false); + if (err) { + return err; + } + + /* Collect responses for each query */ + for (uint8_t q = 0; q < num_queries; q++) { + err = k_sem_take(&response_sem, K_MSEC(CONFIG_STENO_SPLIT_TIMEOUT_MS)); + if (err) { + results[q].status = STENO_STATUS_ERROR; + continue; + } + + const struct steno_response_pkt *resp = + (const struct steno_response_pkt *)response_buf; + + results[q].status = resp->status; + results[q].has_prefix = (resp->status == STENO_STATUS_PREFIX_ONLY); + + if (resp->status == STENO_STATUS_FOUND && resp->data_len > 0) { + uint16_t copy_len = resp->data_len; + if (copy_len >= sizeof(results[q].translation)) { + copy_len = sizeof(results[q].translation) - 1; + } + memcpy(results[q].translation, resp->data, copy_len); + results[q].translation[copy_len] = '\0'; + results[q].translation_len = copy_len; + } else { + results[q].translation[0] = '\0'; + results[q].translation_len = 0; + } + } + + return 0; +} + +int split_dict_init(void) +{ + split_cache_init(&dict_cache); + seq_counter = 0; + split_conn = NULL; + + LOG_INF("Split dict initialized"); + return 0; +} + +int split_dict_gatt_register(void) +{ + /* GATT service registered statically via BT_GATT_SERVICE_DEFINE */ + LOG_INF("Split dict GATT service registered"); + return 0; +} diff --git a/src/split_dict.h b/src/split_dict.h new file mode 100644 index 0000000..b2bff3a --- /dev/null +++ b/src/split_dict.h @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2024 zmk-steno-engine contributors + * SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + * + * Licensed under the PolyForm Noncommercial License 1.0.0; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://polyformproject.org/licenses/noncommercial/1.0.0 + */ + +#ifndef SPLIT_DICT_H +#define SPLIT_DICT_H + +#include +#include +#include +#include + +/* Custom 128-bit UUID base for steno GATT service + * Base: 7374656e-6f00-4000-8000-000000000000 */ +#define STENO_UUID_BASE \ + BT_UUID_DECLARE_128(BT_UUID_128_ENCODE( \ + 0x7374656e, 0x6f00, 0x4000, 0x8000, 0x000000000000)) + +#define STENO_UUID_SERVICE \ + BT_UUID_DECLARE_128(BT_UUID_128_ENCODE( \ + 0x7374656e, 0x6f00, 0x4000, 0x8000, 0x000000000001)) + +#define STENO_UUID_DICT_QUERY \ + BT_UUID_DECLARE_128(BT_UUID_128_ENCODE( \ + 0x7374656e, 0x6f00, 0x4000, 0x8000, 0x000000000002)) + +#define STENO_UUID_DICT_PREFIX \ + BT_UUID_DECLARE_128(BT_UUID_128_ENCODE( \ + 0x7374656e, 0x6f00, 0x4000, 0x8000, 0x000000000003)) + +#define STENO_UUID_DICT_BATCH \ + BT_UUID_DECLARE_128(BT_UUID_128_ENCODE( \ + 0x7374656e, 0x6f00, 0x4000, 0x8000, 0x000000000004)) + +/* Message types */ +enum steno_msg_type { + STENO_MSG_QUERY = 0x01, + STENO_MSG_PREFIX = 0x02, + STENO_MSG_BATCH = 0x03, + STENO_MSG_RESPONSE = 0x80, +}; + +/* Status codes */ +enum steno_status { + STENO_STATUS_FOUND = 0, + STENO_STATUS_NOT_FOUND = 1, + STENO_STATUS_PREFIX_ONLY = 2, + STENO_STATUS_ERROR = 3, +}; + +/* Packet structures */ +struct steno_query_pkt { + uint8_t msg_type; + uint8_t seq; + uint8_t stroke_count; + uint8_t strokes[]; /* 3 bytes per stroke (24-bit packed) */ +} __packed; + +struct steno_response_pkt { + uint8_t msg_type; + uint8_t seq; + uint8_t status; + uint16_t data_len; + uint8_t data[]; /* translation string (UTF-8, not null-terminated) */ +} __packed; + +struct steno_batch_query_pkt { + uint8_t msg_type; + uint8_t seq; + uint8_t query_count; + uint8_t queries[]; /* packed steno_query_pkt entries (without msg_type/seq) */ +} __packed; + +/* Batch result entry */ +struct steno_batch_result { + uint8_t status; + char translation[128]; + uint16_t translation_len; + bool has_prefix; +}; + +/* API */ +int split_dict_init(void); +int split_dict_lookup(const uint32_t *strokes, uint8_t count, + char *result, size_t result_size); +bool split_dict_has_prefix(const uint32_t *strokes, uint8_t count); +int split_dict_batch_lookup(const uint32_t **stroke_seqs, const uint8_t *counts, + uint8_t num_queries, struct steno_batch_result *results); + +/* GATT service registration */ +int split_dict_gatt_register(void); + +#endif /* SPLIT_DICT_H */