feat: Add layout file parsing to x52d

This change is an initial step to adding support for profiles in x52d.
This will allow the user to create a keyboard layout in an easy to
read/write text based format, and have it compiled into a flat layout
that's easy for the daemon to parse and load into memory. This layout
can then be used to map a user's action key to the actual input usage
needed. This is necessary, because keyboards don't actually send the
character that is typed, but just the position on the keyboard. For
example, on a French AZERTY keyboard, the A key would actually send the
usage for `Keyboard_q_and_Q`. The OS would translate that into the
letter 'a' (or 'A' if Shift key is held) and pass that to the active
window.

This commit adds the full logic necessary for the layout loading,
validation and compiling, as well as tests for the compiler and loader.
master
nirenjan 2026-04-04 23:16:51 -07:00
parent f52e328a8b
commit 75f0125f54
29 changed files with 2766 additions and 66 deletions

4
.gitignore vendored
View File

@ -29,6 +29,10 @@ Module.symvers
# Vim swap files
.*.swp
# Python
__pycache__/
*.py[cod]
# Autotools objects
.deps
.dirstamp

View File

@ -32,6 +32,9 @@
/* Define to the location of the local state directory */
#mesondefine LOCALSTATEDIR
/* Define to the installation data directory (e.g. $prefix/share) */
#mesondefine DATADIR
/* Define to the location of the log directory */
#define LOGDIR LOCALSTATEDIR "/log"

View File

@ -277,6 +277,7 @@ static void cmd_logging(char *buffer, int *buflen, int argc, char **argv)
[X52D_MOD_COMMAND] = "command",
[X52D_MOD_CLIENT] = "client",
[X52D_MOD_NOTIFY] = "notify",
[X52D_MOD_KEYBOARD_LAYOUT] = "keyboard_layout",
};
// This corresponds to the levels in pinelog

View File

@ -105,4 +105,8 @@ CFG(Profiles, ClutchEnabled, clutch_enabled, bool, false)
// be held down to remain in clutch mode.
CFG(Profiles, ClutchLatched, clutch_latched, bool, false)
// KeyboardLayout is the default keyboard layout used when mapping
// profile keys to keyboard events.
CFG(Profiles, KeyboardLayout, profile_keyboard_layout, string, us)
#undef CFG

View File

@ -49,6 +49,8 @@ struct x52d_config {
bool clutch_latched;
char profiles_dir[NAME_MAX];
char profile_keyboard_layout[NAME_MAX];
};
/* Callback functions for configuration */
@ -84,6 +86,7 @@ void x52d_cfg_set_Mouse_Deadzone(int param);
void x52d_cfg_set_Profiles_Directory(char* param);
void x52d_cfg_set_Profiles_ClutchEnabled(bool param);
void x52d_cfg_set_Profiles_ClutchLatched(bool param);
void x52d_cfg_set_Profiles_KeyboardLayout(char *param);
int x52d_config_process_kv(void *user, const char *section, const char *key, const char *value);
const char *x52d_config_get_param(struct x52d_config *cfg, const char *section, const char *key);

View File

@ -36,6 +36,7 @@ enum {
X52D_MOD_COMMAND,
X52D_MOD_CLIENT,
X52D_MOD_NOTIFY,
X52D_MOD_KEYBOARD_LAYOUT,
X52D_MOD_MAX
};

75
daemon/crc32.c 100644
View File

@ -0,0 +1,75 @@
/*
* Saitek X52 Pro MFD & LED driver - CRC-32 (zlib / Python zlib.crc32 compatible)
*
* Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
*
* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
*/
#include <daemon/crc32.h>
#include <stdint.h>
/* Table matches zlib's crc_table (reflect, poly 0xEDB88320). */
static const uint32_t x52_crc32_table[256] = {
0x00000000u, 0x77073096u, 0xee0e612cu, 0x990951bau, 0x076dc419u, 0x706af48fu, 0xe963a535u,
0x9e6495a3u, 0x0edb8832u, 0x79dcb8a4u, 0xe0d5e91eu, 0x97d2d988u, 0x09b64c2bu, 0x7eb17cbdu,
0xe7b82d07u, 0x90bf1d91u, 0x1db71064u, 0x6ab020f2u, 0xf3b97148u, 0x84be41deu, 0x1adad47du,
0x6ddde4ebu, 0xf4d4b551u, 0x83d385c7u, 0x136c9856u, 0x646ba8c0u, 0xfd62f97au, 0x8a65c9ecu,
0x14015c4fu, 0x63066cd9u, 0xfa0f3d63u, 0x8d080df5u, 0x3b6e20c8u, 0x4c69105eu, 0xd56041e4u,
0xa2677172u, 0x3c03e4d1u, 0x4b04d447u, 0xd20d85fdu, 0xa50ab56bu, 0x35b5a8fau, 0x42b2986cu,
0xdbbbc9d6u, 0xacbcf940u, 0x32d86ce3u, 0x45df5c75u, 0xdcd60dcfu, 0xabd13d59u, 0x26d930acu,
0x51de003au, 0xc8d75180u, 0xbfd06116u, 0x21b4f4b5u, 0x56b3c423u, 0xcfba9599u, 0xb8bda50fu,
0x2802b89eu, 0x5f058808u, 0xc60cd9b2u, 0xb10be924u, 0x2f6f7c87u, 0x58684c11u, 0xc1611dabu,
0xb6662d3du, 0x76dc4190u, 0x01db7106u, 0x98d220bcu, 0xefd5102au, 0x71b18589u, 0x06b6b51fu,
0x9fbfe4a5u, 0xe8b8d433u, 0x7807c9a2u, 0x0f00f934u, 0x9609a88eu, 0xe10e9818u, 0x7f6a0dbbu,
0x086d3d2du, 0x91646c97u, 0xe6635c01u, 0x6b6b51f4u, 0x1c6c6162u, 0x856530d8u, 0xf262004eu,
0x6c0695edu, 0x1b01a57bu, 0x8208f4c1u, 0xf50fc457u, 0x65b0d9c6u, 0x12b7e950u, 0x8bbeb8eau,
0xfcb9887cu, 0x62dd1ddfu, 0x15da2d49u, 0x8cd37cf3u, 0xfbd44c65u, 0x4db26158u, 0x3ab551ceu,
0xa3bc0074u, 0xd4bb30e2u, 0x4adfa541u, 0x3dd895d7u, 0xa4d1c46du, 0xd3d6f4fbu, 0x4369e96au,
0x346ed9fcu, 0xad678846u, 0xda60b8d0u, 0x44042d73u, 0x33031de5u, 0xaa0a4c5fu, 0xdd0d7cc9u,
0x5005713cu, 0x270241aau, 0xbe0b1010u, 0xc90c2086u, 0x5768b525u, 0x206f85b3u, 0xb966d409u,
0xce61e49fu, 0x5edef90eu, 0x29d9c998u, 0xb0d09822u, 0xc7d7a8b4u, 0x59b33d17u, 0x2eb40d81u,
0xb7bd5c3bu, 0xc0ba6cadu, 0xedb88320u, 0x9abfb3b6u, 0x03b6e20cu, 0x74b1d29au, 0xead54739u,
0x9dd277afu, 0x04db2615u, 0x73dc1683u, 0xe3630b12u, 0x94643b84u, 0x0d6d6a3eu, 0x7a6a5aa8u,
0xe40ecf0bu, 0x9309ff9du, 0x0a00ae27u, 0x7d079eb1u, 0xf00f9344u, 0x8708a3d2u, 0x1e01f268u,
0x6906c2feu, 0xf762575du, 0x806567cbu, 0x196c3671u, 0x6e6b06e7u, 0xfed41b76u, 0x89d32be0u,
0x10da7a5au, 0x67dd4accu, 0xf9b9df6fu, 0x8ebeeff9u, 0x17b7be43u, 0x60b08ed5u, 0xd6d6a3e8u,
0xa1d1937eu, 0x38d8c2c4u, 0x4fdff252u, 0xd1bb67f1u, 0xa6bc5767u, 0x3fb506ddu, 0x48b2364bu,
0xd80d2bdau, 0xaf0a1b4cu, 0x36034af6u, 0x41047a60u, 0xdf60efc3u, 0xa867df55u, 0x316e8eefu,
0x4669be79u, 0xcb61b38cu, 0xbc66831au, 0x256fd2a0u, 0x5268e236u, 0xcc0c7795u, 0xbb0b4703u,
0x220216b9u, 0x5505262fu, 0xc5ba3bbeu, 0xb2bd0b28u, 0x2bb45a92u, 0x5cb36a04u, 0xc2d7ffa7u,
0xb5d0cf31u, 0x2cd99e8bu, 0x5bdeae1du, 0x9b64c2b0u, 0xec63f226u, 0x756aa39cu, 0x026d930au,
0x9c0906a9u, 0xeb0e363fu, 0x72076785u, 0x05005713u, 0x95bf4a82u, 0xe2b87a14u, 0x7bb12baeu,
0x0cb61b38u, 0x92d28e9bu, 0xe5d5be0du, 0x7cdcefb7u, 0x0bdbdf21u, 0x86d3d2d4u, 0xf1d4e242u,
0x68ddb3f8u, 0x1fda836eu, 0x81be16cdu, 0xf6b9265bu, 0x6fb077e1u, 0x18b74777u, 0x88085ae6u,
0xff0f6a70u, 0x66063bcau, 0x11010b5cu, 0x8f659effu, 0xf862ae69u, 0x616bffd3u, 0x166ccf45u,
0xa00ae278u, 0xd70dd2eeu, 0x4e048354u, 0x3903b3c2u, 0xa7672661u, 0xd06016f7u, 0x4969474du,
0x3e6e77dbu, 0xaed16a4au, 0xd9d65adcu, 0x40df0b66u, 0x37d83bf0u, 0xa9bcae53u, 0xdebb9ec5u,
0x47b2cf7fu, 0x30b5ffe9u, 0xbdbdf21cu, 0xcabac28au, 0x53b39330u, 0x24b4a3a6u, 0xbad03605u,
0xcdd70693u, 0x54de5729u, 0x23d967bfu, 0xb3667a2eu, 0xc4614ab8u, 0x5d681b02u, 0x2a6f2b94u,
0xb40bbe37u, 0xc30c8ea1u, 0x5a05df1bu, 0x2d02ef8du,
};
uint32_t x52_crc32_init(void)
{
return X52_CRC32_INIT;
}
uint32_t x52_crc32_update(uint32_t crc, const void *data, size_t len)
{
const unsigned char *p = data;
if (len == 0) {
return crc;
}
crc = ~crc;
while (len != 0) {
crc = x52_crc32_table[(crc ^ *p++) & 0xffu] ^ (crc >> 8);
len--;
}
return ~crc;
}

57
daemon/crc32.h 100644
View File

@ -0,0 +1,57 @@
/*
* Saitek X52 Pro MFD & LED driver - CRC-32 (zlib / Python zlib.crc32 compatible)
*
* Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
*
* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
*/
/**
* @file crc32.h
* @brief IEEE/ZIP CRC-32 matching @c zlib.crc32 / Python @c zlib.crc32 (polynomial 0xEDB88320).
*
* Incremental use: @code
* uint32_t crc = x52_crc32_init();
* crc = x52_crc32_update(crc, chunk0, len0);
* crc = x52_crc32_update(crc, chunk1, len1);
* // crc is final value (unsigned 32-bit, same encoding as Python after & 0xFFFFFFFF)
* @endcode
*/
#ifndef X52D_CRC32_H
#define X52D_CRC32_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/** Initial accumulator value (same as first argument to zlib @c crc32 for a new stream). */
#define X52_CRC32_INIT 0u
/** Start a new CRC computation. */
uint32_t x52_crc32_init(void);
/**
* Feed zero or more bytes into a running CRC.
*
* @param crc Value from @ref x52_crc32_init or a prior @ref x52_crc32_update
* @param data Input bytes; must be non-NULL if @p len is non-zero
* @param len Number of bytes
* @return Updated CRC-32 (same as Python <tt>zlib.crc32(data, crc) & 0xFFFFFFFF</tt>
* when @p data is the new chunk only)
*/
uint32_t x52_crc32_update(uint32_t crc, const void *data, size_t len);
/** Alias for @ref x52_crc32_update (zlib-style name). */
static inline uint32_t x52_crc32(uint32_t crc, const void *data, size_t len)
{
return x52_crc32_update(crc, data, len);
}
#ifdef __cplusplus
}
#endif
#endif /* X52D_CRC32_H */

131
daemon/crc32_test.c 100644
View File

