Merge split-storage: BLE GATT dict protocol + split-aware build

Combined optimize-dict (MPHF compression, block-compressed strings,
formatter, undo, Unicode output) with split-storage (BLE GATT service,
LRU cache, peripheral-side dict embed).

Split-dict mode: central queries peripheral over BLE.
Non-split mode: dict embedded on central, local lookup.
This commit is contained in:
afiqzudinhadi 2026-07-02 08:57:32 +08:00
commit bc2e40caf4
7 changed files with 972 additions and 132 deletions

View file

@ -2,45 +2,87 @@
# SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 # SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
if(CONFIG_STENO_ENGINE) 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 target_include_directories(app PRIVATE
include include
${CMAKE_CURRENT_SOURCE_DIR}/src ${CMAKE_CURRENT_SOURCE_DIR}/src
) )
# ── Dictionary source resolution ── # ── Split-dict mode ──────────────────────────────────
find_package(Python3 REQUIRED COMPONENTS Interpreter) # Central: behavior engine + BLE dict client
set(STENO_DICT_BIN ${CMAKE_CURRENT_BINARY_DIR}/steno_dict.bin) # Peripheral: dict embed + lookup engine + GATT server
set(STENO_DICTS_DIR ${CMAKE_CURRENT_SOURCE_DIR}/dicts) if(CONFIG_STENO_SPLIT_DICT)
set(STENO_FETCH ${CMAKE_CURRENT_SOURCE_DIR}/tools/fetch_dict.py)
if(CONFIG_STENO_DICT_PLOVER) if(CONFIG_ZMK_SPLIT_ROLE_CENTRAL)
set(STENO_DICT_NAME "plover") # Central side: behavior engine queries peripheral over BLE
set(STENO_DICT_SRC ${STENO_DICTS_DIR}/plover-main.json) target_sources(app PRIVATE
elseif(CONFIG_STENO_DICT_LAPWING) src/behavior_steno.c
set(STENO_DICT_NAME "lapwing") src/output.c
set(STENO_DICT_SRC ${STENO_DICTS_DIR}/lapwing.json) src/formatter.c
src/undo.c
src/split_dict.c
src/split_cache.c
)
else()
# 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()
# ── Non-split mode ───────────────────────────────────
# Everything on one board (or central-only without split)
else() else()
set(STENO_DICT_SRC ${STENO_DICTS_DIR}/test.json) 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() endif()
# Auto-download dict if needed (Plover/Lapwing only) # ── Dictionary compilation ───────────────────────────
if(DEFINED STENO_DICT_NAME AND NOT EXISTS ${STENO_DICT_SRC}) # 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...") message(STATUS "Steno: downloading ${STENO_DICT_NAME} dictionary...")
execute_process( execute_process(
COMMAND ${Python3_EXECUTABLE} ${STENO_FETCH} COMMAND ${Python3_EXECUTABLE} ${STENO_FETCH}
@ -50,12 +92,12 @@ if(DEFINED STENO_DICT_NAME AND NOT EXISTS ${STENO_DICT_SRC})
if(NOT FETCH_RESULT EQUAL 0) if(NOT FETCH_RESULT EQUAL 0)
message(WARNING "Steno: dict download failed. Build may fail.") message(WARNING "Steno: dict download failed. Build may fail.")
endif() endif()
endif() endif()
# ── Dictionary compilation ── # Compile dict binary
if(EXISTS ${STENO_DICT_SRC}) if(EXISTS ${STENO_DICT_SRC})
if(CONFIG_STENO_DICT_MPHF) if(CONFIG_STENO_DICT_MPHF)
# Fetch at build time if hash changed (re-run on rebuild) # Fetch at build time if hash changed
if(DEFINED STENO_DICT_NAME) if(DEFINED STENO_DICT_NAME)
add_custom_command( add_custom_command(
OUTPUT ${STENO_DICT_SRC}.stamp OUTPUT ${STENO_DICT_SRC}.stamp
@ -95,11 +137,9 @@ if(EXISTS ${STENO_DICT_SRC})
endif() endif()
add_dependencies(app steno_dict_gen) add_dependencies(app steno_dict_gen)
# Generate header with dict path for .incbin set_property(SOURCE src/dict_embed.S APPEND PROPERTY
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/steno_dict_path.h COMPILE_DEFINITIONS STENO_DICT_BIN_PATH="${STENO_DICT_BIN}")
"#define STENO_DICT_BIN_PATH \"${STENO_DICT_BIN}\"\n") endif()
target_include_directories(app PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
endif() endif()
endif() # central role
endif() # CONFIG_STENO_ENGINE endif() # CONFIG_STENO_ENGINE

34
Kconfig
View file

@ -16,7 +16,7 @@ choice STENO_DICT
config STENO_DICT_PLOVER config STENO_DICT_PLOVER
bool "Plover main dictionary (MPHF compressed)" bool "Plover main dictionary (MPHF compressed)"
help help
Use Plover main.json via MPHF compression (~44K entries in 453KB). Use Plover main.json via MPHF compression.
config STENO_DICT_LAPWING config STENO_DICT_LAPWING
bool "Lapwing dictionary (MPHF compressed)" bool "Lapwing dictionary (MPHF compressed)"
@ -35,7 +35,6 @@ config STENO_DICT_MPHF
help help
Use MPHF (minimal perfect hash) dictionary format. Use MPHF (minimal perfect hash) dictionary format.
Selects ZLIB for block-compressed string table decompression. Selects ZLIB for block-compressed string table decompression.
Auto-selected for Plover/Lapwing dicts.
config STENO_CUSTOM_KEYMAP config STENO_CUSTOM_KEYMAP
bool "Custom steno keymap" bool "Custom steno keymap"
@ -76,10 +75,35 @@ config STENO_MULTI_STROKE_TIMEOUT_MS
config STENO_DICT_MAX_SIZE config STENO_DICT_MAX_SIZE
int "Max dictionary binary size (bytes)" int "Max dictionary binary size (bytes)"
default 430080 default 473088
help help
Max compiled dict size. 430080 = 420KB. Max compiled dict size. 473088 = 462KB.
Leaves room for zlib decompressor + USB logging overhead.
The MPHF compiler auto-trims to fit. 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 endif # STENO_ENGINE

View file

@ -16,7 +16,9 @@
#include "undo.h" #include "undo.h"
#include "formatter.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" #include "dict_mphf.h"
#else #else
#include "trie.h" #include "trie.h"
@ -24,34 +26,18 @@
LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL); 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_start[];
extern const uint8_t _steno_dict_end[]; 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; static struct dict_mphf mphf_dict;
#endif #endif
#define STENO_MAX_MULTI 8 #define STENO_MAX_MULTI 8
#define STENO_MULTI_TIMEOUT_MS CONFIG_STENO_MULTI_STROKE_TIMEOUT_MS #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 { struct steno_state {
uint32_t current_chord; uint32_t current_chord;
uint8_t keys_held; uint8_t keys_held;
@ -68,6 +54,30 @@ static bool dict_ready;
static void flush_strokes(void); static void flush_strokes(void);
static void multi_timeout_handler(struct k_work *work); 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, static void emit_formatted(const char *translation,
const uint32_t *strokes, uint8_t stroke_count) const uint32_t *strokes, uint8_t stroke_count)
{ {
@ -114,15 +124,11 @@ static void process_chord(void)
state.current_chord = 0; state.current_chord = 0;
if (!dict_ready) { if (!dict_ready) {
LOG_WRN("steno dict not ready, flushing");
flush_strokes(); flush_strokes();
return; return;
} }
const char *translation = dict_lookup( const char *translation = do_lookup(state.pending_strokes, state.stroke_count);
state.pending_strokes, state.stroke_count);
LOG_INF("steno lookup %u strokes → %s", state.stroke_count,
translation ? translation : "(null)");
if (translation) { if (translation) {
emit_formatted(translation, state.pending_strokes, state.stroke_count); emit_formatted(translation, state.pending_strokes, state.stroke_count);
@ -130,7 +136,7 @@ static void process_chord(void)
return; 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_work_schedule(&state.multi_timeout,
K_MSEC(STENO_MULTI_TIMEOUT_MS)); K_MSEC(STENO_MULTI_TIMEOUT_MS));
return; return;
@ -140,8 +146,7 @@ static void process_chord(void)
uint32_t last = state.pending_strokes[state.stroke_count - 1]; uint32_t last = state.pending_strokes[state.stroke_count - 1];
state.stroke_count--; state.stroke_count--;
const char *partial = dict_lookup( const char *partial = do_lookup(state.pending_strokes, state.stroke_count);
state.pending_strokes, state.stroke_count);
if (partial) { if (partial) {
emit_formatted(partial, state.pending_strokes, state.stroke_count); emit_formatted(partial, state.pending_strokes, state.stroke_count);
} }
@ -149,7 +154,7 @@ static void process_chord(void)
state.pending_strokes[0] = last; state.pending_strokes[0] = last;
state.stroke_count = 1; state.stroke_count = 1;
const char *rest = dict_lookup(&last, 1); const char *rest = do_lookup(&last, 1);
if (rest) { if (rest) {
emit_formatted(rest, &last, 1); emit_formatted(rest, &last, 1);
state.stroke_count = 0; state.stroke_count = 0;
@ -169,14 +174,10 @@ static void flush_strokes(void)
static void multi_timeout_handler(struct k_work *work) static void multi_timeout_handler(struct k_work *work)
{ {
ARG_UNUSED(work); ARG_UNUSED(work);
if (state.stroke_count == 0) { if (state.stroke_count == 0) {
return; return;
} }
const char *translation = do_lookup(state.pending_strokes, state.stroke_count);
const char *translation = dict_lookup(
state.pending_strokes, state.stroke_count);
if (translation) { if (translation) {
emit_formatted(translation, state.pending_strokes, state.stroke_count); 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) struct zmk_behavior_binding_event event)
{ {
uint32_t key_index = binding->param1; uint32_t key_index = binding->param1;
if (key_index > 35) { if (key_index > 35) {
return -EINVAL; return -EINVAL;
} }
state.current_chord |= (1U << key_index); state.current_chord |= (1U << key_index);
state.keys_held++; 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); key_index, state.current_chord, state.keys_held);
return ZMK_BEHAVIOR_OPAQUE; return ZMK_BEHAVIOR_OPAQUE;
@ -207,14 +206,9 @@ static int on_steno_binding_released(struct zmk_behavior_binding *binding,
if (state.keys_held > 0) { if (state.keys_held > 0) {
state.keys_held--; 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) { if (state.keys_held == 0 && state.current_chord != 0) {
LOG_INF("steno all-up → process chord 0x%06X", state.current_chord);
process_chord(); process_chord();
} }
return ZMK_BEHAVIOR_OPAQUE; return ZMK_BEHAVIOR_OPAQUE;
} }
@ -230,6 +224,10 @@ static int behavior_steno_init(const struct device *dev)
steno_undo_init(&undo_history); steno_undo_init(&undo_history);
k_work_init_delayable(&state.multi_timeout, multi_timeout_handler); 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; size_t dict_size = _steno_dict_end - _steno_dict_start;
if (dict_size > 4) { if (dict_size > 4) {
int ret; int ret;
@ -247,6 +245,7 @@ static int behavior_steno_init(const struct device *dev)
} else { } else {
LOG_WRN("No steno dict embedded"); LOG_WRN("No steno dict embedded");
} }
#endif
LOG_INF("Steno engine initialized"); LOG_INF("Steno engine initialized");
return 0; return 0;

167
src/split_cache.c Normal file
View file

@ -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 <string.h>
#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;
}

46
src/split_cache.h Normal file
View file

@ -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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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 */

465
src/split_dict.c Normal file
View file

@ -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 <string.h>
#include <zephyr/kernel.h>
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/gatt.h>
#include <zephyr/bluetooth/conn.h>
#include <zephyr/bluetooth/uuid.h>
#include <zephyr/logging/log.h>
#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;
}

99
src/split_dict.h Normal file
View file

@ -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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <zephyr/bluetooth/uuid.h>
/* 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 */