Steno engine with BLE split-storage architecture
Full steno engine: chord detection, MPHF/trie dict lookup, Plover formatter (18 commands), undo (ring buffer), HID + Unicode output. Split-storage specific: - BLE GATT service for dict queries (query/prefix/batch) - LRU cache on central side (CONFIG_STENO_SPLIT_CACHE_SIZE) - Dict embedded on peripheral, engine runs on central - 3-way dispatch: split_dict / MPHF / simple trie Build system: auto-fetch Plover dict, MPHF compiler with block compression (~56K entries in 420KB), CMake integration.
This commit is contained in:
parent
234bc8d731
commit
2af45eafce
36 changed files with 5969 additions and 0 deletions
270
src/behavior_steno.c
Normal file
270
src/behavior_steno.c
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Afiq Zudin Hadi
|
||||
* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
*/
|
||||
|
||||
#define DT_DRV_COMPAT zmk_behavior_steno_engine
|
||||
|
||||
#include <zephyr/device.h>
|
||||
#include <zephyr/kernel.h>
|
||||
#include <zephyr/logging/log.h>
|
||||
|
||||
#include <drivers/behavior.h>
|
||||
#include <zmk/behavior.h>
|
||||
|
||||
#include "output.h"
|
||||
#include "undo.h"
|
||||
#include "formatter.h"
|
||||
|
||||
#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"
|
||||
#endif
|
||||
|
||||
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) && !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
|
||||
|
||||
struct steno_state {
|
||||
uint32_t current_chord;
|
||||
uint8_t keys_held;
|
||||
uint32_t pending_strokes[STENO_MAX_MULTI];
|
||||
uint8_t stroke_count;
|
||||
struct k_work_delayable multi_timeout;
|
||||
};
|
||||
|
||||
static struct steno_state state;
|
||||
static struct steno_fmt_state fmt_state;
|
||||
static struct stroke_history undo_history;
|
||||
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)
|
||||
{
|
||||
struct steno_fmt_result result;
|
||||
steno_fmt_process(&fmt_state, translation, &result);
|
||||
if (result.backspaces > 0) {
|
||||
steno_output_backspace(result.backspaces);
|
||||
}
|
||||
if (result.len > 0) {
|
||||
steno_output_send(result.text, result.len);
|
||||
}
|
||||
if (!result.is_command_only) {
|
||||
steno_undo_push(&undo_history, strokes, stroke_count,
|
||||
result.len + result.backspaces, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
static void process_chord(void)
|
||||
{
|
||||
if (state.current_chord == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Star-only stroke (bit 9) → undo */
|
||||
if (state.current_chord == (1U << 9)) {
|
||||
state.current_chord = 0;
|
||||
struct stroke_history_entry *ue = steno_undo_pop(&undo_history);
|
||||
if (ue) {
|
||||
steno_output_backspace(ue->output_len);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
k_work_cancel_delayable(&state.multi_timeout);
|
||||
|
||||
if (state.stroke_count < STENO_MAX_MULTI) {
|
||||
state.pending_strokes[state.stroke_count] = state.current_chord;
|
||||
state.stroke_count++;
|
||||
} else {
|
||||
flush_strokes();
|
||||
state.pending_strokes[0] = state.current_chord;
|
||||
state.stroke_count = 1;
|
||||
}
|
||||
state.current_chord = 0;
|
||||
|
||||
if (!dict_ready) {
|
||||
flush_strokes();
|
||||
return;
|
||||
}
|
||||
|
||||
const char *translation = do_lookup(state.pending_strokes, state.stroke_count);
|
||||
|
||||
if (translation) {
|
||||
emit_formatted(translation, state.pending_strokes, state.stroke_count);
|
||||
state.stroke_count = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (do_has_prefix(state.pending_strokes, state.stroke_count)) {
|
||||
k_work_schedule(&state.multi_timeout,
|
||||
K_MSEC(STENO_MULTI_TIMEOUT_MS));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.stroke_count > 1) {
|
||||
uint32_t last = state.pending_strokes[state.stroke_count - 1];
|
||||
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);
|
||||
}
|
||||
|
||||
state.pending_strokes[0] = last;
|
||||
state.stroke_count = 1;
|
||||
|
||||
const char *rest = do_lookup(&last, 1);
|
||||
if (rest) {
|
||||
emit_formatted(rest, &last, 1);
|
||||
state.stroke_count = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
flush_strokes();
|
||||
}
|
||||
|
||||
static void flush_strokes(void)
|
||||
{
|
||||
LOG_DBG("Flushing %u untranslated strokes", state.stroke_count);
|
||||
state.stroke_count = 0;
|
||||
}
|
||||
|
||||
static void multi_timeout_handler(struct k_work *work)
|
||||
{
|
||||
ARG_UNUSED(work);
|
||||
if (state.stroke_count == 0) {
|
||||
return;
|
||||
}
|
||||
const char *translation = do_lookup(state.pending_strokes, state.stroke_count);
|
||||
if (translation) {
|
||||
emit_formatted(translation, state.pending_strokes, state.stroke_count);
|
||||
}
|
||||
state.stroke_count = 0;
|
||||
}
|
||||
|
||||
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_DBG("Key %u pressed, chord=0x%06X held=%u",
|
||||
key_index, state.current_chord, state.keys_held);
|
||||
|
||||
return ZMK_BEHAVIOR_OPAQUE;
|
||||
}
|
||||
|
||||
static int on_steno_binding_released(struct zmk_behavior_binding *binding,
|
||||
struct zmk_behavior_binding_event event)
|
||||
{
|
||||
if (state.keys_held > 0) {
|
||||
state.keys_held--;
|
||||
}
|
||||
if (state.keys_held == 0 && state.current_chord != 0) {
|
||||
process_chord();
|
||||
}
|
||||
return ZMK_BEHAVIOR_OPAQUE;
|
||||
}
|
||||
|
||||
static int behavior_steno_init(const struct device *dev)
|
||||
{
|
||||
ARG_UNUSED(dev);
|
||||
|
||||
state.current_chord = 0;
|
||||
state.keys_held = 0;
|
||||
state.stroke_count = 0;
|
||||
|
||||
steno_fmt_init(&fmt_state);
|
||||
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;
|
||||
#if IS_ENABLED(CONFIG_STENO_DICT_MPHF)
|
||||
ret = dict_mphf_init(&mphf_dict, _steno_dict_start, dict_size);
|
||||
#else
|
||||
ret = steno_trie_init(_steno_dict_start, dict_size);
|
||||
#endif
|
||||
if (ret == 0) {
|
||||
dict_ready = true;
|
||||
LOG_INF("Steno dict loaded (%u bytes)", (unsigned)dict_size);
|
||||
} else {
|
||||
LOG_ERR("Steno dict init failed: %d", ret);
|
||||
}
|
||||
} else {
|
||||
LOG_WRN("No steno dict embedded");
|
||||
}
|
||||
#endif
|
||||
|
||||
LOG_INF("Steno engine initialized");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static const struct behavior_driver_api steno_driver_api = {
|
||||
.locality = BEHAVIOR_LOCALITY_CENTRAL,
|
||||
.binding_pressed = on_steno_binding_pressed,
|
||||
.binding_released = on_steno_binding_released,
|
||||
};
|
||||
|
||||
#define STENO_INST(n) \
|
||||
BEHAVIOR_DT_INST_DEFINE(n, \
|
||||
behavior_steno_init, \
|
||||
NULL, \
|
||||
NULL, \
|
||||
NULL, \
|
||||
POST_KERNEL, \
|
||||
CONFIG_KERNEL_INIT_PRIORITY_DEFAULT, \
|
||||
&steno_driver_api);
|
||||
|
||||
DT_INST_FOREACH_STATUS_OKAY(STENO_INST)
|
||||
12
src/dict_embed.S
Normal file
12
src/dict_embed.S
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
.section .rodata.steno_dict, "a", %progbits
|
||||
.global _steno_dict_start
|
||||
.global _steno_dict_end
|
||||
|
||||
.balign 4
|
||||
_steno_dict_start:
|
||||
#ifdef STENO_DICT_BIN_PATH
|
||||
.incbin STENO_DICT_BIN_PATH
|
||||
#else
|
||||
.byte 0x00, 0x00, 0x00, 0x00
|
||||
#endif
|
||||
_steno_dict_end:
|
||||
231
src/dict_mphf.c
Normal file
231
src/dict_mphf.c
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* MPHF dictionary lookup engine — implementation.
|
||||
*
|
||||
* All data is read directly from flash. No heap allocation.
|
||||
* Bit-packed fields read via inline bit extraction.
|
||||
*
|
||||
* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
*/
|
||||
|
||||
#include "dict_mphf.h"
|
||||
#include <string.h>
|
||||
|
||||
/* ─── FNV-1a 32-bit hash ─── */
|
||||
|
||||
static uint32_t fnv1a_32(const uint8_t *data, size_t len)
|
||||
{
|
||||
uint32_t h = 0x811c9dc5u;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
h ^= data[i];
|
||||
h *= 0x01000193u;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash key bytes with a seed (prepend seed as LE u32).
|
||||
* Equivalent to: fnv1a_32(pack('<I', seed) + key_bytes)
|
||||
*/
|
||||
static uint32_t hash_key(const uint8_t *key, size_t key_len, uint32_t seed)
|
||||
{
|
||||
uint32_t h = 0x811c9dc5u;
|
||||
|
||||
/* Hash the seed bytes first (little-endian u32) */
|
||||
uint8_t seed_bytes[4];
|
||||
seed_bytes[0] = (uint8_t)(seed);
|
||||
seed_bytes[1] = (uint8_t)(seed >> 8);
|
||||
seed_bytes[2] = (uint8_t)(seed >> 16);
|
||||
seed_bytes[3] = (uint8_t)(seed >> 24);
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
h ^= seed_bytes[i];
|
||||
h *= 0x01000193u;
|
||||
}
|
||||
|
||||
/* Then hash the key bytes */
|
||||
for (size_t i = 0; i < key_len; i++) {
|
||||
h ^= key[i];
|
||||
h *= 0x01000193u;
|
||||
}
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
/* ─── Bit-packed field reading ─── */
|
||||
|
||||
/**
|
||||
* Read n_bits from a bit-packed array starting at bit position bit_pos.
|
||||
* Bits are packed LSB-first within each byte.
|
||||
*/
|
||||
static uint32_t read_bits(const uint8_t *data, uint32_t bit_pos, uint8_t n_bits)
|
||||
{
|
||||
uint32_t value = 0;
|
||||
for (uint8_t i = 0; i < n_bits; i++) {
|
||||
uint32_t byte_idx = (bit_pos + i) / 8;
|
||||
uint8_t bit_idx = (bit_pos + i) % 8;
|
||||
if (data[byte_idx] & (1u << bit_idx)) {
|
||||
value |= (1u << i);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/* ─── Alignment helper ─── */
|
||||
|
||||
static inline uint32_t align4(uint32_t n)
|
||||
{
|
||||
return (n + 3u) & ~3u;
|
||||
}
|
||||
|
||||
/* ─── Init ─── */
|
||||
|
||||
int dict_mphf_init(struct dict_mphf *dict, const void *data, size_t len)
|
||||
{
|
||||
if (!dict || !data) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (len < sizeof(struct dict_mphf_header)) {
|
||||
return -2;
|
||||
}
|
||||
|
||||
const struct dict_mphf_header *hdr = (const struct dict_mphf_header *)data;
|
||||
|
||||
if (hdr->magic != DICT_MPHF_MAGIC) {
|
||||
return -3;
|
||||
}
|
||||
|
||||
if (hdr->version != DICT_MPHF_VERSION) {
|
||||
return -4;
|
||||
}
|
||||
|
||||
dict->header = hdr;
|
||||
|
||||
const uint8_t *base = (const uint8_t *)data;
|
||||
uint32_t offset = sizeof(struct dict_mphf_header);
|
||||
|
||||
/* Displacements section */
|
||||
dict->displacements = base + offset;
|
||||
uint32_t disp_bits_total = (uint32_t)hdr->bucket_count * hdr->disp_bits;
|
||||
dict->disp_section_len = align4((disp_bits_total + 7) / 8);
|
||||
offset += dict->disp_section_len;
|
||||
|
||||
/* Values section */
|
||||
dict->values = base + offset;
|
||||
uint32_t val_bits_total = (uint32_t)hdr->entry_count * hdr->value_bits;
|
||||
dict->val_section_len = align4((val_bits_total + 7) / 8);
|
||||
offset += dict->val_section_len;
|
||||
|
||||
/* Fingerprints section */
|
||||
dict->fingerprints = base + offset;
|
||||
dict->fp_section_len = align4(hdr->entry_count);
|
||||
offset += dict->fp_section_len;
|
||||
|
||||
/* String offsets section (u24 LE, 3 bytes each) */
|
||||
dict->string_offsets = base + offset;
|
||||
offset += hdr->unique_count * 3;
|
||||
|
||||
/* String data section */
|
||||
dict->string_data = (const char *)(base + offset);
|
||||
|
||||
/*
|
||||
* To find string_data length, scan for end of last string.
|
||||
* But we don't strictly need it for lookups — strings are
|
||||
* null-terminated and we just follow offsets.
|
||||
*
|
||||
* For prefix_table, we need to know where string_data ends.
|
||||
* Compute from total file size minus prefix table size.
|
||||
*/
|
||||
|
||||
/* Prefix table is at the end of the file */
|
||||
uint32_t prefix_bytes = (uint32_t)hdr->prefix_count * 4;
|
||||
if (len >= prefix_bytes) {
|
||||
dict->prefix_table = (const uint32_t *)(base + len - prefix_bytes);
|
||||
} else {
|
||||
dict->prefix_table = NULL;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ─── Lookup ─── */
|
||||
|
||||
const char *dict_mphf_lookup(const struct dict_mphf *dict,
|
||||
const uint32_t *strokes, uint8_t count)
|
||||
{
|
||||
if (!dict || !dict->header || !strokes || count == 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const struct dict_mphf_header *hdr = dict->header;
|
||||
|
||||
/* Build key bytes: each stroke as LE u32, concatenated */
|
||||
uint8_t key_buf[32]; /* max 8 strokes × 4 bytes */
|
||||
size_t key_len = (size_t)count * 4;
|
||||
if (key_len > sizeof(key_buf)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
key_buf[i * 4 + 0] = (uint8_t)(strokes[i]);
|
||||
key_buf[i * 4 + 1] = (uint8_t)(strokes[i] >> 8);
|
||||
key_buf[i * 4 + 2] = (uint8_t)(strokes[i] >> 16);
|
||||
key_buf[i * 4 + 3] = (uint8_t)(strokes[i] >> 24);
|
||||
}
|
||||
|
||||
/* MPHF lookup */
|
||||
uint32_t bucket = hash_key(key_buf, key_len, 0) % hdr->bucket_count;
|
||||
uint32_t d = read_bits(dict->displacements,
|
||||
bucket * (uint32_t)hdr->disp_bits,
|
||||
hdr->disp_bits);
|
||||
uint32_t slot = hash_key(key_buf, key_len, d + 1) % hdr->entry_count;
|
||||
|
||||
/* Fingerprint check */
|
||||
uint8_t expected_fp = (uint8_t)(fnv1a_32(key_buf, key_len) & 0xFF);
|
||||
if (dict->fingerprints[slot] != expected_fp) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Read value ID from bit-packed array */
|
||||
uint32_t val_id = read_bits(dict->values,
|
||||
slot * (uint32_t)hdr->value_bits,
|
||||
hdr->value_bits);
|
||||
if (val_id >= hdr->unique_count) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Resolve string (u24 LE offset) */
|
||||
const uint8_t *off_ptr = dict->string_offsets + val_id * 3;
|
||||
uint32_t str_offset = (uint32_t)off_ptr[0]
|
||||
| ((uint32_t)off_ptr[1] << 8)
|
||||
| ((uint32_t)off_ptr[2] << 16);
|
||||
return dict->string_data + str_offset;
|
||||
}
|
||||
|
||||
/* ─── has_prefix ─── */
|
||||
|
||||
bool dict_mphf_has_prefix(const struct dict_mphf *dict, uint32_t stroke)
|
||||
{
|
||||
if (!dict || !dict->header || !dict->prefix_table ||
|
||||
dict->header->prefix_count == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Binary search in sorted prefix table */
|
||||
uint32_t lo = 0;
|
||||
uint32_t hi = dict->header->prefix_count;
|
||||
|
||||
while (lo < hi) {
|
||||
uint32_t mid = lo + (hi - lo) / 2;
|
||||
uint32_t val = dict->prefix_table[mid];
|
||||
if (val == stroke) {
|
||||
return true;
|
||||
} else if (val < stroke) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
115
src/dict_mphf.h
Normal file
115
src/dict_mphf.h
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* MPHF (Minimal Perfect Hash Function) dictionary lookup engine.
|
||||
*
|
||||
* Binary format v2: CHD MPHF + bit-packed displacements/values +
|
||||
* fingerprinted verification + deduped string table.
|
||||
*
|
||||
* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
*/
|
||||
|
||||
#ifndef DICT_MPHF_H
|
||||
#define DICT_MPHF_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#define DICT_MPHF_MAGIC 0x4F4E5453 /* "STNO" */
|
||||
#define DICT_MPHF_VERSION 2
|
||||
|
||||
/**
|
||||
* On-flash header (32 bytes). All fields little-endian.
|
||||
*
|
||||
* Layout:
|
||||
* [0..3] magic u32
|
||||
* [4..5] version u16
|
||||
* [6..7] flags u16
|
||||
* [8..11] entry_count u32
|
||||
* [12..15] bucket_count u32
|
||||
* [16..19] unique_count u32
|
||||
* [20] value_bits u8
|
||||
* [21] disp_bits u8
|
||||
* [22..23] prefix_count u16
|
||||
* [24..27] reserved0 u32
|
||||
* [28..31] reserved1 u32
|
||||
*/
|
||||
struct dict_mphf_header {
|
||||
uint32_t magic;
|
||||
uint16_t version;
|
||||
uint16_t flags;
|
||||
uint32_t entry_count;
|
||||
uint32_t bucket_count;
|
||||
uint32_t unique_count;
|
||||
uint8_t value_bits;
|
||||
uint8_t disp_bits;
|
||||
uint16_t prefix_count;
|
||||
uint32_t reserved0;
|
||||
uint32_t reserved1;
|
||||
} __attribute__((packed));
|
||||
|
||||
_Static_assert(sizeof(struct dict_mphf_header) == 32, "header must be 32 bytes");
|
||||
|
||||
/**
|
||||
* Runtime dictionary handle. Points into flash-resident data.
|
||||
* All pointers are into the compiled binary blob — no heap allocation.
|
||||
*/
|
||||
struct dict_mphf {
|
||||
const struct dict_mphf_header *header;
|
||||
const uint8_t *displacements; /* bit-packed, bucket_count entries */
|
||||
const uint8_t *values; /* bit-packed, entry_count entries */
|
||||
const uint8_t *fingerprints; /* 1 byte per entry */
|
||||
const uint8_t *string_offsets; /* unique_count offsets (u24 LE) */
|
||||
const char *string_data; /* null-terminated UTF-8 strings */
|
||||
const uint32_t *prefix_table; /* sorted first-strokes, prefix_count entries */
|
||||
uint32_t disp_section_len;
|
||||
uint32_t val_section_len;
|
||||
uint32_t fp_section_len;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize dictionary handle from a compiled binary blob.
|
||||
*
|
||||
* @param dict Handle to initialize
|
||||
* @param data Pointer to compiled binary (typically flash-mapped)
|
||||
* @param len Length of binary in bytes
|
||||
* @return 0 on success, negative on error
|
||||
* -1: NULL pointer
|
||||
* -2: too short
|
||||
* -3: bad magic
|
||||
* -4: version mismatch
|
||||
*/
|
||||
int dict_mphf_init(struct dict_mphf *dict, const void *data, size_t len);
|
||||
|
||||
/**
|
||||
* Look up a stroke sequence in the dictionary.
|
||||
*
|
||||
* @param dict Initialized dictionary handle
|
||||
* @param strokes Array of stroke values (each is a 23-bit steno chord)
|
||||
* @param count Number of strokes in sequence
|
||||
* @return Pointer to null-terminated translation string in flash,
|
||||
* or NULL if not found / fingerprint mismatch
|
||||
*/
|
||||
const char *dict_mphf_lookup(const struct dict_mphf *dict,
|
||||
const uint32_t *strokes, uint8_t count);
|
||||
|
||||
/**
|
||||
* Check if a stroke could be the prefix of a multi-stroke entry.
|
||||
*
|
||||
* Binary search on sorted prefix table of first-strokes from
|
||||
* multi-stroke entries.
|
||||
*
|
||||
* @param dict Initialized dictionary handle
|
||||
* @param stroke Single stroke value to check
|
||||
* @return true if stroke appears as first stroke of any multi-stroke entry
|
||||
*/
|
||||
bool dict_mphf_has_prefix(const struct dict_mphf *dict, uint32_t stroke);
|
||||
|
||||
/**
|
||||
* Get number of entries in the dictionary.
|
||||
*/
|
||||
static inline uint32_t dict_mphf_count(const struct dict_mphf *dict)
|
||||
{
|
||||
return dict->header->entry_count;
|
||||
}
|
||||
|
||||
#endif /* DICT_MPHF_H */
|
||||
335
src/formatter.c
Normal file
335
src/formatter.c
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Afiq Zudin Hadi
|
||||
* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
*
|
||||
* Plover formatting engine — parses translation strings containing
|
||||
* {commands} and produces clean output with spacing/capitalization.
|
||||
* No malloc — all stack/static.
|
||||
*/
|
||||
|
||||
#include "formatter.h"
|
||||
#include <string.h>
|
||||
|
||||
/* ── helpers ────────────────────────────────────────────────────── */
|
||||
|
||||
static inline bool is_upper(char c) { return c >= 'A' && c <= 'Z'; }
|
||||
static inline bool is_lower(char c) { return c >= 'a' && c <= 'z'; }
|
||||
static inline char to_upper(char c) { return is_lower(c) ? (char)(c - 32) : c; }
|
||||
static inline char to_lower(char c) { return is_upper(c) ? (char)(c + 32) : c; }
|
||||
|
||||
static bool str_eq(const char *a, const char *b, size_t n)
|
||||
{
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool starts_with(const char *s, size_t slen, const char *prefix)
|
||||
{
|
||||
size_t plen = strlen(prefix);
|
||||
if (slen < plen) return false;
|
||||
return str_eq(s, prefix, plen);
|
||||
}
|
||||
|
||||
/* ── output buffer append ───────────────────────────────────────── */
|
||||
|
||||
static void emit_char(struct steno_fmt_result *r, char c)
|
||||
{
|
||||
if (r->len < STENO_FMT_MAX_OUTPUT - 1) {
|
||||
r->text[r->len++] = c;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── apply capitalization/mode transforms to a text segment ───── */
|
||||
|
||||
static void emit_text_transformed(struct steno_fmt_state *state,
|
||||
struct steno_fmt_result *r,
|
||||
const char *text, size_t len)
|
||||
{
|
||||
if (len == 0) return;
|
||||
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
char c = text[i];
|
||||
|
||||
/* Mode transforms */
|
||||
if (state->mode == STENO_MODE_CAPS) {
|
||||
c = to_upper(c);
|
||||
} else if (state->mode == STENO_MODE_LOWER) {
|
||||
c = to_lower(c);
|
||||
} else if (state->mode == STENO_MODE_TITLE) {
|
||||
/* Capitalize first letter of each word */
|
||||
if (i == 0 || (i > 0 && text[i - 1] == ' ')) {
|
||||
c = to_upper(c);
|
||||
}
|
||||
}
|
||||
|
||||
/* One-shot transforms (first char only) */
|
||||
if (i == 0) {
|
||||
if (state->cap_next) {
|
||||
c = to_upper(c);
|
||||
state->cap_next = false;
|
||||
}
|
||||
if (state->upper_next) {
|
||||
/* uppercase entire word — handled below */
|
||||
}
|
||||
if (state->lower_next) {
|
||||
/* lowercase entire word — handled below */
|
||||
}
|
||||
}
|
||||
|
||||
/* upper_next: entire word */
|
||||
if (state->upper_next) {
|
||||
c = to_upper(c);
|
||||
}
|
||||
|
||||
/* lower_next: entire word */
|
||||
if (state->lower_next) {
|
||||
c = to_lower(c);
|
||||
}
|
||||
|
||||
emit_char(r, c);
|
||||
}
|
||||
|
||||
state->upper_next = false;
|
||||
state->lower_next = false;
|
||||
}
|
||||
|
||||
/* ── prepend space if needed ────────────────────────────────────── */
|
||||
|
||||
static void maybe_space(struct steno_fmt_state *state,
|
||||
struct steno_fmt_result *r)
|
||||
{
|
||||
if (state->space_pending && !state->suppress_space) {
|
||||
emit_char(r, ' ');
|
||||
}
|
||||
state->suppress_space = false;
|
||||
}
|
||||
|
||||
/* ── process a single {…} command ──────────────────────────────── */
|
||||
|
||||
static void process_command(struct steno_fmt_state *state,
|
||||
struct steno_fmt_result *r,
|
||||
const char *cmd, size_t cmd_len)
|
||||
{
|
||||
/* {^} — suppress space */
|
||||
if (cmd_len == 1 && cmd[0] == '^') {
|
||||
state->suppress_space = true;
|
||||
return;
|
||||
}
|
||||
|
||||
/* {^suffix} — attach suffix */
|
||||
if (cmd_len > 1 && cmd[0] == '^') {
|
||||
state->suppress_space = true;
|
||||
maybe_space(state, r);
|
||||
emit_text_transformed(state, r, cmd + 1, cmd_len - 1);
|
||||
state->space_pending = true;
|
||||
state->glue = false;
|
||||
return;
|
||||
}
|
||||
|
||||
/* {prefix^} — attach prefix */
|
||||
if (cmd_len > 1 && cmd[cmd_len - 1] == '^') {
|
||||
maybe_space(state, r);
|
||||
emit_text_transformed(state, r, cmd, cmd_len - 1);
|
||||
state->suppress_space = true;
|
||||
state->space_pending = false;
|
||||
state->glue = false;
|
||||
return;
|
||||
}
|
||||
|
||||
/* {-|} — capitalize next */
|
||||
if (cmd_len == 2 && cmd[0] == '-' && cmd[1] == '|') {
|
||||
state->cap_next = true;
|
||||
return;
|
||||
}
|
||||
|
||||
/* {~|} — carry capitalize */
|
||||
if (cmd_len == 2 && cmd[0] == '~' && cmd[1] == '|') {
|
||||
state->cap_next = true;
|
||||
return;
|
||||
}
|
||||
|
||||
/* {*-|} — retro capitalize (backspace + re-emit) */
|
||||
if (cmd_len == 3 && cmd[0] == '*' && cmd[1] == '-' && cmd[2] == '|') {
|
||||
/* Signal retro-capitalize — simplified: set flag, caller handles */
|
||||
r->backspaces = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
/* {*!} — retro delete space */
|
||||
if (cmd_len == 2 && cmd[0] == '*' && cmd[1] == '!') {
|
||||
r->backspaces = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
/* {*?} — retro insert space */
|
||||
if (cmd_len == 2 && cmd[0] == '*' && cmd[1] == '?') {
|
||||
/* Would need undo context; simplified signal */
|
||||
return;
|
||||
}
|
||||
|
||||
/* {*} — undo */
|
||||
if (cmd_len == 1 && cmd[0] == '*') {
|
||||
r->is_undo = true;
|
||||
r->is_command_only = true;
|
||||
return;
|
||||
}
|
||||
|
||||
/* {.} {,} {?} {!} {;} {:} — punctuation: attach to prev, cap next for sentence-enders */
|
||||
if (cmd_len == 1 && (cmd[0] == '.' || cmd[0] == ',' ||
|
||||
cmd[0] == '?' || cmd[0] == '!' ||
|
||||
cmd[0] == ';' || cmd[0] == ':')) {
|
||||
state->suppress_space = true;
|
||||
maybe_space(state, r);
|
||||
emit_char(r, cmd[0]);
|
||||
state->space_pending = true;
|
||||
state->suppress_space = false;
|
||||
/* Sentence-ending punctuation capitalizes next */
|
||||
if (cmd[0] == '.' || cmd[0] == '?' || cmd[0] == '!') {
|
||||
state->cap_next = true;
|
||||
}
|
||||
state->glue = false;
|
||||
return;
|
||||
}
|
||||
|
||||
/* {#...} — key combo (emit nothing for now, could extend) */
|
||||
if (cmd_len > 0 && cmd[0] == '#') {
|
||||
r->is_command_only = true;
|
||||
return;
|
||||
}
|
||||
|
||||
/* {&letter} — fingerspelling */
|
||||
if (cmd_len >= 2 && cmd[0] == '&') {
|
||||
/* Glue: no space between glue strokes */
|
||||
if (state->glue) {
|
||||
state->suppress_space = true;
|
||||
}
|
||||
maybe_space(state, r);
|
||||
emit_text_transformed(state, r, cmd + 1, cmd_len - 1);
|
||||
state->space_pending = true;
|
||||
state->glue = true;
|
||||
state->suppress_space = false;
|
||||
return;
|
||||
}
|
||||
|
||||
/* {&} — bare glue */
|
||||
if (cmd_len == 1 && cmd[0] == '&') {
|
||||
state->glue = true;
|
||||
state->suppress_space = true;
|
||||
return;
|
||||
}
|
||||
|
||||
/* {MODE:CAPS} {MODE:TITLE} {MODE:LOWER} {MODE:RESET} */
|
||||
if (starts_with(cmd, cmd_len, "MODE:")) {
|
||||
const char *mode_str = cmd + 5;
|
||||
size_t mode_len = cmd_len - 5;
|
||||
if (mode_len == 4 && str_eq(mode_str, "CAPS", 4)) {
|
||||
state->mode = STENO_MODE_CAPS;
|
||||
} else if (mode_len == 5 && str_eq(mode_str, "TITLE", 5)) {
|
||||
state->mode = STENO_MODE_TITLE;
|
||||
} else if (mode_len == 5 && str_eq(mode_str, "LOWER", 5)) {
|
||||
state->mode = STENO_MODE_LOWER;
|
||||
} else if (mode_len == 5 && str_eq(mode_str, "RESET", 5)) {
|
||||
state->mode = STENO_MODE_NORMAL;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* {<} — uppercase next word */
|
||||
if (cmd_len == 1 && cmd[0] == '<') {
|
||||
state->upper_next = true;
|
||||
return;
|
||||
}
|
||||
|
||||
/* {>} — lowercase next word */
|
||||
if (cmd_len == 1 && cmd[0] == '>') {
|
||||
state->lower_next = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── main entry point ──────────────────────────────────────────── */
|
||||
|
||||
void steno_fmt_init(struct steno_fmt_state *state)
|
||||
{
|
||||
memset(state, 0, sizeof(*state));
|
||||
state->space_pending = false;
|
||||
state->cap_next = false;
|
||||
state->upper_next = false;
|
||||
state->lower_next = false;
|
||||
state->suppress_space = false;
|
||||
state->glue = false;
|
||||
state->mode = STENO_MODE_NORMAL;
|
||||
}
|
||||
|
||||
void steno_fmt_process(struct steno_fmt_state *state,
|
||||
const char *translation,
|
||||
struct steno_fmt_result *result)
|
||||
{
|
||||
memset(result, 0, sizeof(*result));
|
||||
|
||||
if (!translation || !translation[0]) {
|
||||
result->is_command_only = true;
|
||||
return;
|
||||
}
|
||||
|
||||
size_t tlen = strlen(translation);
|
||||
const char *p = translation;
|
||||
const char *end = translation + tlen;
|
||||
bool emitted_text = false;
|
||||
bool had_command = false;
|
||||
bool this_glue = false;
|
||||
|
||||
while (p < end) {
|
||||
/* Escaped braces: \{ \} */
|
||||
if (p[0] == '\\' && p + 1 < end && (p[1] == '{' || p[1] == '}')) {
|
||||
maybe_space(state, result);
|
||||
emit_text_transformed(state, result, p + 1, 1);
|
||||
state->space_pending = true;
|
||||
state->glue = false;
|
||||
emitted_text = true;
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Command: {…} */
|
||||
if (p[0] == '{') {
|
||||
const char *close = p + 1;
|
||||
while (close < end && *close != '}') {
|
||||
close++;
|
||||
}
|
||||
if (close < end) {
|
||||
const char *cmd = p + 1;
|
||||
size_t cmd_len = (size_t)(close - cmd);
|
||||
process_command(state, result, cmd, cmd_len);
|
||||
had_command = true;
|
||||
if (state->glue) this_glue = true;
|
||||
p = close + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
/* Literal text segment — find end (next '{' or '\' or end) */
|
||||
const char *seg_start = p;
|
||||
while (p < end && p[0] != '{' &&
|
||||
!(p[0] == '\\' && p + 1 < end && (p[1] == '{' || p[1] == '}'))) {
|
||||
p++;
|
||||
}
|
||||
size_t seg_len = (size_t)(p - seg_start);
|
||||
if (seg_len > 0) {
|
||||
if (!this_glue) {
|
||||
maybe_space(state, result);
|
||||
}
|
||||
emit_text_transformed(state, result, seg_start, seg_len);
|
||||
state->space_pending = true;
|
||||
if (!this_glue) {
|
||||
state->glue = false;
|
||||
}
|
||||
emitted_text = true;
|
||||
}
|
||||
}
|
||||
|
||||
result->text[result->len] = '\0';
|
||||
result->is_command_only = !emitted_text && had_command && result->len == 0;
|
||||
}
|
||||
46
src/formatter.h
Normal file
46
src/formatter.h
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
#ifndef STENO_FORMATTER_H
|
||||
#define STENO_FORMATTER_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/* Max output length from a single translation */
|
||||
#define STENO_FMT_MAX_OUTPUT 128
|
||||
|
||||
/* Formatting mode */
|
||||
enum steno_fmt_mode {
|
||||
STENO_MODE_NORMAL = 0,
|
||||
STENO_MODE_CAPS,
|
||||
STENO_MODE_TITLE,
|
||||
STENO_MODE_LOWER,
|
||||
};
|
||||
|
||||
/* State persists between translations */
|
||||
struct steno_fmt_state {
|
||||
bool space_pending; /* insert space before next word */
|
||||
bool cap_next; /* capitalize next word */
|
||||
bool upper_next; /* uppercase next word */
|
||||
bool lower_next; /* lowercase next word */
|
||||
bool suppress_space; /* {^} suppress next space */
|
||||
bool glue; /* in fingerspelling/glue sequence */
|
||||
enum steno_fmt_mode mode;
|
||||
};
|
||||
|
||||
/* Result of formatting one translation */
|
||||
struct steno_fmt_result {
|
||||
char text[STENO_FMT_MAX_OUTPUT]; /* output text to emit */
|
||||
uint8_t len; /* length of text */
|
||||
uint8_t backspaces; /* backspaces to send BEFORE text */
|
||||
bool is_command_only; /* true if no text output (pure command) */
|
||||
bool is_undo; /* true if this is an undo command */
|
||||
};
|
||||
|
||||
void steno_fmt_init(struct steno_fmt_state *state);
|
||||
|
||||
/* Format a raw translation string. Updates state, fills result. */
|
||||
void steno_fmt_process(struct steno_fmt_state *state,
|
||||
const char *translation,
|
||||
struct steno_fmt_result *result);
|
||||
|
||||
#endif
|
||||
167
src/output.c
Normal file
167
src/output.c
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
#include "output.h"
|
||||
|
||||
#include <zephyr/kernel.h>
|
||||
#include <zephyr/logging/log.h>
|
||||
|
||||
#include <zmk/hid.h>
|
||||
#include <zmk/endpoints.h>
|
||||
#include <zmk/events/keycode_state_changed.h>
|
||||
|
||||
LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL);
|
||||
|
||||
struct hid_map {
|
||||
uint8_t keycode;
|
||||
bool shift;
|
||||
};
|
||||
|
||||
static const struct hid_map ASCII_TO_HID[128] = {
|
||||
[' '] = {0x2C, false}, /* space */
|
||||
['!'] = {0x1E, true}, /* shift+1 */
|
||||
['"'] = {0x34, true}, /* shift+' */
|
||||
['#'] = {0x20, true},
|
||||
['$'] = {0x21, true},
|
||||
['%'] = {0x22, true},
|
||||
['&'] = {0x24, true},
|
||||
['\''] = {0x34, false},
|
||||
['('] = {0x26, true},
|
||||
[')'] = {0x27, true},
|
||||
['*'] = {0x25, true},
|
||||
['+'] = {0x2E, true},
|
||||
[','] = {0x36, false},
|
||||
['-'] = {0x2D, false},
|
||||
['.'] = {0x37, false},
|
||||
['/'] = {0x38, false},
|
||||
['0'] = {0x27, false},
|
||||
['1'] = {0x1E, false},
|
||||
['2'] = {0x1F, false},
|
||||
['3'] = {0x20, false},
|
||||
['4'] = {0x21, false},
|
||||
['5'] = {0x22, false},
|
||||
['6'] = {0x23, false},
|
||||
['7'] = {0x24, false},
|
||||
['8'] = {0x25, false},
|
||||
['9'] = {0x26, false},
|
||||
[':'] = {0x33, true},
|
||||
[';'] = {0x33, false},
|
||||
['<'] = {0x36, true},
|
||||
['='] = {0x2E, false},
|
||||
['>'] = {0x37, true},
|
||||
['?'] = {0x38, true},
|
||||
['@'] = {0x1F, true},
|
||||
['A'] = {0x04, true},
|
||||
['B'] = {0x05, true},
|
||||
['C'] = {0x06, true},
|
||||
['D'] = {0x07, true},
|
||||
['E'] = {0x08, true},
|
||||
['F'] = {0x09, true},
|
||||
['G'] = {0x0A, true},
|
||||
['H'] = {0x0B, true},
|
||||
['I'] = {0x0C, true},
|
||||
['J'] = {0x0D, true},
|
||||
['K'] = {0x0E, true},
|
||||
['L'] = {0x0F, true},
|
||||
['M'] = {0x10, true},
|
||||
['N'] = {0x11, true},
|
||||
['O'] = {0x12, true},
|
||||
['P'] = {0x13, true},
|
||||
['Q'] = {0x14, true},
|
||||
['R'] = {0x15, true},
|
||||
['S'] = {0x16, true},
|
||||
['T'] = {0x17, true},
|
||||
['U'] = {0x18, true},
|
||||
['V'] = {0x19, true},
|
||||
['W'] = {0x1A, true},
|
||||
['X'] = {0x1B, true},
|
||||
['Y'] = {0x1C, true},
|
||||
['Z'] = {0x1D, true},
|
||||
['['] = {0x2F, false},
|
||||
['\\'] = {0x31, false},
|
||||
[']'] = {0x30, false},
|
||||
['^'] = {0x23, true},
|
||||
['_'] = {0x2D, true},
|
||||
['`'] = {0x35, false},
|
||||
['a'] = {0x04, false},
|
||||
['b'] = {0x05, false},
|
||||
['c'] = {0x06, false},
|
||||
['d'] = {0x07, false},
|
||||
['e'] = {0x08, false},
|
||||
['f'] = {0x09, false},
|
||||
['g'] = {0x0A, false},
|
||||
['h'] = {0x0B, false},
|
||||
['i'] = {0x0C, false},
|
||||
['j'] = {0x0D, false},
|
||||
['k'] = {0x0E, false},
|
||||
['l'] = {0x0F, false},
|
||||
['m'] = {0x10, false},
|
||||
['n'] = {0x11, false},
|
||||
['o'] = {0x12, false},
|
||||
['p'] = {0x13, false},
|
||||
['q'] = {0x14, false},
|
||||
['r'] = {0x15, false},
|
||||
['s'] = {0x16, false},
|
||||
['t'] = {0x17, false},
|
||||
['u'] = {0x18, false},
|
||||
['v'] = {0x19, false},
|
||||
['w'] = {0x1A, false},
|
||||
['x'] = {0x1B, false},
|
||||
['y'] = {0x1C, false},
|
||||
['z'] = {0x1D, false},
|
||||
['{'] = {0x2F, true},
|
||||
['|'] = {0x31, true},
|
||||
['}'] = {0x30, true},
|
||||
['~'] = {0x35, true},
|
||||
};
|
||||
|
||||
#define HID_BACKSPACE 0x2A
|
||||
#define HID_RETURN 0x28
|
||||
#define HID_LSHIFT 0xE1
|
||||
|
||||
static void tap_key(uint8_t keycode, bool shift)
|
||||
{
|
||||
if (shift) {
|
||||
raise_zmk_keycode_state_changed((struct zmk_keycode_state_changed){
|
||||
.usage_page = 0x07, .keycode = HID_LSHIFT,
|
||||
.implicit_modifiers = 0, .explicit_modifiers = 0,
|
||||
.state = true, .timestamp = k_uptime_get()});
|
||||
}
|
||||
|
||||
raise_zmk_keycode_state_changed((struct zmk_keycode_state_changed){
|
||||
.usage_page = 0x07, .keycode = keycode,
|
||||
.implicit_modifiers = 0, .explicit_modifiers = 0,
|
||||
.state = true, .timestamp = k_uptime_get()});
|
||||
|
||||
raise_zmk_keycode_state_changed((struct zmk_keycode_state_changed){
|
||||
.usage_page = 0x07, .keycode = keycode,
|
||||
.implicit_modifiers = 0, .explicit_modifiers = 0,
|
||||
.state = false, .timestamp = k_uptime_get()});
|
||||
|
||||
if (shift) {
|
||||
raise_zmk_keycode_state_changed((struct zmk_keycode_state_changed){
|
||||
.usage_page = 0x07, .keycode = HID_LSHIFT,
|
||||
.implicit_modifiers = 0, .explicit_modifiers = 0,
|
||||
.state = false, .timestamp = k_uptime_get()});
|
||||
}
|
||||
}
|
||||
|
||||
void steno_output_send(const char *text, size_t len)
|
||||
{
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
unsigned char c = (unsigned char)text[i];
|
||||
if (c == '\n') {
|
||||
tap_key(HID_RETURN, false);
|
||||
continue;
|
||||
}
|
||||
if (c >= 128 || ASCII_TO_HID[c].keycode == 0) {
|
||||
LOG_WRN("Skipping non-ASCII char 0x%02X", c);
|
||||
continue;
|
||||
}
|
||||
tap_key(ASCII_TO_HID[c].keycode, ASCII_TO_HID[c].shift);
|
||||
}
|
||||
}
|
||||
|
||||
void steno_output_backspace(int count)
|
||||
{
|
||||
for (int i = 0; i < count; i++) {
|
||||
tap_key(HID_BACKSPACE, false);
|
||||
}
|
||||
}
|
||||
11
src/output.h
Normal file
11
src/output.h
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
#ifndef STENO_OUTPUT_H
|
||||
#define STENO_OUTPUT_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
void steno_output_send(const char *text, size_t len);
|
||||
|
||||
void steno_output_backspace(int count);
|
||||
|
||||
#endif
|
||||
167
src/split_cache.c
Normal file
167
src/split_cache.c
Normal 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
46
src/split_cache.h
Normal 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
465
src/split_dict.c
Normal 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
99
src/split_dict.h
Normal 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 */
|
||||
148
src/trie.c
Normal file
148
src/trie.c
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
#include "trie.h"
|
||||
#include <string.h>
|
||||
|
||||
static const uint8_t *dict_data;
|
||||
static const struct steno_dict_header *dict_hdr;
|
||||
static const uint8_t *entry_array;
|
||||
static const char *string_table;
|
||||
static size_t entry_stride;
|
||||
|
||||
int steno_trie_init(const uint8_t *data, size_t len)
|
||||
{
|
||||
if (!data || len < sizeof(struct steno_dict_header)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
dict_hdr = (const struct steno_dict_header *)data;
|
||||
|
||||
if (dict_hdr->magic != STENO_DICT_MAGIC) {
|
||||
return -2;
|
||||
}
|
||||
if (dict_hdr->version != 1) {
|
||||
return -3;
|
||||
}
|
||||
if (dict_hdr->max_strokes == 0 || dict_hdr->max_strokes > 16) {
|
||||
return -4;
|
||||
}
|
||||
|
||||
entry_stride = (size_t)dict_hdr->max_strokes * 4 + 4;
|
||||
size_t entries_end = sizeof(struct steno_dict_header) +
|
||||
(size_t)dict_hdr->entry_count * entry_stride;
|
||||
|
||||
if (entries_end > len || dict_hdr->strings_offset > len) {
|
||||
return -5;
|
||||
}
|
||||
|
||||
dict_data = data;
|
||||
entry_array = data + sizeof(struct steno_dict_header);
|
||||
string_table = (const char *)(data + dict_hdr->strings_offset);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static const uint8_t *get_entry(uint32_t idx)
|
||||
{
|
||||
return entry_array + (size_t)idx * entry_stride;
|
||||
}
|
||||
|
||||
static int cmp_strokes(const uint32_t *a, uint8_t a_count,
|
||||
const uint8_t *entry_bytes, uint8_t max_s)
|
||||
{
|
||||
const uint32_t *b = (const uint32_t *)entry_bytes;
|
||||
|
||||
uint8_t b_count = 0;
|
||||
for (uint8_t i = 0; i < max_s; i++) {
|
||||
if (b[i] != 0) {
|
||||
b_count = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t min_count = a_count < b_count ? a_count : b_count;
|
||||
for (uint8_t i = 0; i < min_count; i++) {
|
||||
if (a[i] < b[i]) return -1;
|
||||
if (a[i] > b[i]) return 1;
|
||||
}
|
||||
if (a_count < b_count) return -1;
|
||||
if (a_count > b_count) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char *steno_trie_lookup(const uint32_t *strokes, uint8_t count)
|
||||
{
|
||||
if (!dict_hdr || !strokes || count == 0 || count > dict_hdr->max_strokes) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
uint32_t lo = 0;
|
||||
uint32_t hi = dict_hdr->entry_count;
|
||||
|
||||
while (lo < hi) {
|
||||
uint32_t mid = lo + (hi - lo) / 2;
|
||||
const uint8_t *entry = get_entry(mid);
|
||||
int c = cmp_strokes(strokes, count, entry, dict_hdr->max_strokes);
|
||||
if (c == 0) {
|
||||
uint32_t str_off;
|
||||
memcpy(&str_off, entry + (size_t)dict_hdr->max_strokes * 4, 4);
|
||||
return string_table + str_off;
|
||||
}
|
||||
if (c < 0) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
bool steno_trie_has_prefix(const uint32_t *strokes, uint8_t count)
|
||||
{
|
||||
if (!dict_hdr || !strokes || count == 0 || count >= dict_hdr->max_strokes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t lo = 0;
|
||||
uint32_t hi = dict_hdr->entry_count;
|
||||
uint32_t first_ge = hi;
|
||||
|
||||
while (lo < hi) {
|
||||
uint32_t mid = lo + (hi - lo) / 2;
|
||||
const uint8_t *entry = get_entry(mid);
|
||||
const uint32_t *e_strokes = (const uint32_t *)entry;
|
||||
|
||||
int cmp = 0;
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
if (e_strokes[i] < strokes[i]) { cmp = -1; break; }
|
||||
if (e_strokes[i] > strokes[i]) { cmp = 1; break; }
|
||||
}
|
||||
|
||||
if (cmp >= 0) {
|
||||
first_ge = mid;
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (uint32_t idx = first_ge; idx < dict_hdr->entry_count; idx++) {
|
||||
const uint8_t *entry = get_entry(idx);
|
||||
const uint32_t *e_strokes = (const uint32_t *)entry;
|
||||
|
||||
bool prefix_match = true;
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
if (e_strokes[i] != strokes[i]) {
|
||||
prefix_match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!prefix_match) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e_strokes[count] != 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
25
src/trie.h
Normal file
25
src/trie.h
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
#ifndef STENO_TRIE_H
|
||||
#define STENO_TRIE_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define STENO_DICT_MAGIC 0x4F4E5453 /* "STNO" */
|
||||
|
||||
struct steno_dict_header {
|
||||
uint32_t magic;
|
||||
uint16_t version;
|
||||
uint8_t max_strokes;
|
||||
uint8_t _pad;
|
||||
uint32_t entry_count;
|
||||
uint32_t strings_offset;
|
||||
} __attribute__((packed));
|
||||
|
||||
int steno_trie_init(const uint8_t *data, size_t len);
|
||||
|
||||
const char *steno_trie_lookup(const uint32_t *strokes, uint8_t count);
|
||||
|
||||
bool steno_trie_has_prefix(const uint32_t *strokes, uint8_t count);
|
||||
|
||||
#endif
|
||||
64
src/undo.c
Normal file
64
src/undo.c
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Afiq Zudin Hadi
|
||||
* SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
*/
|
||||
|
||||
#include "undo.h"
|
||||
#include <string.h>
|
||||
|
||||
void steno_undo_init(struct stroke_history *hist)
|
||||
{
|
||||
memset(hist, 0, sizeof(*hist));
|
||||
}
|
||||
|
||||
void steno_undo_push(struct stroke_history *hist,
|
||||
const uint32_t *strokes, uint8_t stroke_count,
|
||||
uint8_t output_len, uint8_t space_before,
|
||||
uint8_t fmt_flags)
|
||||
{
|
||||
struct stroke_history_entry *e = &hist->entries[hist->head];
|
||||
|
||||
uint8_t n = stroke_count;
|
||||
if (n > STENO_MAX_MULTI_STROKE) {
|
||||
n = STENO_MAX_MULTI_STROKE;
|
||||
}
|
||||
|
||||
memcpy(e->strokes, strokes, n * sizeof(uint32_t));
|
||||
e->stroke_count = n;
|
||||
e->output_len = output_len;
|
||||
e->space_before = space_before;
|
||||
e->fmt_flags = fmt_flags;
|
||||
|
||||
hist->head = (hist->head + 1) % CONFIG_STENO_HISTORY_SIZE;
|
||||
|
||||
if (hist->count < CONFIG_STENO_HISTORY_SIZE) {
|
||||
hist->count++;
|
||||
}
|
||||
}
|
||||
|
||||
struct stroke_history_entry *steno_undo_pop(struct stroke_history *hist)
|
||||
{
|
||||
if (hist->count == 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
hist->head = (hist->head + CONFIG_STENO_HISTORY_SIZE - 1) % CONFIG_STENO_HISTORY_SIZE;
|
||||
hist->count--;
|
||||
|
||||
return &hist->entries[hist->head];
|
||||
}
|
||||
|
||||
const struct stroke_history_entry *steno_undo_peek(const struct stroke_history *hist)
|
||||
{
|
||||
if (hist->count == 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
uint16_t idx = (hist->head + CONFIG_STENO_HISTORY_SIZE - 1) % CONFIG_STENO_HISTORY_SIZE;
|
||||
return &hist->entries[idx];
|
||||
}
|
||||
|
||||
uint16_t steno_undo_count(const struct stroke_history *hist)
|
||||
{
|
||||
return hist->count;
|
||||
}
|
||||
44
src/undo.h
Normal file
44
src/undo.h
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
#ifndef STENO_UNDO_H
|
||||
#define STENO_UNDO_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifndef CONFIG_STENO_HISTORY_SIZE
|
||||
#define CONFIG_STENO_HISTORY_SIZE 100
|
||||
#endif
|
||||
|
||||
#define STENO_MAX_MULTI_STROKE 8
|
||||
|
||||
struct stroke_history_entry {
|
||||
uint32_t strokes[STENO_MAX_MULTI_STROKE];
|
||||
uint8_t stroke_count;
|
||||
uint8_t output_len; /* chars emitted (for backspace count) */
|
||||
uint8_t space_before; /* 1 if space was prepended */
|
||||
uint8_t fmt_flags; /* formatter state snapshot for restore */
|
||||
};
|
||||
|
||||
struct stroke_history {
|
||||
struct stroke_history_entry entries[CONFIG_STENO_HISTORY_SIZE];
|
||||
uint16_t head; /* next write position */
|
||||
uint16_t count; /* entries in buffer */
|
||||
};
|
||||
|
||||
void steno_undo_init(struct stroke_history *hist);
|
||||
|
||||
/* Push a new entry after successful output */
|
||||
void steno_undo_push(struct stroke_history *hist,
|
||||
const uint32_t *strokes, uint8_t stroke_count,
|
||||
uint8_t output_len, uint8_t space_before,
|
||||
uint8_t fmt_flags);
|
||||
|
||||
/* Pop most recent entry for undo. Returns NULL if empty. */
|
||||
struct stroke_history_entry *steno_undo_pop(struct stroke_history *hist);
|
||||
|
||||
/* Peek at most recent entry without removing. Returns NULL if empty. */
|
||||
const struct stroke_history_entry *steno_undo_peek(const struct stroke_history *hist);
|
||||
|
||||
/* Get current count */
|
||||
uint16_t steno_undo_count(const struct stroke_history *hist);
|
||||
|
||||
#endif
|
||||
Loading…
Add table
Add a link
Reference in a new issue