@ -0,0 +1,131 @@
/*
* Saitek X52 Pro MFD & LED driver - CRC-32 unit tests
*
* Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
*
* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
*/
#include "build-config.h"
#include <stddef.h>
#include <stdint.h>
#include <stdarg.h>
#include <setjmp.h>
#include <cmocka.h>
#include <daemon/crc32.h>
/* Golden values: Python zlib.crc32(data) & 0xFFFFFFFF */
static void test_init_zero(void **state)
{
(void)state;
assert_int_equal((int)x52_crc32_init(), 0);
assert_int_equal((int)X52_CRC32_INIT, 0);
}
static void test_empty(void **state)
{
(void)state;
uint32_t crc = x52_crc32_init();
crc = x52_crc32_update(crc, NULL, 0);
assert_true(crc == 0u);
crc = x52_crc32_update(crc, "", 0);
assert_true(crc == 0u);
}
static void test_len0_preserves(void **state)
{
(void)state;
const char *s = "abc";
uint32_t crc = x52_crc32_update(0, s, 3);
uint32_t again = x52_crc32_update(crc, s, 0);
assert_true(again == crc);
}
static void test_string_123456789(void **state)
{
(void)state;
static const char s[] = "123456789";
uint32_t crc = x52_crc32_update(0, s, sizeof(s) - 1u);
assert_true(crc == 0xcbf43926u);
}
static void test_bytes_0_to_255(void **state)
{
(void)state;
unsigned char buf[256];
for (unsigned i = 0; i < 256; i++) {
buf[i] = (unsigned char)i;
}
uint32_t crc = x52_crc32_update(0, buf, sizeof(buf));
assert_true(crc == 0x29058c73u);
}
static void test_incremental_matches_one_shot(void **state)
{
(void)state;
static const char s[] = "123456789";
uint32_t a = x52_crc32_update(0, s, sizeof(s) - 1u);
uint32_t b = x52_crc32_init();
b = x52_crc32_update(b, s, 3);
b = x52_crc32_update(b, s + 3, 3);
b = x52_crc32_update(b, s + 6, 3);
assert_true(b == a);
}
static void test_chaining_second_segment(void **state)
{
(void)state;
static const char h[] = "hello";
static const char w[] = "world";
static const char hw[] = "helloworld";
uint32_t c = x52_crc32_update(0, h, sizeof(h) - 1u);
c = x52_crc32_update(c, w, sizeof(w) - 1u);
uint32_t whole = x52_crc32_update(0, hw, sizeof(hw) - 1u);
assert_true(c == whole);
assert_true(c == 0xf9eb20adu);
}
static void test_one_byte_at_a_time(void **state)
{
(void)state;
static const char s[] = "123456789";
uint32_t expect = x52_crc32_update(0, s, sizeof(s) - 1u);
uint32_t crc = x52_crc32_init();
for (size_t i = 0; i < sizeof(s) - 1u; i++) {
crc = x52_crc32_update(crc, s + i, 1);
}
assert_true(crc == expect);
}
static void test_x52_crc32_alias(void **state)
{
(void)state;
static const char s[] = "123456789";
uint32_t u = x52_crc32_update(0, s, sizeof(s) - 1u);
uint32_t v = x52_crc32(0, s, sizeof(s) - 1u);
assert_true(u == v);
}
int main(void)
{
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_init_zero),
cmocka_unit_test(test_empty),
cmocka_unit_test(test_len0_preserves),
cmocka_unit_test(test_string_123456789),
cmocka_unit_test(test_bytes_0_to_255),
cmocka_unit_test(test_incremental_matches_one_shot),
cmocka_unit_test(test_chaining_second_segment),
cmocka_unit_test(test_one_byte_at_a_time),
cmocka_unit_test(test_x52_crc32_alias),
};
cmocka_set_message_output(CM_OUTPUT_TAP);
return cmocka_run_group_tests(tests, NULL, NULL);
}

View File

@ -0,0 +1,99 @@
/*
* Saitek X52 Pro MFD & LED driver - keyboard layout from config
*
* Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
*
* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
*/
#include "build-config.h"
#define PINELOG_MODULE X52D_MOD_KEYBOARD_LAYOUT
#include "pinelog.h"
#include <daemon/constants.h>
#include <daemon/keyboard_layout.h>
#include <daemon/layout_format.h>
#include <errno.h>
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static x52_layout *active_layout;
/**
* Normal install: @c $DATADIR/x52d/<basename>.x52l
*
* If @c X52D_LAYOUT_DIR is set (non-empty), load @c $X52D_LAYOUT_DIR/<basename>.x52l instead so
* uninstalled tests can point at the Meson build directory.
*/
static int load_layout_for_basename(const char *basename, x52_layout **out)
{
const char *layout_dir = getenv("X52D_LAYOUT_DIR");
if (layout_dir != NULL && layout_dir[0] != '\0') {
char path[PATH_MAX];
int n = snprintf(path, sizeof path, "%s/%s.x52l", layout_dir, basename);
if (n < 0) {
return EIO;
}
if ((size_t)n >= sizeof path) {
return ENAMETOOLONG;
}
return x52_layout_load_path(path, out);
}
return x52_layout_load_datadir(DATADIR, basename, out);
}
const x52_layout *x52d_keyboard_layout_get(void)
{
return active_layout;
}
void x52d_keyboard_layout_fini(void)
{
x52_layout_free(active_layout);
active_layout = NULL;
}
void x52d_keyboard_layout_reload(char *profile_keyboard_layout_value)
{
char basename[256];
bool rejected = false;
x52_layout_normalize_keyboard_basename(profile_keyboard_layout_value, basename, sizeof basename, &rejected);
if (rejected) {
PINELOG_WARN(_("Invalid Profiles.KeyboardLayout value; using default layout basename 'us'"));
}
x52_layout *new_layout = NULL;
int err = load_layout_for_basename(basename, &new_layout);
if (err != 0 && strcmp(basename, "us") != 0) {
PINELOG_WARN(
_("Keyboard layout '%s' could not be loaded (%s); loading default 'us'"),
basename, strerror(err > 0 ? err : EIO));
err = load_layout_for_basename("us", &new_layout);
}
if (err != 0) {
PINELOG_FATAL(_("Could not load keyboard layout from %s/x52d (%s)"), DATADIR,
strerror(err > 0 ? err : EIO));
}
x52_layout *old_layout = active_layout;
active_layout = new_layout;
x52_layout_free(old_layout);
const char *desc = x52_layout_description(active_layout);
PINELOG_INFO(_("Keyboard layout ready: %s (%s)"), x52_layout_name(active_layout),
desc[0] != '\0' ? desc : _("no description"));
}
void x52d_cfg_set_Profiles_KeyboardLayout(char *param)
{
x52d_keyboard_layout_reload(param);
}

View File

@ -0,0 +1,27 @@
/*
* Saitek X52 Pro MFD & LED driver - keyboard layout from config
*
* Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
*
* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
*/
#ifndef X52D_KEYBOARD_LAYOUT_H
#define X52D_KEYBOARD_LAYOUT_H
#include <daemon/layout_format.h>
/**
* @brief Load or reload layout from @c profile_keyboard_layout (@c Profiles.KeyboardLayout).
*
* Resolves @c $datadir/x52d/<basename>.x52l with basename hardening; falls back to @c us once if the
* requested file is missing. Exits the process if no layout can be loaded.
*/
void x52d_keyboard_layout_reload(char *profile_keyboard_layout_value);
/** Active layout after @ref x52d_keyboard_layout_reload, or @c NULL before the first apply. */
const x52_layout *x52d_keyboard_layout_get(void);
void x52d_keyboard_layout_fini(void);
#endif /* X52D_KEYBOARD_LAYOUT_H */

View File

@ -0,0 +1,197 @@
/*
* Saitek X52 Pro MFD & LED driver - x52layout v1 binary format
*
* Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
*
* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
*/
/**
* @file layout_format.h
* @brief On-disk keyboard layout (@c .x52l) v1: constants, documentation, load and lookup API.
*
* The file is a dense index: entry @c entries[c] maps Unicode scalar @c c when
* @c 0 <= c < codepoint_limit. For @c c >= codepoint_limit there is no mapping.
*
* **Header (128 bytes, all multi-byte integers big-endian / network order):**
* - **0..3:** magic @c 'X' @c '5' @c '2' @c 'L'
* - **4..5:** @c version (must be @ref X52_LAYOUT_FORMAT_VERSION)
* - **6..7:** @c flags (v1: only @ref X52_LAYOUT_FLAG_NAME_TRUNCATED and/or
* @ref X52_LAYOUT_FLAG_DESCRIPTION_TRUNCATED; other bits are reserved and must be zero)
* - **8..11:** @c codepoint_limit exclusive end of range; number of two-byte rows in @c entries
* - **12..15:** @c checksum CRC-32 (ZIP/IEEE, same as Python @c zlib.crc32) over the full
* file with bytes 12..15 taken as zero when computing the digest
* - **16..47:** @c layout_name (required: at least one character before @c NUL; remainder zero)
* - **48..111:** @c description (optional, NUL-terminated, remainder zero)
* - **112..127:** reserved (ignored on read in v1)
*
* **128+:** @c entries[] pairs @c (modifiers, usage_key) for HID page 0x07; @c (0, 0) is empty.
*
* **File size:** exactly @c 128 + 2 * @c codepoint_limit bytes.
*/
#ifndef X52D_LAYOUT_FORMAT_H
#define X52D_LAYOUT_FORMAT_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/** Four-byte magic at offset 0 (not NUL-terminated in the file). */
#define X52_LAYOUT_MAGIC_0 'X'
#define X52_LAYOUT_MAGIC_1 '5'
#define X52_LAYOUT_MAGIC_2 '2'
#define X52_LAYOUT_MAGIC_3 'L'
/** Total header size and byte offset of the @c entries table. */
#define X52_LAYOUT_HEADER_BYTES 128u
#define X52_LAYOUT_FORMAT_VERSION 1u
/** Inclusive maximum for @c codepoint_limit - 1 (Unicode scalar space + sentinel ceiling). */
#define X52_LAYOUT_CODEPOINT_LIMIT_MAX 0x110000u
/** @c layout_name field size in the header (offset 16). */
#define X52_LAYOUT_NAME_FIELD_BYTES 32u
/** @c description field size in the header (offset 48). */
#define X52_LAYOUT_DESCRIPTION_FIELD_BYTES 64u
/**
* v1 header @c flags (big-endian on disk). @ref x52_layout_flags returns the host-endian value.
*/
#define X52_LAYOUT_FLAG_NAME_TRUNCATED 1u
#define X52_LAYOUT_FLAG_DESCRIPTION_TRUNCATED 2u
/** Bitmask of flags defined for v1; other bits must be zero. */
#define X52_LAYOUT_FLAGS_KNOWN (X52_LAYOUT_FLAG_NAME_TRUNCATED | X52_LAYOUT_FLAG_DESCRIPTION_TRUNCATED)
/** Loaded layout snapshot (opaque): full file copy, validated at load time. */
typedef struct x52_layout x52_layout;
/**
* @brief Read a layout file into a malloc'd snapshot and validate it (no @c mmap).
*
* @param path Path to the @c .x52l file; must not be @c NULL (otherwise @c EINVAL)
* @param out On success, receives a new @ref x52_layout; must not be @c NULL; caller must @ref x52_layout_free
*
* @returns 0 on success, or a positive @c errno value (@c EINVAL, @c ENOMEM, @c EIO, @c ENOENT, )
*/
int x52_layout_load_path(const char *path, x52_layout **out);
/**
* @brief Turn @c Profiles.KeyboardLayout INI value into a safe layout basename.
*
* Empty or @c NULL yields @c "us" with @p rejected_out @c false. Values containing @c '/' ,
* @c '\\' , @c ".." , disallowed characters, or oversize strings are rejected: @p out becomes
* @c "us" and @p rejected_out is @c true (caller should log once). Requires @p out_sz @c >= 3.
*
* Allowed characters: ASCII alphanumeric, @c '_' , @c '-'.
*/
void x52_layout_normalize_keyboard_basename(const char *cfg_value, char *out, size_t out_sz, bool *rejected_out);
/**
* @brief Build @c <datadir>/x52d/<basename>.x52l into @p path.
*
* @returns 0 on success, or @c ENAMETOOLONG if the path does not fit
*/
int x52_layout_join_file_path(char *path, size_t path_sz, const char *datadir, const char *basename);
/**
* @brief Load @c join(datadir, "x52d", basename + ".x52l") after the same validation as @ref x52_layout_load_path.
*
* @returns 0 on success, or a positive @c errno (e.g. @c ENOENT, @c EINVAL, @c ENAMETOOLONG)
*/
int x52_layout_load_datadir(const char *datadir, const char *basename, x52_layout **out);
/**
* @brief Copy @p data into an owned buffer and validate it.
*
* Same validation rules as @ref x52_layout_load_path (magic, version, flags, size, CRC-32, entries,
* non-empty @c layout_name, etc.).
*
* @param data Layout file bytes; may be @c NULL only if @p len is zero (otherwise @c EINVAL)
* @param len Number of bytes in @p data
* @param out On success, receives a new @ref x52_layout; must not be @c NULL; caller must @ref x52_layout_free
*
* @returns 0 on success, or a positive @c errno value (@c EINVAL, @c ENOMEM, )
*/
int x52_layout_load_memory(const void *data, size_t len, x52_layout **out);
/**
* @brief Release a layout loaded by @ref x52_layout_load_path or @ref x52_layout_load_memory.
*
* @param layout Layout to free; @c NULL is a no-op
*/
void x52_layout_free(x52_layout *layout);
/**
* @brief Exclusive end of the Unicode scalar range covered by @c entries (same as on-disk @c codepoint_limit).
*
* Lookups for @c code_point >= this value are not in the table.
*
* @param layout Loaded layout, or @c NULL
*
* @returns The limit value, or @c 0 if @p layout is @c NULL
*/
uint32_t x52_layout_codepoint_limit(const x52_layout *layout);
/**
* @brief Host-endian copy of the on-disk @c flags field at header offset 6.
*
* Only bits in @ref X52_LAYOUT_FLAGS_KNOWN may be set in valid files; the loader rejects unknown bits.
*
* @param layout Loaded layout, or @c NULL
*
* @returns Flag word, or @c 0 if @p layout is @c NULL
*/
uint16_t x52_layout_flags(const x52_layout *layout);
/**
* @brief Layout name from the header @c layout_name field.
*
* If @ref X52_LAYOUT_FLAG_NAME_TRUNCATED is set, the returned string is the on-disk name plus
* @c "<truncated>". The pointer remains valid until @ref x52_layout_free; do not modify the string.
*
* @param layout Loaded layout, or @c NULL
*
* @returns Read-only NUL-terminated UTF-8 string; empty string if @p layout is @c NULL
*/
const char *x52_layout_name(const x52_layout *layout);
/**
* @brief Optional description from the header @c description field.
*
* If @ref X52_LAYOUT_FLAG_DESCRIPTION_TRUNCATED is set, the returned string is the on-disk text plus
* @c "<truncated>". The pointer remains valid until @ref x52_layout_free; do not modify the string.
*
* @param layout Loaded layout, or @c NULL
*
* @returns Read-only NUL-terminated UTF-8 string, or empty string if @p layout is @c NULL or the field is empty
*/
const char *x52_layout_description(const x52_layout *layout);
/**
* @brief O(1) lookup of the HID chord for Unicode scalar @p code_point.
*
* Returns @c false when @p code_point is out of range, when the slot is empty @c (modifiers, usage_key) = (0, 0),
* or when any argument is invalid. On @c false, @p modifiers_out and @p usage_key_out are left unchanged.
*
* @param layout Loaded layout; if @c NULL, returns @c false
* @param code_point Unicode scalar value
* @param[out] modifiers_out HID keyboard modifier byte (@ref vkm_key_modifiers bits); if @c NULL, returns @c false
* @param[out] usage_key_out HID usage (page 0x07); if @c NULL, returns @c false
*
* @returns @c true if a non-empty mapping exists and was written to @p modifiers_out and @p usage_key_out
*/
bool x52_layout_lookup(const x52_layout *layout, uint32_t code_point, uint8_t *modifiers_out,
uint8_t *usage_key_out);
#ifdef __cplusplus
}
#endif
#endif /* X52D_LAYOUT_FORMAT_H */

View File

@ -0,0 +1,392 @@
/*
* Saitek X52 Pro MFD & LED driver - x52layout v1 loader
*
* Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
*
* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
*/
#include "build-config.h"
#include <daemon/crc32.h>
#include <daemon/layout_format.h>
#include <daemon/layout_usage_allowlist.h>
#include <ctype.h>
#include <errno.h>
#include <limits.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct x52_layout {
uint8_t *data;
size_t size;
uint32_t codepoint_limit;
uint16_t flags;
char *name;
char *description;
};
static size_t meta_field_len(const uint8_t *field, size_t nbytes)
{
size_t i;
for (i = 0; i < nbytes; i++) {
if (field[i] == '\0') {
break;
}
}
return i;
}
static char *copy_meta_string(const uint8_t *field, size_t nbytes, bool add_trunc_suffix)
{
static const char suf[] = "<truncated>";
const size_t suf_len = sizeof(suf) - 1u;
size_t base_len = meta_field_len(field, nbytes);
size_t total = base_len + (add_trunc_suffix ? suf_len : 0);
char *s = (char *)malloc(total + 1u);
if (s == NULL) {
return NULL;
}
if (base_len != 0) {
memcpy(s, field, base_len);
}
if (add_trunc_suffix) {
memcpy(s + base_len, suf, suf_len);
}
s[total] = '\0';
return s;
}
static uint16_t read_be16(const uint8_t *p)
{
return (uint16_t)((uint16_t)p[0] << 8 | (uint16_t)p[1]);
}
static uint32_t read_be32(const uint8_t *p)
{
return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | (uint32_t)p[3];
}
static uint32_t x52_layout_file_crc(const uint8_t *buf, size_t len)
{
uint32_t crc = x52_crc32_init();
crc = x52_crc32_update(crc, buf, 12u);
static const uint8_t zero_chk[4] = {0, 0, 0, 0};
crc = x52_crc32_update(crc, zero_chk, 4u);
if (len > 16u) {
crc = x52_crc32_update(crc, buf + 16u, len - 16u);
}
return crc;
}
static int validate_entries(const uint8_t *buf, uint32_t codepoint_limit)
{
const uint8_t *base = buf + X52_LAYOUT_HEADER_BYTES;
for (uint32_t i = 0; i < codepoint_limit; i++) {
uint8_t usage = base[2u * (size_t)i + 1u];
if (usage == 0) {
continue;
}
if (!x52_layout_usage_key_allowed(usage)) {
return EINVAL;
}
}
return 0;
}
static int layout_validate_and_adopt(uint8_t *buf, size_t len, x52_layout **out)
{
if (out == NULL) {
free(buf);
return EINVAL;
}
*out = NULL;
if (len < X52_LAYOUT_HEADER_BYTES) {
free(buf);
return EINVAL;
}
if (memcmp(buf, "X52L", 4) != 0) {
free(buf);
return EINVAL;
}
uint16_t version = read_be16(buf + 4);
uint16_t flags = read_be16(buf + 6);
uint32_t codepoint_limit = read_be32(buf + 8);
uint32_t stored_crc = read_be32(buf + 12);
if (version != X52_LAYOUT_FORMAT_VERSION) {
free(buf);
return EINVAL;
}
if ((flags & (uint16_t)~X52_LAYOUT_FLAGS_KNOWN) != 0) {
free(buf);
return EINVAL;
}
if (codepoint_limit > X52_LAYOUT_CODEPOINT_LIMIT_MAX) {
free(buf);
return EINVAL;
}
size_t body = (size_t)codepoint_limit * 2u;
size_t expected = X52_LAYOUT_HEADER_BYTES + body;
if (expected < X52_LAYOUT_HEADER_BYTES || len != expected) {
free(buf);
return EINVAL;
}
if (meta_field_len(buf + 16, X52_LAYOUT_NAME_FIELD_BYTES) == 0) {
free(buf);
return EINVAL;
}
uint32_t calc = x52_layout_file_crc(buf, len);
if (calc != stored_crc) {
free(buf);
return EINVAL;
}
int en = validate_entries(buf, codepoint_limit);
if (en != 0) {
free(buf);
return en;
}
x52_layout *L = (x52_layout *)malloc(sizeof *L);
if (L == NULL) {
free(buf);
return ENOMEM;
}
L->data = buf;
L->size = len;
L->codepoint_limit = codepoint_limit;
L->flags = flags;
L->name = copy_meta_string(buf + 16, X52_LAYOUT_NAME_FIELD_BYTES,
(flags & X52_LAYOUT_FLAG_NAME_TRUNCATED) != 0);
if (L->name == NULL) {
free(L);
free(buf);
return ENOMEM;
}
L->description = copy_meta_string(buf + 48, X52_LAYOUT_DESCRIPTION_FIELD_BYTES,
(flags & X52_LAYOUT_FLAG_DESCRIPTION_TRUNCATED) != 0);
if (L->description == NULL) {
free(L->name);
free(L);
free(buf);
return ENOMEM;
}
*out = L;
return 0;
}
void x52_layout_normalize_keyboard_basename(const char *cfg_value, char *out, size_t out_sz, bool *rejected_out)
{
*rejected_out = false;
if (out_sz < 3) {
if (out_sz > 0) {
out[0] = '\0';
}
return;
}
if (cfg_value == NULL || cfg_value[0] == '\0') {
out[0] = 'u';
out[1] = 's';
out[2] = '\0';
return;
}
if (strchr(cfg_value, '/') != NULL || strchr(cfg_value, '\\') != NULL ||
strstr(cfg_value, "..") != NULL) {
goto failed_normalization;
}
size_t len = strlen(cfg_value);
if (len >= out_sz) {
goto failed_normalization;
}
for (size_t i = 0; i < len; i++) {
unsigned char c = (unsigned char)cfg_value[i];
if (!isalnum((int)c) && c != '_' && c != '-') {
goto failed_normalization;
}
}
memcpy(out, cfg_value, len + 1u);
return;
failed_normalization:
*rejected_out = true;
out[0] = 'u';
out[1] = 's';
out[2] = '\0';
}
int x52_layout_join_file_path(char *path, size_t path_sz, const char *datadir, const char *basename)
{
if (path == NULL || datadir == NULL || basename == NULL || path_sz == 0) {
return EINVAL;
}
int n = snprintf(path, path_sz, "%s/x52d/%s.x52l", datadir, basename);
if (n < 0) {
return EIO;
}
if ((size_t)n >= path_sz) {
return ENAMETOOLONG;
}
return 0;
}
int x52_layout_load_datadir(const char *datadir, const char *basename, x52_layout **out)
{
char path[PATH_MAX];
int rc = x52_layout_join_file_path(path, sizeof path, datadir, basename);
if (rc != 0) {
return rc;
}
return x52_layout_load_path(path, out);
}
int x52_layout_load_path(const char *path, x52_layout **out)
{
if (path == NULL) {
return EINVAL;
}
FILE *fp = fopen(path, "rb");
if (fp == NULL) {
return errno != 0 ? errno : EIO;
}
if (fseek(fp, 0, SEEK_END) != 0) {
int e = errno != 0 ? errno : EIO;
fclose(fp);
return e;
}
long pos = ftell(fp);
if (pos < 0) {
int e = errno != 0 ? errno : EIO;
fclose(fp);
return e;
}
if (fseek(fp, 0, SEEK_SET) != 0) {
int e = errno != 0 ? errno : EIO;
fclose(fp);
return e;
}
size_t len = (size_t)pos;
if ((long)len != pos || pos < (long)X52_LAYOUT_HEADER_BYTES) {
fclose(fp);
return EINVAL;
}
uint8_t *buf = (uint8_t *)malloc(len);
if (buf == NULL) {
fclose(fp);
return ENOMEM;
}
size_t n = fread(buf, 1, len, fp);
fclose(fp);
if (n != len) {
free(buf);
return EIO;
}
return layout_validate_and_adopt(buf, len, out);
}
int x52_layout_load_memory(const void *data, size_t len, x52_layout **out)
{
if (data == NULL && len != 0) {
return EINVAL;
}
uint8_t *buf = (uint8_t *)malloc(len);
if (buf == NULL) {
return ENOMEM;
}
if (len != 0) {
memcpy(buf, data, len);
}
return layout_validate_and_adopt(buf, len, out);
}
void x52_layout_free(x52_layout *layout)
{
if (layout == NULL) {
return;
}
free(layout->name);
free(layout->description);
free(layout->data);
free(layout);
}
uint32_t x52_layout_codepoint_limit(const x52_layout *layout)
{
if (layout == NULL) {
return 0;
}
return layout->codepoint_limit;
}
uint16_t x52_layout_flags(const x52_layout *layout)
{
if (layout == NULL) {
return 0;
}
return layout->flags;
}
const char *x52_layout_name(const x52_layout *layout)
{
if (layout == NULL || layout->name == NULL) {
return "";
}
return layout->name;
}
const char *x52_layout_description(const x52_layout *layout)
{
if (layout == NULL || layout->description == NULL) {
return "";
}
return layout->description;
}
bool x52_layout_lookup(const x52_layout *layout, uint32_t code_point, uint8_t *modifiers_out,
uint8_t *usage_key_out)
{
if (layout == NULL || modifiers_out == NULL || usage_key_out == NULL) {
return false;
}
if (code_point >= layout->codepoint_limit) {
return false;
}
size_t off = X52_LAYOUT_HEADER_BYTES + 2u * (size_t)code_point;
uint8_t mod = layout->data[off];
uint8_t usage = layout->data[off + 1u];
if (usage == 0) {
return false;
}
*modifiers_out = mod;
*usage_key_out = usage;
return true;
}

View File

@ -0,0 +1,463 @@
/*
* Saitek X52 Pro MFD & LED driver - x52layout loader tests
*
* Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
*
* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
*/
#include "build-config.h"
#include <errno.h>
#include <limits.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
#include <setjmp.h>
#include <cmocka.h>
#include <daemon/crc32.h>
#include <daemon/layout_format.h>
#include <vkm/vkm.h>
static void write_be16(uint8_t *p, uint16_t v)
{
p[0] = (uint8_t)(v >> 8);
p[1] = (uint8_t)v;
}
static void write_be32(uint8_t *p, uint32_t v)
{
p[0] = (uint8_t)(v >> 24);
p[1] = (uint8_t)(v >> 16);
p[2] = (uint8_t)(v >> 8);
p[3] = (uint8_t)v;
}
static uint32_t read_be32(const uint8_t *p)
{
return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | (uint32_t)p[3];
}
/** Patch checksum (offsets 12..15) after building the rest; @p len is full file size. */
static void finalize_crc(uint8_t *buf, size_t len)
{
uint32_t crc = x52_crc32_init();
crc = x52_crc32_update(crc, buf, 12u);
static const uint8_t z[4] = {0, 0, 0, 0};
crc = x52_crc32_update(crc, z, 4u);
if (len > 16u) {
crc = x52_crc32_update(crc, buf + 16u, len - 16u);
}
write_be32(buf + 12, crc);
}
/** Python @c zlib.crc32 over @c minimal v1 layout (@c limit=1, name @c "x", empty entry). */
#define X52_LAYOUT_TEST_MINIMAL_ZLIB_CRC32 0xc951bfaau
static void test_load_minimal_lookup(void **state)
{
(void)state;
const uint32_t limit = 98u; /* enough for 'a' at 97 */
size_t len = X52_LAYOUT_HEADER_BYTES + 2u * (size_t)limit;
uint8_t *buf = (uint8_t *)calloc(1, len);
assert_non_null(buf);
memcpy(buf, "X52L", 4);
write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION);
write_be16(buf + 6, 0);
write_be32(buf + 8, limit);
memcpy(buf + 16, "minimal", 8);
/* chord for 'a': VKM_KEY_A */
size_t off = X52_LAYOUT_HEADER_BYTES + 2u * 97u;
buf[off] = 0x02; /* LSHIFT */
buf[off + 1] = (uint8_t)VKM_KEY_A;
finalize_crc(buf, len);
x52_layout *L = NULL;
assert_int_equal(x52_layout_load_memory(buf, len, &L), 0);
assert_non_null(L);
assert_int_equal((int)x52_layout_codepoint_limit(L), (int)limit);
uint8_t m = 0xff;
uint8_t u = 0xff;
assert_true(x52_layout_lookup(L, 97u, &m, &u));
assert_int_equal((int)m, 0x02);
assert_int_equal((int)u, (int)VKM_KEY_A);
m = 0;
u = 0;
assert_false(x52_layout_lookup(L, 0u, &m, &u));
assert_false(x52_layout_lookup(L, limit, &m, &u));
assert_int_equal((int)x52_layout_flags(L), 0);
assert_string_equal(x52_layout_name(L), "minimal");
assert_string_equal(x52_layout_description(L), "");
x52_layout_free(L);
free(buf);
}
static void test_reject_bad_checksum(void **state)
{
(void)state;
const uint32_t limit = 1u;
size_t len = X52_LAYOUT_HEADER_BYTES + 2u;
uint8_t *buf = (uint8_t *)calloc(1, len);
assert_non_null(buf);
memcpy(buf, "X52L", 4);
write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION);
write_be16(buf + 6, 0);
write_be32(buf + 8, limit);
memcpy(buf + 16, "x", 2);
write_be32(buf + 12, 0xdeadbeefu);
x52_layout *L = NULL;
assert_int_equal(x52_layout_load_memory(buf, len, &L), EINVAL);
assert_null(L);
free(buf);
}
static void test_layout_crc_matches_python_zlib_minimal(void **state)
{
(void)state;
const uint32_t limit = 1u;
size_t len = X52_LAYOUT_HEADER_BYTES + 2u;
uint8_t *buf = (uint8_t *)calloc(1, len);
assert_non_null(buf);
memcpy(buf, "X52L", 4);
write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION);
write_be16(buf + 6, 0);
write_be32(buf + 8, limit);
memcpy(buf + 16, "x", 2);
finalize_crc(buf, len);
assert_int_equal((int)read_be32(buf + 12), (int)X52_LAYOUT_TEST_MINIMAL_ZLIB_CRC32);
x52_layout *L = NULL;
assert_int_equal(x52_layout_load_memory(buf, len, &L), 0);
assert_non_null(L);
assert_string_equal(x52_layout_name(L), "x");
x52_layout_free(L);
free(buf);
}
static void test_reject_tampered_checksum_byte(void **state)
{
(void)state;
const uint32_t limit = 1u;
size_t len = X52_LAYOUT_HEADER_BYTES + 2u;
uint8_t *buf = (uint8_t *)calloc(1, len);
assert_non_null(buf);
memcpy(buf, "X52L", 4);
write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION);
write_be16(buf + 6, 0);
write_be32(buf + 8, limit);
memcpy(buf + 16, "x", 2);
finalize_crc(buf, len);
buf[12] ^= 0x01u;
x52_layout *L = NULL;
assert_int_equal(x52_layout_load_memory(buf, len, &L), EINVAL);
assert_null(L);
free(buf);
}
static void test_reject_codepoint_limit_not_big_endian(void **state)
{
(void)state;
/* Little-endian uint32_t 1 in the codepoint_limit field: read_be32 → 0x01000000. */
const uint32_t limit = 1u;
size_t len = X52_LAYOUT_HEADER_BYTES + 2u * (size_t)limit;
uint8_t *buf = (uint8_t *)calloc(1, len);
assert_non_null(buf);
memcpy(buf, "X52L", 4);
write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION);
write_be16(buf + 6, 0);
buf[8] = 0x01u;
buf[9] = 0x00u;
buf[10] = 0x00u;
buf[11] = 0x00u;
memcpy(buf + 16, "x", 2);
finalize_crc(buf, len);
x52_layout *L = NULL;
assert_int_equal(x52_layout_load_memory(buf, len, &L), EINVAL);
assert_null(L);
free(buf);
}
static void test_reject_version_word_not_big_endian_one(void **state)
{
(void)state;
const uint32_t limit = 1u;
size_t len = X52_LAYOUT_HEADER_BYTES + 2u;
uint8_t *buf = (uint8_t *)calloc(1, len);
assert_non_null(buf);
memcpy(buf, "X52L", 4);
/* Native little-endian 0x0001 would appear as 01 00 — not BE version 1 (00 01). */
buf[4] = 0x01u;
buf[5] = 0x00u;
write_be16(buf + 6, 0);
write_be32(buf + 8, limit);
memcpy(buf + 16, "x", 2);
finalize_crc(buf, len);
x52_layout *L = NULL;
assert_int_equal(x52_layout_load_memory(buf, len, &L), EINVAL);
assert_null(L);
free(buf);
}
static void test_reject_size_mismatch(void **state)
{
(void)state;
const uint32_t limit = 4u;
size_t len = X52_LAYOUT_HEADER_BYTES + 2u * (size_t)limit;
uint8_t *buf = (uint8_t *)calloc(1, len);
assert_non_null(buf);
memcpy(buf, "X52L", 4);
write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION);
write_be16(buf + 6, 0);
write_be32(buf + 8, limit);
memcpy(buf + 16, "x", 2);
finalize_crc(buf, len);
x52_layout *L = NULL;
assert_int_equal(x52_layout_load_memory(buf, len - 1u, &L), EINVAL);
assert_null(L);
free(buf);
}
static void test_reject_disallowed_usage(void **state)
{
(void)state;
const uint32_t limit = 1u;
size_t len = X52_LAYOUT_HEADER_BYTES + 2u;
uint8_t *buf = (uint8_t *)calloc(1, len);
assert_non_null(buf);
memcpy(buf, "X52L", 4);
write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION);
write_be16(buf + 6, 0);
write_be32(buf + 8, limit);
memcpy(buf + 16, "x", 2);
buf[X52_LAYOUT_HEADER_BYTES + 1u] = 0x3A; /* VKM_KEY_F1 — not in allowlist */
finalize_crc(buf, len);
x52_layout *L = NULL;
assert_int_equal(x52_layout_load_memory(buf, len, &L), EINVAL);
assert_null(L);
free(buf);
}
static void test_metadata_plain(void **state)
{
(void)state;
const uint32_t limit = 1u;
size_t len = X52_LAYOUT_HEADER_BYTES + 2u;
uint8_t *buf = (uint8_t *)calloc(1, len);
assert_non_null(buf);
memcpy(buf, "X52L", 4);
write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION);
write_be16(buf + 6, 0);
write_be32(buf + 8, limit);
memcpy(buf + 16, "us", 3);
memcpy(buf + 48, "US QWERTY", 10);
finalize_crc(buf, len);
x52_layout *L = NULL;
assert_int_equal(x52_layout_load_memory(buf, len, &L), 0);
assert_non_null(L);
assert_string_equal(x52_layout_name(L), "us");
assert_string_equal(x52_layout_description(L), "US QWERTY");
x52_layout_free(L);
free(buf);
}
static void test_metadata_truncated_suffix(void **state)
{
(void)state;
const uint32_t limit = 1u;
size_t len = X52_LAYOUT_HEADER_BYTES + 2u;
uint8_t *buf = (uint8_t *)calloc(1, len);
assert_non_null(buf);
memcpy(buf, "X52L", 4);
write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION);
write_be16(buf + 6,
(uint16_t)(X52_LAYOUT_FLAG_NAME_TRUNCATED | X52_LAYOUT_FLAG_DESCRIPTION_TRUNCATED));
write_be32(buf + 8, limit);
memcpy(buf + 16, "longish", 8);
memcpy(buf + 48, "desc", 5);
finalize_crc(buf, len);
x52_layout *L = NULL;
assert_int_equal(x52_layout_load_memory(buf, len, &L), 0);
assert_non_null(L);
assert_int_equal((int)x52_layout_flags(L),
(int)(X52_LAYOUT_FLAG_NAME_TRUNCATED | X52_LAYOUT_FLAG_DESCRIPTION_TRUNCATED));
assert_string_equal(x52_layout_name(L), "longish<truncated>");
assert_string_equal(x52_layout_description(L), "desc<truncated>");
x52_layout_free(L);
free(buf);
}
static void test_metadata_name_truncated_flag_only(void **state)
{
(void)state;
const uint32_t limit = 1u;
size_t len = X52_LAYOUT_HEADER_BYTES + 2u;
uint8_t *buf = (uint8_t *)calloc(1, len);
assert_non_null(buf);
memcpy(buf, "X52L", 4);
write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION);
write_be16(buf + 6, X52_LAYOUT_FLAG_NAME_TRUNCATED);
write_be32(buf + 8, limit);
memcpy(buf + 16, "nm", 3);
memcpy(buf + 48, "plain", 6);
finalize_crc(buf, len);
x52_layout *L = NULL;
assert_int_equal(x52_layout_load_memory(buf, len, &L), 0);
assert_non_null(L);
assert_int_equal((int)x52_layout_flags(L), (int)X52_LAYOUT_FLAG_NAME_TRUNCATED);
assert_string_equal(x52_layout_name(L), "nm<truncated>");
assert_string_equal(x52_layout_description(L), "plain");
x52_layout_free(L);
free(buf);
}
static void test_reject_unknown_flags(void **state)
{
(void)state;
const uint32_t limit = 1u;
size_t len = X52_LAYOUT_HEADER_BYTES + 2u;
uint8_t *buf = (uint8_t *)calloc(1, len);
assert_non_null(buf);
memcpy(buf, "X52L", 4);
write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION);
write_be16(buf + 6, 0x8000);
write_be32(buf + 8, limit);
memcpy(buf + 16, "x", 2);
finalize_crc(buf, len);
x52_layout *L = NULL;
assert_int_equal(x52_layout_load_memory(buf, len, &L), EINVAL);
assert_null(L);
free(buf);
}
static void test_reject_empty_name(void **state)
{
(void)state;
const uint32_t limit = 1u;
size_t len = X52_LAYOUT_HEADER_BYTES + 2u;
uint8_t *buf = (uint8_t *)calloc(1, len);
assert_non_null(buf);
memcpy(buf, "X52L", 4);
write_be16(buf + 4, X52_LAYOUT_FORMAT_VERSION);
write_be16(buf + 6, 0);
write_be32(buf + 8, limit);
finalize_crc(buf, len);
x52_layout *L = NULL;
assert_int_equal(x52_layout_load_memory(buf, len, &L), EINVAL);
assert_null(L);
free(buf);
}
static void test_basename_normalize_and_join(void **state)
{
(void)state;
char out[256];
bool rej;
x52_layout_normalize_keyboard_basename(NULL, out, sizeof out, &rej);
assert_false(rej);
assert_string_equal(out, "us");
x52_layout_normalize_keyboard_basename("", out, sizeof out, &rej);
assert_false(rej);
assert_string_equal(out, "us");
x52_layout_normalize_keyboard_basename("de", out, sizeof out, &rej);
assert_false(rej);
assert_string_equal(out, "de");
x52_layout_normalize_keyboard_basename("ab_cd-9", out, sizeof out, &rej);
assert_false(rej);
assert_string_equal(out, "ab_cd-9");
x52_layout_normalize_keyboard_basename("../x", out, sizeof out, &rej);
assert_true(rej);
assert_string_equal(out, "us");
x52_layout_normalize_keyboard_basename("a/b", out, sizeof out, &rej);
assert_true(rej);
assert_string_equal(out, "us");
x52_layout_normalize_keyboard_basename("bad name", out, sizeof out, &rej);
assert_true(rej);
assert_string_equal(out, "us");
x52_layout_normalize_keyboard_basename("a\\b", out, sizeof out, &rej);
assert_true(rej);
assert_string_equal(out, "us");
x52_layout_normalize_keyboard_basename("x..y", out, sizeof out, &rej);
assert_true(rej);
assert_string_equal(out, "us");
memset(out, 0, sizeof out);
x52_layout_normalize_keyboard_basename("almost..", out, sizeof out, &rej);
assert_true(rej);
assert_string_equal(out, "us");
char path[PATH_MAX];
assert_int_equal(x52_layout_join_file_path(path, sizeof path, "/usr/share", "us"), 0);
assert_string_equal(path, "/usr/share/x52d/us.x52l");
assert_int_equal(x52_layout_join_file_path(path, 20, "/usr/share", "us"), ENAMETOOLONG);
}
static void test_reject_version(void **state)
{
(void)state;
const uint32_t limit = 1u;
size_t len = X52_LAYOUT_HEADER_BYTES + 2u;
uint8_t *buf = (uint8_t *)calloc(1, len);
assert_non_null(buf);
memcpy(buf, "X52L", 4);
write_be16(buf + 4, 99);
write_be16(buf + 6, 0);
write_be32(buf + 8, limit);
memcpy(buf + 16, "v", 2);
finalize_crc(buf, len);
x52_layout *L = NULL;
assert_int_equal(x52_layout_load_memory(buf, len, &L), EINVAL);
assert_null(L);
free(buf);
}
int main(void)
{
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_load_minimal_lookup),
cmocka_unit_test(test_metadata_plain),
cmocka_unit_test(test_metadata_truncated_suffix),
cmocka_unit_test(test_metadata_name_truncated_flag_only),
cmocka_unit_test(test_reject_unknown_flags),
cmocka_unit_test(test_reject_empty_name),
cmocka_unit_test(test_reject_bad_checksum),
cmocka_unit_test(test_layout_crc_matches_python_zlib_minimal),
cmocka_unit_test(test_reject_tampered_checksum_byte),
cmocka_unit_test(test_reject_codepoint_limit_not_big_endian),
cmocka_unit_test(test_reject_version_word_not_big_endian_one),
cmocka_unit_test(test_reject_size_mismatch),
cmocka_unit_test(test_reject_disallowed_usage),
cmocka_unit_test(test_reject_version),
cmocka_unit_test(test_basename_normalize_and_join),
};
cmocka_set_message_output(CM_OUTPUT_TAP);
return cmocka_run_group_tests(tests, NULL, NULL);
}

View File

@ -0,0 +1,32 @@
/*
* Saitek X52 Pro MFD & LED driver - Keyboard layout HID usage allowlist
*
* Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
*
* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
*/
#include <daemon/layout_usage_allowlist.h>
#include <stdint.h>
#include <vkm/vkm.h>
/* layout binary and compiler must stay aligned with vkm_key numeric values. */
_Static_assert((unsigned)VKM_KEY_A == 0x04u, "vkm_key main block start");
_Static_assert((unsigned)VKM_KEY_CAPS_LOCK == 0x39u, "vkm_key main block end");
_Static_assert((unsigned)VKM_KEY_INTL_BACKSLASH == 0x64u, "vkm_key ISO backslash");
bool x52_layout_usage_key_allowed(uint8_t usage)
{
if (usage == 0) {
return false;
}
if (usage >= 0x04 && usage <= 0x39) {
return true;
}
if (usage == 0x64) {
return true;
}
return false;
}

View File

@ -0,0 +1,42 @@
/*
* Saitek X52 Pro MFD & LED driver - Keyboard layout HID usage allowlist
*
* Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
*
* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
*/
/**
* @file layout_usage_allowlist.h
* @brief HID keyboard/page (0x07) usages permitted as layout chord \c usage_key bytes.
*
* The set is the USB HID "main block" (usages @c 0x04-@c 0x39, i.e. @c VKM_KEY_A
* through @c VKM_KEY_CAPS_LOCK) plus @c VKM_KEY_INTL_BACKSLASH (@c 0x64). It excludes
* @c VKM_KEY_NONE, modifiers, function row, navigation cluster, keypad, and all other
* @ref vkm_key values; same rule as @c tools/x52compile_layout.py.
*/
#ifndef X52D_LAYOUT_USAGE_ALLOWLIST_H
#define X52D_LAYOUT_USAGE_ALLOWLIST_H
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Return whether @p usage may appear as the non-modifier byte in a layout entry.
*
* @param usage HID usage ID (page 0x07), same encoding as @ref vkm_key.
*
* @returns true if @p usage is in the main-block allowlist; false for @c VKM_KEY_NONE
* and for any disallowed usage.
*/
bool x52_layout_usage_key_allowed(uint8_t usage);
#ifdef __cplusplus
}
#endif
#endif /* X52D_LAYOUT_USAGE_ALLOWLIST_H */

View File

@ -0,0 +1,55 @@
/*
* Saitek X52 Pro MFD & LED driver - layout HID usage allowlist tests
*
* Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
*
* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
*/
#include "build-config.h"
#include <stdio.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <setjmp.h>
#include <cmocka.h>
#include <daemon/layout_usage_allowlist.h>
#include <vkm/vkm.h>
static void test_allows_main_block(void **state)
{
(void)state;
assert_true(x52_layout_usage_key_allowed(VKM_KEY_A));
assert_true(x52_layout_usage_key_allowed(VKM_KEY_Z));
assert_true(x52_layout_usage_key_allowed(VKM_KEY_1));
assert_true(x52_layout_usage_key_allowed(VKM_KEY_0));
assert_true(x52_layout_usage_key_allowed(VKM_KEY_SPACE));
assert_true(x52_layout_usage_key_allowed(VKM_KEY_CAPS_LOCK));
assert_true(x52_layout_usage_key_allowed(VKM_KEY_INTL_BACKSLASH));
assert_true(x52_layout_usage_key_allowed(VKM_KEY_NONUS_HASH));
}
static void test_rejects_disallowed(void **state)
{
(void)state;
assert_false(x52_layout_usage_key_allowed(0));
assert_false(x52_layout_usage_key_allowed(VKM_KEY_F1));
assert_false(x52_layout_usage_key_allowed(VKM_KEY_LEFT_CTRL));
assert_false(x52_layout_usage_key_allowed(VKM_KEY_KEYPAD_1));
assert_false(x52_layout_usage_key_allowed(0x03));
assert_false(x52_layout_usage_key_allowed(0x3A));
}
int main(void)
{
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_allows_main_block),
cmocka_unit_test(test_rejects_disallowed),
};
cmocka_set_message_output(CM_OUTPUT_TAP);
return cmocka_run_group_tests(tests, NULL, NULL);
}

View File

@ -26,6 +26,7 @@
#include <daemon/command.h>
#include <daemon/notify.h>
#include <daemon/x52dcomm-internal.h>
#include <daemon/keyboard_layout.h>
#include <libx52/x52dcomm.h>
#include "pinelog.h"
@ -364,6 +365,7 @@ int main(int argc, char **argv)
PINELOG_INFO(_("Received termination signal %s"), strsignal(flag_quit));
cleanup:
x52d_keyboard_layout_fini();
// Stop device threads
x52d_clock_exit();
x52d_dev_exit();

View File

@ -35,6 +35,10 @@ x52d_sources = [
'command.c',
'io.c',
'mouse_handler.c',
'layout_usage_allowlist.c',
'layout_load.c',
'keyboard_layout.c',
'crc32.c',
]
dep_threads = dependency('threads')
@ -60,6 +64,19 @@ exe_x52ctl = executable('x52ctl', 'daemon_control.c',
install_data('x52d.conf',
install_dir: join_paths(get_option('sysconfdir'), 'x52d'))
us_x52l = custom_target(
'us-x52l',
input: files('../data/layouts/us.layout'),
output: 'us.x52l',
command: [
python,
join_paths(meson.project_source_root(), 'tools', 'x52compile_layout.py'),
'@INPUT@',
'@OUTPUT@',
],
install: true,
install_dir: join_paths(get_option('datadir'), 'x52d'))
test('daemon-communication', files('test_daemon_comm.py')[0],
depends: [exe_x52d, exe_x52ctl], protocol: 'tap')
@ -70,6 +87,43 @@ x52d_mouse_test = executable('x52d-mouse-test', x52d_mouse_test_sources,
test('x52d-mouse-test', x52d_mouse_test, protocol: 'tap')
layout_usage_allowlist_test = executable('layout-usage-allowlist-test',
'layout_usage_allowlist_test.c',
'layout_usage_allowlist.c',
build_by_default: false,
include_directories: includes,
dependencies: [dep_cmocka, dep_config_h])
test('layout-usage-allowlist', layout_usage_allowlist_test, protocol: 'tap')
crc32_test = executable('crc32-test', 'crc32_test.c', 'crc32.c',
build_by_default: false,
include_directories: includes,
dependencies: [dep_cmocka, dep_config_h])
test('crc32', crc32_test, protocol: 'tap')
layout_load_test = executable('layout-load-test',
'layout_load_test.c',
'layout_load.c',
'layout_usage_allowlist.c',
'crc32.c',
build_by_default: false,
include_directories: includes,
dependencies: [dep_cmocka, dep_config_h])
test('layout-load', layout_load_test, protocol: 'tap')
pymod_daemon = import('python')
python_layout_test = pymod_daemon.find_installation('python3')
test('layout-usage-allowlist-sync', python_layout_test,
args: [join_paths(meson.project_source_root(), 'tools', 'test_layout_allowlist_sync.py')],
protocol: 'tap')
test('layout-compile-py', python_layout_test,
args: [join_paths(meson.project_source_root(), 'tools', 'test_x52compile_layout.py')],
protocol: 'tap')
# Install service file
if dep_systemd.found()
systemd_system_unit_dir = get_option('systemd-unit-dir')

View File

@ -134,7 +134,11 @@ class Test:
with open(daemon_cmdline[4], 'w', encoding='utf-8'):
pass
self.daemon = subprocess.Popen(daemon_cmdline) # pylint: disable=consider-using-with
env = os.environ.copy()
# Uninstalled build: us.x52l lives next to the x52d binary (see daemon/meson.build).
env['X52D_LAYOUT_DIR'] = os.path.dirname(self.program)
self.daemon = subprocess.Popen(daemon_cmdline, env=env) # pylint: disable=consider-using-with
print("# Sleeping 2 seconds for daemon to start")
time.sleep(2)

View File

@ -139,6 +139,10 @@ ClutchEnabled=no
# be held down to remain in clutch mode.
ClutchLatched=no
# KeyboardLayout is a basename only (alphanumeric, underscore, hyphen), not a path.
# Resolves to $datadir/x52d/<basename>.x52l; default us uses the installed us.x52l pack.
KeyboardLayout=us
##################
#X52 Input Servic#
#Version 0.3.3 #

View File

@ -0,0 +1,111 @@
# US QWERTY — main block (letters, digits, punctuation, space, controls)
name: us
description: US QWERTY main block
U+0008 BACKSPACE
U+0009 TAB
U+000A ENTER
U+000D ENTER
U+001B ESCAPE
U+0020 SPACE
a a
b b
c c
d d
e e
f f
g g
h h
i i
j j
k k
l l
m m
n n
o o
p p
q q
r r
s s
t t
u u
v v
w w
x x
y y
z z
A SHIFT+a
B SHIFT+b
C SHIFT+c
D SHIFT+d
E SHIFT+e
F SHIFT+f
G SHIFT+g
H SHIFT+h
I SHIFT+i
J SHIFT+j
K SHIFT+k
L SHIFT+l
M SHIFT+m
N SHIFT+n
O SHIFT+o
P SHIFT+p
Q SHIFT+q
R SHIFT+r
S SHIFT+s
T SHIFT+t
U SHIFT+u
V SHIFT+v
W SHIFT+w
X SHIFT+x
Y SHIFT+y
Z SHIFT+z
1 1
2 2
3 3
4 4
5 5
6 6
7 7
8 8
9 9
0 0
! SHIFT+1
@ SHIFT+2
# SHIFT+3
$ SHIFT+4
% SHIFT+5
^ SHIFT+6
& SHIFT+7
* SHIFT+8
( SHIFT+9
) SHIFT+0
_ SHIFT+MINUS
+ SHIFT+EQUAL
[ LEFT_BRACKET
{ SHIFT+LEFT_BRACKET
] RIGHT_BRACKET
} SHIFT+RIGHT_BRACKET
\ BACKSLASH
| SHIFT+BACKSLASH
; SEMICOLON
: SHIFT+SEMICOLON
' APOSTROPHE
" SHIFT+APOSTROPHE
` GRAVE
~ SHIFT+GRAVE
, COMMA
< SHIFT+COMMA
. PERIOD
> SHIFT+PERIOD
/ SLASH
? SHIFT+SLASH
- MINUS
= EQUAL

View File

@ -50,6 +50,7 @@ cdata.set_quoted('PACKAGE_NAME', meson.project_name())
cdata.set_quoted('LOCALEDIR', get_option('prefix') / get_option('localedir'))
cdata.set_quoted('SYSCONFDIR', get_option('prefix') / get_option('sysconfdir'))
cdata.set_quoted('LOCALSTATEDIR', get_option('prefix') / get_option('localstatedir'))
cdata.set_quoted('DATADIR', get_option('prefix') / get_option('datadir'))
cdata.set_quoted('PACKAGE_VERSION', meson.project_version())
cdata.set_quoted('VERSION', meson.project_version())
cdata.set10('ENABLE_NLS', not get_option('nls').disabled())

View File

@ -23,6 +23,7 @@ daemon/config_parser.c
daemon/daemon_control.c
daemon/device.c
daemon/io.c
daemon/keyboard_layout.c
daemon/main.c
daemon/mouse.c
daemon/mouse_handler.c

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: libx52 0.3.3\n"
"Report-Msgid-Bugs-To: https://github.com/nirenjan/libx52/issues\n"
"POT-Creation-Date: 2026-04-03 23:44-0700\n"
"POT-Creation-Date: 2026-04-04 23:11-0700\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -599,41 +599,41 @@ msgstr ""
msgid "Shutting down X52 clock manager thread"
msgstr ""
#: daemon/command.c:379
#: daemon/command.c:380
#, c-format
msgid "Error reading from client %d: %s"
msgstr ""
#: daemon/command.c:390
#: daemon/command.c:391
#, c-format
msgid "Short write to client %d; expected %d bytes, wrote %d bytes"
msgstr ""
#: daemon/command.c:415
#: daemon/command.c:416
#, c-format
msgid "Error %d during command loop: %s"
msgstr ""
#: daemon/command.c:442
#: daemon/command.c:443
#, c-format
msgid "Error creating command socket: %s"
msgstr ""
#: daemon/command.c:450
#: daemon/command.c:451
#, c-format
msgid "Error marking command socket as nonblocking: %s"
msgstr ""
#: daemon/command.c:456
#: daemon/command.c:457
#, c-format
msgid "Error listening on command socket: %s"
msgstr ""
#: daemon/command.c:460
#: daemon/command.c:461
msgid "Starting command processing thread"
msgstr ""
#: daemon/command.c:478
#: daemon/command.c:479
msgid "Shutting down command processing thread"
msgstr ""
@ -774,17 +774,41 @@ msgstr ""
msgid "Shutting down X52 I/O driver thread"
msgstr ""
#: daemon/main.c:67
#: daemon/keyboard_layout.c:69
msgid ""
"Invalid Profiles.KeyboardLayout value; using default layout basename 'us'"
msgstr ""
#: daemon/keyboard_layout.c:77
#, c-format
msgid "Keyboard layout '%s' could not be loaded (%s); loading default 'us'"
msgstr ""
#: daemon/keyboard_layout.c:83
#, c-format
msgid "Could not load keyboard layout from %s/x52d (%s)"
msgstr ""
#: daemon/keyboard_layout.c:92
#, c-format
msgid "Keyboard layout ready: %s (%s)"
msgstr ""
#: daemon/keyboard_layout.c:93
msgid "no description"
msgstr ""
#: daemon/main.c:68
#, c-format
msgid "Error %d setting log file: %s\n"
msgstr ""
#: daemon/main.c:83
#: daemon/main.c:84
#, c-format
msgid "Error %d installing handler for signal %d: %s"
msgstr ""
#: daemon/main.c:94
#: daemon/main.c:95
#, c-format
msgid ""
"Usage: %s [-f] [-v] [-q]\n"
@ -794,88 +818,88 @@ msgid ""
"\t[-b notify-socket-path]\n"
msgstr ""
#: daemon/main.c:129
#: daemon/main.c:130
#, c-format
msgid "Daemon is already running as PID %u"
msgstr ""
#: daemon/main.c:271
#: daemon/main.c:272
#, c-format
msgid "Unable to parse configuration override '%s'\n"
msgstr ""
#: daemon/main.c:303
#: daemon/main.c:304
#, c-format
msgid "Foreground = %s"
msgstr ""
#: daemon/main.c:303 daemon/main.c:304
#: daemon/main.c:304 daemon/main.c:305
msgid "true"
msgstr ""
#: daemon/main.c:303 daemon/main.c:304
#: daemon/main.c:304 daemon/main.c:305
msgid "false"
msgstr ""
#: daemon/main.c:304
#, c-format
msgid "Quiet = %s"
msgstr ""
#: daemon/main.c:305
#, c-format
msgid "Verbosity = %d"
msgid "Quiet = %s"
msgstr ""
#: daemon/main.c:306
#, c-format
msgid "Log file = %s"
msgid "Verbosity = %d"
msgstr ""
#: daemon/main.c:307
#, c-format
msgid "Config file = %s"
msgid "Log file = %s"
msgstr ""
#: daemon/main.c:308
#, c-format
msgid "PID file = %s"
msgid "Config file = %s"
msgstr ""
#: daemon/main.c:309
#, c-format
msgid "Command socket = %s"
msgid "PID file = %s"
msgstr ""
#: daemon/main.c:310
#, c-format
msgid "Command socket = %s"
msgstr ""
#: daemon/main.c:311
#, c-format
msgid "Notify socket = %s"
msgstr ""
#: daemon/main.c:321
#: daemon/main.c:322
#, c-format
msgid "Error %d blocking signals on child threads: %s"
msgstr ""
#: daemon/main.c:338
#: daemon/main.c:339
#, c-format
msgid "Error %d unblocking signals on child threads: %s"
msgstr ""
#: daemon/main.c:351
#: daemon/main.c:352
msgid "Reloading X52 configuration"
msgstr ""
#: daemon/main.c:358
#: daemon/main.c:359
msgid "Saving X52 configuration to disk"
msgstr ""
#: daemon/main.c:364
#: daemon/main.c:365
#, c-format
msgid "Received termination signal %s"
msgstr ""
#: daemon/main.c:379
#: daemon/main.c:381
msgid "Shutting down X52 daemon"
msgstr ""

View File

@ -5,10 +5,10 @@
#
msgid ""
msgstr ""
"Project-Id-Version: libx52 0.2.3\n"
"Project-Id-Version: libx52 0.3.3\n"
"Report-Msgid-Bugs-To: https://github.com/nirenjan/libx52/issues\n"
"POT-Creation-Date: 2026-04-03 23:44-0700\n"
"PO-Revision-Date: 2026-04-01 20:50-0700\n"
"POT-Creation-Date: 2026-04-04 23:11-0700\n"
"PO-Revision-Date: 2026-04-04 12:00-0700\n"
"Last-Translator: Nirenjan Krishnan <nirenjan@gmail.com>\n"
"Language-Team: Dummy Language for testing i18n\n"
"Language: xx_PL\n"
@ -645,42 +645,42 @@ msgstr "Erroray %d initializingay ockclay eadthray: %s"
msgid "Shutting down X52 clock manager thread"
msgstr "Uttingshay ownday X52 ockclay anagermay eadthray"
#: daemon/command.c:379
#: daemon/command.c:380
#, c-format
msgid "Error reading from client %d: %s"
msgstr "Erroray eadingray omfray ientclay %d: %s"
#: daemon/command.c:390
#: daemon/command.c:391
#, c-format
msgid "Short write to client %d; expected %d bytes, wrote %d bytes"
msgstr ""
"Ortshay itewray otay ientclay %d; expecteday %d ytesbay, otewray %d ytesbay"
#: daemon/command.c:415
#: daemon/command.c:416
#, c-format
msgid "Error %d during command loop: %s"
msgstr "Erroray %d uringday ommandcay ooplay: %s"
#: daemon/command.c:442
#: daemon/command.c:443
#, c-format
msgid "Error creating command socket: %s"
msgstr "Erroray eatingcray ommandcay ocketsay: %s"
#: daemon/command.c:450
#: daemon/command.c:451
#, c-format
msgid "Error marking command socket as nonblocking: %s"
msgstr "Erroray arkingmay ommandcay ocketsay asay onblockingnay: %s"
#: daemon/command.c:456
#: daemon/command.c:457
#, c-format
msgid "Error listening on command socket: %s"
msgstr "Erroray isteninglay onay ommandcay ocketsay: %s"
#: daemon/command.c:460
#: daemon/command.c:461
msgid "Starting command processing thread"
msgstr "Artingstay ommandcay ocessingpray eadthray"
#: daemon/command.c:478
#: daemon/command.c:479
msgid "Shutting down command processing thread"
msgstr "Uttingshay ownday ommandcay ocessingpray eadthray"
@ -821,17 +821,46 @@ msgstr "Erroray %d initializingay I/O iverdray eadthray: %s"
msgid "Shutting down X52 I/O driver thread"
msgstr "Uttingshay ownday X52 I/O iverdray eadthray"
#: daemon/main.c:67
#: daemon/keyboard_layout.c:69
msgid ""
"Invalid Profiles.KeyboardLayout value; using default layout basename 'us'"
msgstr ""
"Invaliday Ofilespray.EyboardKayayoutLay aluevay; usingay efaultday ayoutlay "
"asenambay 'us'"
#: daemon/keyboard_layout.c:77
#, c-format
msgid "Keyboard layout '%s' could not be loaded (%s); loading default 'us'"
msgstr ""
"EyboardKay ayoutlay '%s' ouldcay otnay ebay oadedlay (%s); oadinglay "
"efaultday 'us'"
#: daemon/keyboard_layout.c:83
#, c-format
msgid "Could not load keyboard layout from %s/x52d (%s)"
msgstr ""
"ouldCay otnay oadlay eyboardkay ayoutlay omfray %s/x52d (%s)"
#: daemon/keyboard_layout.c:92
#, c-format
msgid "Keyboard layout ready: %s (%s)"
msgstr "EyboardKay ayoutlay eadyray: %s (%s)"
#: daemon/keyboard_layout.c:93
msgid "no description"
msgstr "onay escriptionday"
#: daemon/main.c:68
#, c-format
msgid "Error %d setting log file: %s\n"
msgstr "Erroray %d ettingsay oglay ilefay: %s\n"
#: daemon/main.c:83
#: daemon/main.c:84
#, c-format
msgid "Error %d installing handler for signal %d: %s"
msgstr "Erroray %d installingay andlerhay orfay ignalsay %d: %s"
#: daemon/main.c:94
#: daemon/main.c:95
#, c-format
msgid ""
"Usage: %s [-f] [-v] [-q]\n"
@ -847,88 +876,88 @@ msgstr ""
"\t[-b otifynay-ocketsay-athpay]\n"
"\n"
#: daemon/main.c:129
#: daemon/main.c:130
#, c-format
msgid "Daemon is already running as PID %u"
msgstr "Aemonday isay alreadyay unningray asay IDPay %u"
#: daemon/main.c:271
#: daemon/main.c:272
#, c-format
msgid "Unable to parse configuration override '%s'\n"
msgstr "Unableay otay arsepay onfigurationcay overrideay '%s'\n"
#: daemon/main.c:303
#: daemon/main.c:304
#, c-format
msgid "Foreground = %s"
msgstr "Oregroundfay = %s"
#: daemon/main.c:303 daemon/main.c:304
#: daemon/main.c:304 daemon/main.c:305
msgid "true"
msgstr "uetray"
#: daemon/main.c:303 daemon/main.c:304
#: daemon/main.c:304 daemon/main.c:305
msgid "false"
msgstr "alsefay"
#: daemon/main.c:304
#: daemon/main.c:305
#, c-format
msgid "Quiet = %s"
msgstr "Uietqay = %s"
#: daemon/main.c:305
#: daemon/main.c:306
#, c-format
msgid "Verbosity = %d"
msgstr "Erbosityvay = %d"
#: daemon/main.c:306
#: daemon/main.c:307
#, c-format
msgid "Log file = %s"
msgstr "Oglay ilefay = %s"
#: daemon/main.c:307
#: daemon/main.c:308
#, c-format
msgid "Config file = %s"
msgstr "Onfigcay ilefay = %s"
#: daemon/main.c:308
#: daemon/main.c:309
#, c-format
msgid "PID file = %s"
msgstr "IDPay ilefay = %s"
#: daemon/main.c:309
#: daemon/main.c:310
#, c-format
msgid "Command socket = %s"
msgstr "Ommandcay ocketsay = %s"
#: daemon/main.c:310
#: daemon/main.c:311
#, c-format
msgid "Notify socket = %s"
msgstr "Otifynay ocketsay = %s"
#: daemon/main.c:321
#: daemon/main.c:322
#, c-format
msgid "Error %d blocking signals on child threads: %s"
msgstr "Erroray %d ockingblay ignalssay onay ildchay eadsthray: %s"
#: daemon/main.c:338
#: daemon/main.c:339
#, c-format
msgid "Error %d unblocking signals on child threads: %s"
msgstr "Erroray %d unblockingay ignalssay onay ildchay eadsthray: %s"
#: daemon/main.c:351
#: daemon/main.c:352
msgid "Reloading X52 configuration"
msgstr "Eloadingray X52 onfigurationcay"
#: daemon/main.c:358
#: daemon/main.c:359
msgid "Saving X52 configuration to disk"
msgstr "Avingsay X52 onfigurationcay otay iskday"
#: daemon/main.c:364
#: daemon/main.c:365
#, c-format
msgid "Received termination signal %s"
msgstr "Eceivedray erminationtay ignalsay %s"
#: daemon/main.c:379
#: daemon/main.c:381
msgid "Shutting down X52 daemon"
msgstr "Uttingshay ownday X52 aemonday"

View File

@ -0,0 +1,106 @@
#!/usr/bin/env python3
# Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
#
# SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
"""Regression checks for layout HID allowlist and token maps (see vkm.h / layout_usage_allowlist.c)."""
import pathlib
import re
import sys
def _emit_tap(test_cases):
"""test_cases: iterable of (description, callable). Callable raises on failure."""
case_list = list(test_cases)
print("TAP version 13")
print("1..%d" % len(case_list))
failed = 0
for i, (desc, fn) in enumerate(case_list, 1):
try:
fn()
except AssertionError as e:
failed += 1
print("not ok %d - %s" % (i, desc))
msg = str(e) if str(e) else "assertion failed"
for line in msg.splitlines():
print("# %s" % line)
else:
print("ok %d - %s" % (i, desc))
return 1 if failed else 0
def main():
root = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(root / "tools"))
import x52compile_layout as x
def check_allowed_set():
expected = frozenset(range(0x04, 0x3A)) | frozenset((0x64,))
assert x.ALLOWED_KEY_USAGES == expected, "%r != %r" % (x.ALLOWED_KEY_USAGES, expected)
def check_allowlist_c_range():
src = (root / "daemon" / "layout_usage_allowlist.c").read_text(encoding="utf-8")
assert "usage >= 0x04 && usage <= 0x39" in src, (
"daemon/layout_usage_allowlist.c: expected main-block range check"
)
def check_allowlist_c_intl():
src = (root / "daemon" / "layout_usage_allowlist.c").read_text(encoding="utf-8")
assert "usage == 0x64" in src, (
"daemon/layout_usage_allowlist.c: expected INTL_BACKSLASH (0x64) check"
)
def check_vkm_modifier_symbols():
vkm = (root / "vkm" / "vkm.h").read_text(encoding="utf-8")
for name in (
"VKM_KEY_MOD_LCTRL",
"VKM_KEY_MOD_LSHIFT",
"VKM_KEY_MOD_LALT",
"VKM_KEY_MOD_LGUI",
"VKM_KEY_MOD_RCTRL",
"VKM_KEY_MOD_RSHIFT",
"VKM_KEY_MOD_RALT",
"VKM_KEY_MOD_RGUI",
):
assert re.search(r"%s\s*=\s*\([^)]+\)" % re.escape(name), vkm), (
"could not find %s in vkm.h" % name
)
def check_modifier_bits():
assert x.MODIFIER_TOKEN_TO_BIT["LCTRL"] == 1 << 0 and x.MODIFIER_TOKEN_TO_BIT["RALT"] == 1 << 6, (
"modifier map mismatch vs expected HID bit layout"
)
def check_tokens_in_allowlist():
for _tok, usage in x.KEY_TOKEN_TO_USAGE.items():
assert usage in x.ALLOWED_KEY_USAGES, (
"token maps outside allowlist: %r -> %#x" % (_tok, usage)
)
def check_usage_from_key_token_a():
assert x.usage_from_key_token("a") == 0x04
def check_usage_from_key_token_space():
assert x.usage_from_key_token("SPACE") == 0x2C
def check_modifier_mask_ctrl_shift():
assert x.modifier_mask_from_tokens("CTRL+SHIFT") == (1 << 0) | (1 << 1)
tests = (
("ALLOWED_KEY_USAGES matches main block + INTL_BACKSLASH", check_allowed_set),
("layout_usage_allowlist.c main-block range", check_allowlist_c_range),
("layout_usage_allowlist.c INTL_BACKSLASH (0x64)", check_allowlist_c_intl),
("vkm.h modifier symbol names", check_vkm_modifier_symbols),
("MODIFIER_TOKEN_TO_BIT LCTRL/RALT bits", check_modifier_bits),
("KEY_TOKEN_TO_USAGE subset of allowlist", check_tokens_in_allowlist),
("usage_from_key_token(a)", check_usage_from_key_token_a),
("usage_from_key_token(SPACE)", check_usage_from_key_token_space),
("modifier_mask_from_tokens(CTRL+SHIFT)", check_modifier_mask_ctrl_shift),
)
return _emit_tap(tests)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,342 @@
#!/usr/bin/env python3
# Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
#
# SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
"""TAP tests for ``tools/x52compile_layout.py`` (v1 ``.x52l`` compiler)."""
import pathlib
import struct
import subprocess
import sys
import tempfile
import zlib
def _emit_tap(test_cases):
"""test_cases: iterable of (description, callable). Callable raises on failure."""
case_list = list(test_cases)
print("TAP version 13")
print("1..%d" % len(case_list))
failed = 0
for i, (desc, fn) in enumerate(case_list, 1):
try:
fn()
except AssertionError as e:
failed += 1
print("not ok %d - %s" % (i, desc))
msg = str(e) if str(e) else "assertion failed"
for line in msg.splitlines():
print("# %s" % line)
else:
print("ok %d - %s" % (i, desc))
return 1 if failed else 0
def _x52_crc32_verify(buf):
"""CRC-32 over file with checksum field (bytes 12..15) taken as zero."""
return zlib.crc32(buf[:12] + b"\0\0\0\0" + buf[16:]) & 0xFFFFFFFF
def main():
root = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(root / "tools"))
import x52compile_layout as x
def check_layout_compile_error_is_syntax_error():
assert issubclass(x.LayoutCompileError, SyntaxError)
def check_parse_chord_simple():
mod, usage = x.parse_chord("a")
assert mod == 0 and usage == 0x04
def check_parse_chord_shifted_letter():
mod, usage = x.parse_chord("SHIFT+a")
assert mod == (1 << 1) and usage == 0x04
def check_parse_chord_space_token():
mod, usage = x.parse_chord("SPACE")
assert mod == 0 and usage == 0x2C
def check_compile_minimal_header_and_entry():
src = "name: t\na a\n"
buf = x.compile_layout_source("minimal.layout", src)
assert buf[0:4] == b"X52L"
assert struct.unpack_from("!H", buf, 4)[0] == x.FORMAT_VERSION
assert struct.unpack_from("!H", buf, 6)[0] == 0
limit = struct.unpack_from("!I", buf, 8)[0]
assert limit == 98
assert len(buf) == x.HEADER_BYTES + 2 * limit
off = x.HEADER_BYTES + 2 * ord('a')
assert buf[off] == 0 and buf[off + 1] == 0x04
stored = struct.unpack_from("!I", buf, 12)[0]
assert stored == _x52_crc32_verify(buf)
def check_compile_name_and_description_fields():
src = "name: demo\ndescription: hello\nU+0020 SPACE\n"
buf = x.compile_layout_source("meta.layout", src)
name_end = buf.index(b"\0", 16)
assert buf[16:name_end] == b"demo"
desc_end = buf.index(b"\0", 48)
assert buf[48:desc_end] == b"hello"
space_off = x.HEADER_BYTES + 2 * 0x20
assert buf[space_off + 1] == 0x2C
def check_compile_shift_uppercase_mapping():
src = "name: u\nA SHIFT+a\n"
buf = x.compile_layout_source("up.layout", src)
limit = struct.unpack_from("!I", buf, 8)[0]
assert limit == ord("A") + 1
off = x.HEADER_BYTES + 2 * ord("A")
assert buf[off] == (1 << 1) and buf[off + 1] == 0x04
def check_reject_missing_name():
try:
x.compile_layout_source("bad.layout", "a a\n")
except x.LayoutCompileError as e:
assert "missing name" in str(e)
else:
assert False, "expected LayoutCompileError"
def check_reject_no_mappings():
try:
x.compile_layout_source("bad.layout", "name: x\n")
except x.LayoutCompileError as e:
assert "no mappings" in str(e)
else:
assert False, "expected LayoutCompileError"
def check_reject_duplicate_codepoint():
src = "name: x\na a\na b\n"
try:
x.compile_layout_source("dup.layout", src)
except x.LayoutCompileError as e:
assert "duplicate" in str(e) and "U+0061" in str(e)
assert "line" in str(e)
else:
assert False, "expected LayoutCompileError"
def check_reject_invalid_scalar():
src = "name: x\nU+D800 SPACE\n"
try:
x.compile_layout_source("surrogate.layout", src)
except x.LayoutCompileError as e:
assert "invalid Unicode scalar" in str(e)
assert e.filename == "surrogate.layout"
assert e.lineno == 2
else:
assert False, "expected LayoutCompileError"
def check_reject_scalar_above_unicode_max():
src = "name: x\nU+110000 SPACE\n"
try:
x.compile_layout_source("high.layout", src)
except x.LayoutCompileError as e:
assert "invalid Unicode scalar" in str(e)
else:
assert False, "expected LayoutCompileError"
def check_reject_bad_chord_line():
src = "name: x\na NOT_A_KEY\n"
try:
x.compile_layout_source("chord.layout", src)
except x.LayoutCompileError as e:
assert "unknown key token" in str(e)
assert e.lineno == 2
else:
assert False, "expected LayoutCompileError"
def check_reject_oversize_name():
long_name = "n" * 32
src = "name: %s\na a\n" % long_name
try:
x.compile_layout_source("long.layout", src)
except x.LayoutCompileError as e:
assert "exceeds" in str(e) and "UTF-8" in str(e)
else:
assert False, "expected LayoutCompileError"
def check_default_allows_name_at_31_bytes():
name = "n" * 31
src = "name: %s\na a\n" % name
buf = x.compile_layout_source("ok31.layout", src)
name_end = buf.index(b"\0", 16)
assert buf[16:name_end] == name.encode("utf-8")
assert struct.unpack_from("!H", buf, 6)[0] == 0
def check_default_allows_description_at_63_bytes():
desc = "d" * 63
src = "name: ok\ndescription: %s\na a\n" % desc
buf = x.compile_layout_source("ok63.layout", src)
desc_end = buf.index(b"\0", 48)
assert buf[48:desc_end] == desc.encode("utf-8")
assert struct.unpack_from("!H", buf, 6)[0] == 0
def check_reject_invalid_layout_name():
for bad in ("bad/name", "a b", "caf\u00e9", "dot."):
src = "name: %s\na a\n" % bad
try:
x.compile_layout_source("name.layout", src)
except x.LayoutCompileError as e:
assert "ASCII letters" in str(e) or "hyphen" in str(e)
else:
assert False, "expected LayoutCompileError for %r" % bad
def check_compile_name_hyphen_underscore():
src = "name: a-b_9\na a\n"
buf = x.compile_layout_source("hy.layout", src)
name_end = buf.index(b"\0", 16)
assert buf[16:name_end] == b"a-b_9"
def check_reject_oversize_description():
long_desc = "d" * 64
src = "name: x\ndescription: %s\na a\n" % long_desc
try:
x.compile_layout_source("longdesc.layout", src)
except x.LayoutCompileError as e:
assert "description exceeds" in str(e)
else:
assert False, "expected LayoutCompileError"
def check_truncate_metadata_sets_name_flag_and_field():
long_name = "n" * 32
src = "name: %s\na a\n" % long_name
buf = x.compile_layout_source("trunc.layout", src, truncate_metadata=True)
assert struct.unpack_from("!H", buf, 6)[0] == x.FLAG_NAME_TRUNCATED
name_end = buf.index(b"\0", 16)
assert buf[16:name_end] == (b"n" * 31)
assert struct.unpack_from("!I", buf, 12)[0] == _x52_crc32_verify(buf)
def check_truncate_metadata_sets_description_flag():
long_desc = "d" * 64
src = "name: ok\ndescription: %s\na a\n" % long_desc
buf = x.compile_layout_source("truncdesc.layout", src, truncate_metadata=True)
assert struct.unpack_from("!H", buf, 6)[0] == x.FLAG_DESCRIPTION_TRUNCATED
desc_end = buf.index(b"\0", 48)
assert buf[48:desc_end] == (b"d" * 63)
def check_truncate_metadata_sets_both_flags_when_both_long():
long_name = "n" * 32
long_desc = "d" * 64
src = "name: %s\ndescription: %s\na a\n" % (long_name, long_desc)
buf = x.compile_layout_source("bothtrunc.layout", src, truncate_metadata=True)
f = struct.unpack_from("!H", buf, 6)[0]
assert f == (x.FLAG_NAME_TRUNCATED | x.FLAG_DESCRIPTION_TRUNCATED)
name_end = buf.index(b"\0", 16)
assert buf[16:name_end] == (b"n" * 31)
desc_end = buf.index(b"\0", 48)
assert buf[48:desc_end] == (b"d" * 63)
assert struct.unpack_from("!I", buf, 12)[0] == _x52_crc32_verify(buf)
def check_truncate_metadata_utf8_safe_no_split():
# Names are ASCII-only; exercise UTF-8 trim on description (32 × "é" → 63-byte cap).
long_desc = "\u00e9" * 32
src = "name: ok\ndescription: %s\na a\n" % long_desc
buf = x.compile_layout_source("utf8.layout", src, truncate_metadata=True)
assert struct.unpack_from("!H", buf, 6)[0] == x.FLAG_DESCRIPTION_TRUNCATED
desc_end = buf.index(b"\0", 48)
assert buf[48:desc_end].decode("utf-8") == "\u00e9" * 31
def check_data_layouts_us_layout_compiles():
us_path = root / "data" / "layouts" / "us.layout"
if not us_path.is_file():
return
text = us_path.read_text(encoding="utf-8")
buf = x.compile_layout_source(str(us_path), text)
assert buf[0:4] == b"X52L"
limit = struct.unpack_from("!I", buf, 8)[0]
assert len(buf) == x.HEADER_BYTES + 2 * limit
assert struct.unpack_from("!I", buf, 12)[0] == _x52_crc32_verify(buf)
def check_compile_layout_file_rejects_mismatched_output_basename():
with tempfile.TemporaryDirectory() as td:
src = pathlib.Path(td) / "in.layout"
src.write_text("name: us\na a\n", encoding="utf-8")
bad = pathlib.Path(td) / "other.x52l"
try:
x.compile_layout_file(str(src), str(bad))
except x.LayoutCompileError as e:
assert "us.x52l" in str(e) and "other.x52l" in str(e)
else:
assert False, "expected LayoutCompileError"
def check_compile_layout_file_accepts_matching_basename():
with tempfile.TemporaryDirectory() as td:
src = pathlib.Path(td) / "in.layout"
src.write_text("name: us\na a\n", encoding="utf-8")
out = pathlib.Path(td) / "us.x52l"
x.compile_layout_file(str(src), str(out))
assert out.is_file()
assert out.stat().st_size == x.HEADER_BYTES + 2 * 98
def check_compile_layout_file_truncated_name_sets_output_basename():
long_name = "n" * 32
with tempfile.TemporaryDirectory() as td:
src = pathlib.Path(td) / "in.layout"
src.write_text("name: %s\na a\n" % long_name, encoding="utf-8")
# Stored name is 31 × "n" → file must be nnn... (31) + .x52l
out = pathlib.Path(td) / ("n" * 31 + ".x52l")
x.compile_layout_file(str(src), str(out), truncate_metadata=True)
assert out.is_file()
def check_cli_truncate_metadata_sets_flags_in_output():
long_name = "n" * 32
long_desc = "d" * 64
script = root / "tools" / "x52compile_layout.py"
with tempfile.TemporaryDirectory() as td:
td = pathlib.Path(td)
src = td / "in.layout"
src.write_text(
"name: %s\ndescription: %s\na a\n" % (long_name, long_desc),
encoding="utf-8",
)
out = td / ("n" * 31 + ".x52l")
r = subprocess.run(
[sys.executable, str(script), "--truncate-metadata", str(src), str(out)],
cwd=str(root),
capture_output=True,
text=True,
check=False,
)
assert r.returncode == 0, r.stderr
buf = out.read_bytes()
f = struct.unpack_from("!H", buf, 6)[0]
assert f == (x.FLAG_NAME_TRUNCATED | x.FLAG_DESCRIPTION_TRUNCATED)
assert struct.unpack_from("!I", buf, 12)[0] == _x52_crc32_verify(buf)
tests = (
("LayoutCompileError subclasses SyntaxError", check_layout_compile_error_is_syntax_error),
("parse_chord(a)", check_parse_chord_simple),
("parse_chord(SHIFT+a)", check_parse_chord_shifted_letter),
("parse_chord(SPACE)", check_parse_chord_space_token),
("compile minimal layout: header, size, entry, CRC", check_compile_minimal_header_and_entry),
("compile name/description and U+ mapping", check_compile_name_and_description_fields),
("compile SHIFT+a for uppercase letter", check_compile_shift_uppercase_mapping),
("reject missing name:", check_reject_missing_name),
("reject no mappings", check_reject_no_mappings),
("reject duplicate codepoint", check_reject_duplicate_codepoint),
("reject surrogate scalar U+D800", check_reject_invalid_scalar),
("reject scalar U+110000", check_reject_scalar_above_unicode_max),
("reject unknown key token with lineno", check_reject_bad_chord_line),
("reject name longer than 31 UTF-8 bytes", check_reject_oversize_name),
("default: name length 31 UTF-8 bytes accepted", check_default_allows_name_at_31_bytes),
("default: description length 63 UTF-8 bytes accepted", check_default_allows_description_at_63_bytes),
("reject layout name outside ASCII alnum hyphen underscore", check_reject_invalid_layout_name),
("compile name with hyphen and underscore", check_compile_name_hyphen_underscore),
("reject description longer than 63 UTF-8 bytes", check_reject_oversize_description),
("--truncate-metadata: name flag and 31-byte field", check_truncate_metadata_sets_name_flag_and_field),
("truncate_metadata: description flag and 63-byte field", check_truncate_metadata_sets_description_flag),
("truncate_metadata: both long name and description set both flags", check_truncate_metadata_sets_both_flags_when_both_long),
("truncate_metadata: UTF-8 safe trim (no split codepoint)", check_truncate_metadata_utf8_safe_no_split),
("CLI --truncate-metadata sets composite flags and valid CRC", check_cli_truncate_metadata_sets_flags_in_output),
("data/layouts/us.layout compiles with valid CRC", check_data_layouts_us_layout_compiles),
("compile_layout_file rejects wrong output basename", check_compile_layout_file_rejects_mismatched_output_basename),
("compile_layout_file writes when basename matches name:", check_compile_layout_file_accepts_matching_basename),
("compile_layout_file: truncated stored name defines output basename", check_compile_layout_file_truncated_name_sets_output_basename),
)
return _emit_tap(tests)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,436 @@
#!/usr/bin/env python3
# Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
#
# SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
"""Keyboard layout source (``.layout``) compiler and HID token maps.
Compiles human-readable ``.layout`` files into v1 ``.x52l`` binaries (see
``daemon/layout_format.h``). The output file basename must be
``{name}.x52l`` with ``name`` taken from the ``name:`` metadata (after any
``--truncate-metadata`` trimming), matching ``Profiles.KeyboardLayout``.
The non-modifier key set matches
``x52_layout_usage_key_allowed`` in ``daemon/layout_usage_allowlist.c`` and
``vkm_key`` / ``vkm_key_modifiers`` in ``vkm/vkm.h``.
"""
import argparse
import os
import re
import struct
import sys
import zlib
from typing import Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# HID page 0x07 usages allowed as the layout RHS key (one byte, non-modifier).
# Main block: 0x04 (A) through 0x39 (Caps Lock); plus ISO third row backslash (0x64).
# ---------------------------------------------------------------------------
ALLOWED_KEY_USAGES = frozenset(range(0x04, 0x3A)) | frozenset((0x64,))
def _letter_usage(ch: str) -> int:
o = ord(ch)
if not (ord("A") <= o <= ord("Z") or ord("a") <= o <= ord("z")):
raise ValueError(ch)
return 0x04 + (ord(ch.upper()) - ord("A"))
def _build_key_token_map() -> Dict[str, int]:
m: Dict[str, int] = {}
for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
u = _letter_usage(c)
m[c] = u
m[c.lower()] = u
digit_usages = [0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27]
for d, u in zip("1234567890", digit_usages):
m[d] = u
named = {
"ENTER": 0x28,
"ESCAPE": 0x29,
"ESC": 0x29,
"BACKSPACE": 0x2A,
"TAB": 0x2B,
"SPACE": 0x2C,
"MINUS": 0x2D,
"EQUAL": 0x2E,
"LEFT_BRACKET": 0x2F,
"RIGHT_BRACKET": 0x30,
"BACKSLASH": 0x31,
"NONUS_HASH": 0x32,
"SEMICOLON": 0x33,
"APOSTROPHE": 0x34,
"GRAVE_ACCENT": 0x35,
"GRAVE": 0x35,
"COMMA": 0x36,
"PERIOD": 0x37,
"SLASH": 0x38,
"CAPS_LOCK": 0x39,
"CAPS": 0x39,
"INTL_BACKSLASH": 0x64,
}
m.update(named)
bad = {k: v for k, v in m.items() if v not in ALLOWED_KEY_USAGES}
if bad:
raise RuntimeError("internal error: token maps to disallowed usage: %r" % bad)
return m
KEY_TOKEN_TO_USAGE: Dict[str, int] = _build_key_token_map()
# Matches vkm_key_modifiers in vkm/vkm.h (HID modifier byte bits).
MODIFIER_TOKEN_TO_BIT: Dict[str, int] = {
"LCTRL": 1 << 0,
"LSHIFT": 1 << 1,
"LALT": 1 << 2,
"LGUI": 1 << 3,
"RCTRL": 1 << 4,
"RSHIFT": 1 << 5,
"RALT": 1 << 6,
"RGUI": 1 << 7,
# Convenience aliases — same as VKM_KEY_MOD_* macros in vkm.h
"CTRL": 1 << 0,
"SHIFT": 1 << 1,
"ALT": 1 << 2,
"GUI": 1 << 3,
}
_MOD_KNOWN_BITS = 0
for _b in MODIFIER_TOKEN_TO_BIT.values():
_MOD_KNOWN_BITS |= _b
if _MOD_KNOWN_BITS != 0xFF:
raise RuntimeError("internal error: modifier map must cover HID bits 0-7")
def modifier_mask_from_tokens(tokens: str) -> int:
"""OR together modifier bits for plus-separated tokens (e.g. ``'CTRL+SHIFT'``)."""
mask = 0
for raw in tokens.replace(" ", "").split("+"):
if not raw:
continue
try:
bit = MODIFIER_TOKEN_TO_BIT[raw.upper()]
except KeyError as e:
raise KeyError("unknown modifier token %r" % raw) from e
mask |= bit
return mask
def usage_from_key_token(token: str) -> int:
"""Resolve a single key token string to a HID usage (page 0x07)."""
key = token.strip()
if key.isalpha() and len(key) == 1:
u = KEY_TOKEN_TO_USAGE[key]
else:
u = KEY_TOKEN_TO_USAGE[key.upper()]
return u
class LayoutCompileError(SyntaxError):
"""Invalid layout source or oversize metadata.
Subclasses :class:`SyntaxError` so errors can carry ``filename`` and ``lineno``
and render as ``message (path, line N)``. Pass ``path`` and ``lineno`` only
when ``message`` is the detail alone; for whole-file problems pass one fully
formatted string and leave location arguments unset.
"""
def __init__(self, message, path=None, lineno=None):
if path is not None and lineno is not None:
SyntaxError.__init__(self, message, (path, lineno, None, None))
else:
SyntaxError.__init__(self, message)
HEADER_BYTES = 128
FORMAT_VERSION = 1
CODEPOINT_LIMIT_MAX = 0x110000
NAME_MAX_UTF8 = 31
DESC_MAX_UTF8 = 63
# v1 header flags (big-endian on disk); must match daemon/layout_format.h
FLAG_NAME_TRUNCATED = 1
FLAG_DESCRIPTION_TRUNCATED = 2
FLAGS_KNOWN_V1 = FLAG_NAME_TRUNCATED | FLAG_DESCRIPTION_TRUNCATED
# Fixed 128-byte header: magic, version, flags, codepoint_limit, checksum (BE).
_HEADER_PREFIX = struct.Struct("!4sHHII")
# Layout basename / ``name:`` field: matches safe config tokens (see Profiles.KeyboardLayout).
_LAYOUT_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
def _validate_layout_name(name: str, path: str) -> None:
if not _LAYOUT_NAME_RE.fullmatch(name):
raise LayoutCompileError(
"%s: layout name must contain only ASCII letters, digits, hyphen, or underscore"
% path
)
def _utf8_trim_to_max_bytes(text: str, max_bytes: int) -> Tuple[str, bool]:
"""Return ``(s, truncated)`` with ``len(s.encode('utf-8')) <= max_bytes``."""
raw = text.encode("utf-8")
if len(raw) <= max_bytes:
return text, False
cut = raw[:max_bytes]
while cut:
try:
return cut.decode("utf-8"), True
except UnicodeDecodeError:
cut = cut[:-1]
return "", True
def parse_chord(
chord: str, path: Optional[str] = None, lineno: Optional[int] = None
) -> Tuple[int, int]:
"""Return ``(modifiers_byte, usage)`` for a plus-separated HID chord.
If ``path`` and ``lineno`` are given, :exc:`LayoutCompileError` includes that
location (via :class:`SyntaxError`); omit them for programmatic chord checks.
"""
parts = [p.strip() for p in chord.replace(" ", "").split("+") if p.strip()]
if not parts:
raise LayoutCompileError("empty chord", path, lineno)
key_tok = parts[-1]
mod_parts = parts[:-1]
try:
mod = modifier_mask_from_tokens("+".join(mod_parts)) if mod_parts else 0
except KeyError as e:
raise LayoutCompileError("unknown modifier in %r" % chord, path, lineno) from e
try:
usage = usage_from_key_token(key_tok)
except KeyError as e:
raise LayoutCompileError("unknown key token in %r" % chord, path, lineno) from e
if usage not in ALLOWED_KEY_USAGES:
raise LayoutCompileError("disallowed key usage for %r" % chord, path, lineno)
return mod & 0xFF, usage & 0xFF
_U_LINE = re.compile(r"^[Uu]\+([0-9A-Fa-f]{1,6})\s+(.+)$")
_META_NAME = re.compile(r"(?i)^name:\s*(.*)$")
_META_DESC = re.compile(r"(?i)^description:\s*(.*)$")
def _parse_layout_source(
path: str, text: str
) -> Tuple[str, str, List[Tuple[int, int, str]]]:
"""First pass: metadata and mapping lines. Returns ``(name, description, raw_maps)``."""
name = "" # type: str
description = "" # type: str
raw_maps = [] # type: List[Tuple[int, int, str]]
for lineno, line in enumerate(text.splitlines(), 1):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
mn = _META_NAME.match(stripped)
if mn:
name = mn.group(1).strip()
continue
md = _META_DESC.match(stripped)
if md:
description = md.group(1).strip()
continue
cp, chord = _parse_mapping_line(path, lineno, stripped)
raw_maps.append((cp, lineno, chord))
if not name:
raise LayoutCompileError("%s: missing name: metadata" % path)
return name, description, raw_maps
def _layout_stored_name_from_bytes(buf: bytes) -> str:
"""UTF-8 layout name as stored in the v1 header field (offset 16, up to first NUL)."""
end = buf.index(0, 16, 48)
return buf[16:end].decode("utf-8")
def _parse_mapping_line(path: str, lineno: int, line: str) -> Tuple[int, str]:
s = line.strip()
m = _U_LINE.match(s)
if m:
cp = int(m.group(1), 16)
if cp < 0 or cp > 0x10FFFF or (cp >= 0xD800 and cp <= 0xDFFF):
raise LayoutCompileError("invalid Unicode scalar", path, lineno)
return cp, m.group(2).strip()
if not s:
raise LayoutCompileError("empty line", path, lineno)
ch = s[0]
rest = s[1:].lstrip()
if not rest:
raise LayoutCompileError("missing chord after character", path, lineno)
return ord(ch), rest
def compile_layout_source(path: str, text: str, *, truncate_metadata: bool = False) -> bytes:
"""Parse ``.layout`` source and return ``.x52l`` bytes.
``codepoint_limit`` is ``max_mapped_scalar + 1`` (dense table size).
If ``truncate_metadata`` is false (default), oversize ``name:`` / ``description:``
values are errors. If true, UTF-8 strings are trimmed to the field limits
(no split codepoints) and ``FLAG_NAME_TRUNCATED`` / ``FLAG_DESCRIPTION_TRUNCATED``
are set in the on-disk ``flags`` field.
"""
name, description, raw_maps = _parse_layout_source(path, text)
flags = 0
if truncate_metadata:
name, name_t = _utf8_trim_to_max_bytes(name, NAME_MAX_UTF8)
if name_t:
flags |= FLAG_NAME_TRUNCATED
if not name:
raise LayoutCompileError("%s: name: empty after truncation to metadata limit" % path)
description, desc_t = _utf8_trim_to_max_bytes(description, DESC_MAX_UTF8)
if desc_t:
flags |= FLAG_DESCRIPTION_TRUNCATED
else:
name_b = name.encode("utf-8")
if len(name_b) > NAME_MAX_UTF8:
raise LayoutCompileError(
"%s: layout name exceeds %d UTF-8 bytes" % (path, NAME_MAX_UTF8)
)
desc_b = description.encode("utf-8")
if len(desc_b) > DESC_MAX_UTF8:
raise LayoutCompileError(
"%s: description exceeds %d UTF-8 bytes" % (path, DESC_MAX_UTF8)
)
_validate_layout_name(name, path)
name_b = name.encode("utf-8")
desc_b = description.encode("utf-8")
assert len(name_b) <= NAME_MAX_UTF8
assert len(desc_b) <= DESC_MAX_UTF8
if flags & ~FLAGS_KNOWN_V1:
raise RuntimeError("internal error: unknown layout flags bits")
if not raw_maps:
raise LayoutCompileError("%s: no mappings" % path)
by_cp = {} # type: Dict[int, Tuple[int, int, int]]
for cp, lineno, chord in raw_maps:
if cp in by_cp:
raise LayoutCompileError(
"duplicate mapping for U+%04X (also at line %d)" % (cp, by_cp[cp][2]),
path,
lineno,
)
mod, usage = parse_chord(chord, path, lineno)
if usage == 0:
raise LayoutCompileError("chord must not resolve to usage 0", path, lineno)
by_cp[cp] = (mod, usage, lineno)
max_cp = max(by_cp.keys())
limit = max_cp + 1
if limit > CODEPOINT_LIMIT_MAX:
raise LayoutCompileError("%s: codepoint_limit would exceed 0x110000" % path)
body = limit * 2
buf = bytearray(HEADER_BYTES + body)
_HEADER_PREFIX.pack_into(buf, 0, b"X52L", FORMAT_VERSION, flags, limit, 0)
buf[16 : 16 + len(name_b)] = name_b
buf[48 : 48 + len(desc_b)] = desc_b
base = HEADER_BYTES
for cp, (mod, usage, _) in by_cp.items():
off = base + 2 * cp
buf[off] = mod
buf[off + 1] = usage
crc_input = bytes(buf[0:12]) + b"\0\0\0\0" + bytes(buf[16:])
crc = zlib.crc32(crc_input, 0) & 0xFFFFFFFF
struct.pack_into("!I", buf, 12, crc)
return bytes(buf)
def compile_layout_file(
src_path: str, dst_path: str, *, truncate_metadata: bool = False
) -> None:
"""Compile ``src_path`` to ``dst_path``.
The destination basename must be ``{name}.x52l`` where ``name`` is the layout name
stored in the binary (from the ``name:`` line, after any ``--truncate-metadata``
trimming) so it matches ``Profiles.KeyboardLayout``.
"""
with open(src_path, "r", encoding="utf-8-sig") as f:
text = f.read()
out = compile_layout_source(src_path, text, truncate_metadata=truncate_metadata)
stored_name = _layout_stored_name_from_bytes(out)
expected_base = stored_name + ".x52l"
if os.path.basename(dst_path) != expected_base:
raise LayoutCompileError(
"%s: output file must be named %r (from layout name:), got %r"
% (src_path, expected_base, os.path.basename(dst_path))
)
with open(dst_path, "wb") as f:
f.write(out)
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(
description=(
"Compile human-readable .layout source into a v1 .x52l binary "
"(see daemon/layout_format.h)."
)
)
parser.add_argument(
"--truncate-metadata",
action="store_true",
help=(
"Trim oversize name:/description: to header field limits and set truncation flags."
),
)
parser.add_argument(
"source",
metavar="layout.source",
help="Input .layout file.",
)
parser.add_argument(
"output",
nargs="?",
metavar="out.x52l",
help=(
"Output path; basename must be {name}.x52l from layout metadata. "
"If omitted, write ./{name}.x52l in the current directory."
),
)
parsed = parser.parse_args(argv)
truncate_metadata = bool(parsed.truncate_metadata)
src_path = parsed.source
try:
if parsed.output is None:
with open(src_path, "r", encoding="utf-8-sig") as f:
text = f.read()
out = compile_layout_source(
src_path, text, truncate_metadata=truncate_metadata
)
stored_name = _layout_stored_name_from_bytes(out)
dst_path = os.path.join(os.getcwd(), stored_name + ".x52l")
with open(dst_path, "wb") as f:
f.write(out)
else:
compile_layout_file(
src_path, parsed.output, truncate_metadata=truncate_metadata
)
except LayoutCompileError as e:
sys.stderr.write("%s\n" % e)
return 1
except OSError as e:
sys.stderr.write("%s\n" % e)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())