From 4ab8da6680a3c348df7d5d9b7f84a74610be93a1 Mon Sep 17 00:00:00 2001 From: nirenjan Date: Sun, 26 Apr 2026 21:18:33 -0700 Subject: [PATCH] feat: Migrate daemon to use localipc library --- Doxyfile.in | 3 +- daemon/comm_client.c | 67 ++ daemon/comm_internal.c | 9 + daemon/command_defs.py | 44 ++ daemon/config.c | 249 ++++++++ daemon/config.h | 70 +++ daemon/config_dump.c | 78 ++- daemon/constants.h | 13 + daemon/daemon.dox | 17 +- daemon/daemon_control.c | 192 ------ daemon/device.c | 15 +- daemon/install_copy_file.py | 24 + daemon/ipc_lipc_handlers.c | 367 +++++++++++ daemon/ipc_lipc_handlers.h | 19 + daemon/ipc_service.c | 119 ++++ daemon/ipc_service.h | 31 + daemon/main.c | 32 +- daemon/meson.build | 75 ++- daemon/notify.c | 21 + daemon/notify.h | 10 + daemon/protocol.dox | 202 +++++- daemon/test_daemon_comm.py | 4 +- daemon/x52ctl-installed.in | 3 + daemon/x52ctl.dox | 42 ++ daemon/x52ctl.in | 31 + daemon/x52ctl_main.py | 615 +++++++++++++++++++ daemon/x52d_gen_module.py | 31 + daemon/x52d_map_config.py | 22 +- daemon/x52dcomm-internal.h | 1 + docs/main.dox | 1 + include/libx52/libx52.h | 20 + include/libx52/x52dcomm.h | 201 +++--- include/meson.build | 1 + install-doxygen-docs.sh | 3 +- libx52/common.h | 3 + libx52/core.c | 41 ++ subprojects/localipc/include/localipc/lipc.h | 19 + subprojects/localipc/lipc_dispatch.c | 42 ++ 38 files changed, 2380 insertions(+), 357 deletions(-) create mode 100644 daemon/command_defs.py delete mode 100644 daemon/daemon_control.c create mode 100644 daemon/install_copy_file.py create mode 100644 daemon/ipc_lipc_handlers.c create mode 100644 daemon/ipc_lipc_handlers.h create mode 100644 daemon/ipc_service.c create mode 100644 daemon/ipc_service.h create mode 100644 daemon/x52ctl-installed.in create mode 100644 daemon/x52ctl.dox create mode 100644 daemon/x52ctl.in create mode 100644 daemon/x52ctl_main.py diff --git a/Doxyfile.in b/Doxyfile.in index b7c4e44..7adbfe6 100644 --- a/Doxyfile.in +++ b/Doxyfile.in @@ -988,8 +988,9 @@ FILE_PATTERNS = libx52.h \ libx52util.h \ vkm.h \ x52_cli.c \ - daemon_control.c \ + daemon/x52ctl.dox \ x52dcomm.h \ + x52d_ipc.h \ *.dox # The RECURSIVE tag can be used to specify whether or not subdirectories should diff --git a/daemon/comm_client.c b/daemon/comm_client.c index 38e7838..638eca1 100644 --- a/daemon/comm_client.c +++ b/daemon/comm_client.c @@ -15,7 +15,9 @@ #include #include +#include #include +#include #include static int _setup_socket(struct sockaddr_un *remote, int len) @@ -71,6 +73,71 @@ int x52d_dial_notify(const char *sock_path) return _setup_socket(&remote, len); } +const char *x52d_ipc_socket_path(const char *sock_path) +{ + return x52d_ipc_sock_path(sock_path); +} + +int x52d_dial_ipc(const char *sock_path) +{ + const char *path = x52d_ipc_sock_path(sock_path); + int fd; + + fd = lipc_socket_connect(path, LIPC_SOCKET_CLOEXEC); + return fd; +} + +int x52d_ipc_device_state_decode(const lipc_header *hdr, const void *payload, size_t payload_len, + int *connected, uint16_t *vid, uint16_t *pid, const char **name_utf8, size_t *name_len) +{ + if (!hdr) { + return -1; + } + if (payload_len > 0 && !payload) { + return -1; + } + if (hdr->tid != 0 || hdr->request != X52D_IPC_PUSH_DEVICE_STATE) { + return -1; + } + if (hdr->index > 1u) { + return -1; + } + if (hdr->length != (uint32_t)payload_len) { + return -1; + } + if (connected) { + *connected = (hdr->index == 1u) ? 1 : 0; + } + if (vid || pid) { + x52d_ipc_device_state_unpack_usb(hdr->value, vid, pid); + } + if (name_utf8) { + *name_utf8 = (payload_len > 0) ? (const char *)payload : NULL; + } + if (name_len) { + *name_len = payload_len; + } + return 0; +} + +lipc_status x52d_ipc_call(int fd, uint16_t request_id, uint16_t index, uint64_t value, + const void *payload, size_t payload_len, + lipc_header *reply_hdr, void *reply_payload, size_t reply_payload_cap, size_t *reply_len) +{ + lipc_client *client; + lipc_status st; + + client = lipc_client_create(0, NULL, NULL); + if (!client) { + return LIPC_INTERNAL_ERROR; + } + + st = lipc_client_call(client, fd, request_id, index, value, payload, payload_len, + reply_hdr, reply_payload, reply_payload_cap, reply_len); + lipc_client_destroy(client); + return st; +} + int x52d_format_command(int argc, const char **argv, char *buffer, size_t buflen) { int msglen; diff --git a/daemon/comm_internal.c b/daemon/comm_internal.c index 1a4f25e..f513f19 100644 --- a/daemon/comm_internal.c +++ b/daemon/comm_internal.c @@ -34,6 +34,15 @@ const char * x52d_notify_sock_path(const char *sock_path) return sock_path; } +const char *x52d_ipc_sock_path(const char *sock_path) +{ + if (sock_path == NULL) { + sock_path = X52D_SOCK_IPC; + } + + return sock_path; +} + static int _setup_sockaddr(struct sockaddr_un *remote, const char *sock_path) { int len; diff --git a/daemon/command_defs.py b/daemon/command_defs.py new file mode 100644 index 0000000..36a765b --- /dev/null +++ b/daemon/command_defs.py @@ -0,0 +1,44 @@ +"""LIPC wire identifiers and helpers for x52ctl. + +Keep numeric values in sync with include/libx52/x52d_ipc.h and the daemon +registry (daemon/config_registry.json / generated config-defs). +""" + +from enum import IntEnum + + +class IpcRequest(IntEnum): + """Framed IPC request opcodes (lipc_header.request).""" + + CONFIG_LOAD = 0x01 + CONFIG_RELOAD = 0x02 + CONFIG_RESET = 0x03 + CONFIG_CLEAR = 0x04 + CONFIG_SAVE = 0x05 + CONFIG_DUMP = 0x06 + CONFIG_SET = 0x07 + CONFIG_GET = 0x08 + LOGGING_SHOW = 0x11 + LOGGING_SET = 0x12 + + +class ConfigClearTarget(IntEnum): + """lipc_header.index for CONFIG_CLEAR.""" + + STATE = 1 + SYSCONF = 2 + + +class IpcPush(IntEnum): + """Server push request ids (tid == 0).""" + + DEVICE_STATE = 0x8001 + + +# Same sentinel as module-map.h / x52d_ipc logging selectors. +X52D_MOD_GLOBAL = 0xFF + + +def device_state_pack_usb(vendor_id: int, product_id: int) -> int: + """Pack 16-bit USB ids into lipc_header.value lower 32 bits (host order).""" + return int(((vendor_id & 0xFFFF) << 16) | (product_id & 0xFFFF)) diff --git a/daemon/config.c b/daemon/config.c index 4d8fcdf..7637062 100644 --- a/daemon/config.c +++ b/daemon/config.c @@ -8,6 +8,13 @@ #include "build-config.h" #include +#include +#include +#include +#include +#include +#include +#include #define PINELOG_MODULE X52D_MOD_CONFIG #include "pinelog.h" @@ -15,6 +22,28 @@ #include static struct x52d_config x52d_config; +static const char *ipc_save_path; + +void x52d_config_set_ipc_save_path(const char *path) +{ + ipc_save_path = path; +} + +int x52d_config_save_session(void) +{ + int rc; + + if (ipc_save_path != NULL) { + rc = x52d_config_save_file(&x52d_config, ipc_save_path); + if (rc != 0) { + PINELOG_ERROR(_("Error %d saving configuration file: %s"), + rc, strerror(rc)); + } + return rc; + } + + return x52d_config_save_state_atomic(); +} void x52d_config_load(const char *cfg_file) { @@ -110,3 +139,223 @@ void x52d_config_apply(void) x52d_cfg_set_ ## section ## _ ## key(x52d_config . name); #include } + +static int mkdir_p(char *path) +{ + char *p; + int saved_errno; + + for (p = path + 1; *p != '\0'; p++) { + if (*p != '/') { + continue; + } + *p = '\0'; + if (mkdir(path, 0755) != 0 && errno != EEXIST) { + saved_errno = errno; + *p = '/'; + return saved_errno != 0 ? saved_errno : EIO; + } + *p = '/'; + } + if (mkdir(path, 0755) != 0 && errno != EEXIST) { + return errno != 0 ? errno : EIO; + } + return 0; +} + +static int ensure_dir_for_file(const char *filepath) +{ + char buf[PATH_MAX]; + char *slash; + + if (filepath == NULL || filepath[0] == '\0') { + return EINVAL; + } + if (strlen(filepath) >= sizeof(buf)) { + return ENAMETOOLONG; + } + memcpy(buf, filepath, strlen(filepath) + 1); + slash = strrchr(buf, '/'); + if (slash == NULL || slash == buf) { + return 0; + } + *slash = '\0'; + return mkdir_p(buf); +} + +int x52d_config_reload_canonical(void) +{ + int rc; + const char *chosen = NULL; + + rc = x52d_config_set_defaults(&x52d_config); + if (rc != 0) { + return rc; + } + + if (access(X52D_STATE_CFG_FILE, R_OK) == 0) { + chosen = X52D_STATE_CFG_FILE; + } else if (access(X52D_SYS_CFG_FILE, R_OK) == 0) { + chosen = X52D_SYS_CFG_FILE; + } + + if (chosen != NULL) { + rc = x52d_config_load_file(&x52d_config, chosen); + if (rc != 0) { + return rc; + } + } + + rc = x52d_config_apply_overrides(&x52d_config); + x52d_config_clear_overrides(); + return rc; +} + +int x52d_config_load_from_path(const char *path) +{ + int rc; + + if (path == NULL || path[0] == '\0') { + return EINVAL; + } + if (access(path, R_OK) != 0) { + return errno != 0 ? errno : EACCES; + } + + rc = x52d_config_set_defaults(&x52d_config); + if (rc != 0) { + return rc; + } + + rc = x52d_config_load_file(&x52d_config, path); + if (rc != 0) { + return rc; + } + + rc = x52d_config_apply_overrides(&x52d_config); + x52d_config_clear_overrides(); + return rc; +} + +int x52d_config_reset_to_defaults(void) +{ + int rc; + + rc = x52d_config_set_defaults(&x52d_config); + if (rc != 0) { + return rc; + } + rc = x52d_config_apply_overrides(&x52d_config); + x52d_config_clear_overrides(); + return rc; +} + +int x52d_config_dump_to_alloc(char **out, size_t *out_len) +{ + FILE *fp; + int rc; + + if (out == NULL || out_len == NULL) { + return EINVAL; + } + *out = NULL; + *out_len = 0; + + fp = open_memstream(out, out_len); + if (fp == NULL) { + return errno != 0 ? errno : ENOMEM; + } + + rc = x52d_config_write_ini(&x52d_config, fp, "(memory)"); + if (fclose(fp) != 0) { + if (rc == 0) { + rc = errno != 0 ? errno : EIO; + } + free(*out); + *out = NULL; + *out_len = 0; + return rc; + } + return rc; +} + +int x52d_config_save_state_atomic(void) +{ + char template[PATH_MAX]; + int fd; + FILE *fp; + int rc; + int errsv; + + rc = ensure_dir_for_file(X52D_STATE_CFG_FILE); + if (rc != 0) { + return rc; + } + + if (snprintf(template, sizeof template, "%s.XXXXXX", X52D_STATE_CFG_FILE) >= (int)sizeof(template)) { + return ENAMETOOLONG; + } + + fd = mkstemp(template); + if (fd < 0) { + return errno != 0 ? errno : EIO; + } + + fp = fdopen(fd, "w"); + if (fp == NULL) { + errsv = errno; + close(fd); + unlink(template); + return errsv != 0 ? errsv : EIO; + } + + rc = x52d_config_write_ini(&x52d_config, fp, template); + if (fflush(fp) != 0 && rc == 0) { + rc = errno != 0 ? errno : EIO; + } + if (fsync(fileno(fp)) != 0 && rc == 0) { + rc = errno != 0 ? errno : EIO; + } + if (fclose(fp) != 0 && rc == 0) { + rc = errno != 0 ? errno : EIO; + } + if (rc != 0) { + unlink(template); + return rc; + } + + if (rename(template, X52D_STATE_CFG_FILE) != 0) { + errsv = errno; + unlink(template); + return errsv != 0 ? errsv : EIO; + } + + return 0; +} + +int x52d_config_clear_disk_then_reload(uint16_t target, int *out_unlink_errno) +{ + const char *path = NULL; + int reload_rc; + + if (out_unlink_errno != NULL) { + *out_unlink_errno = 0; + } + + if (target == X52D_CONFIG_CLEAR_TARGET_STATE) { + path = X52D_STATE_CFG_FILE; + } else if (target == X52D_CONFIG_CLEAR_TARGET_SYSCONF) { + path = X52D_SYS_CFG_FILE; + } else { + return EINVAL; + } + + if (unlink(path) != 0 && errno != ENOENT) { + if (out_unlink_errno != NULL) { + *out_unlink_errno = errno != 0 ? errno : EIO; + } + } + + reload_rc = x52d_config_reload_canonical(); + return reload_rc; +} diff --git a/daemon/config.h b/daemon/config.h index 200bf11..020dd34 100644 --- a/daemon/config.h +++ b/daemon/config.h @@ -9,6 +9,7 @@ #ifndef X52D_CONFIG_H #define X52D_CONFIG_H +#include #include #include #include @@ -106,9 +107,78 @@ void x52d_config_apply_immediate(const char *section, const char *key); void x52d_config_apply(void); int x52d_config_save_file(struct x52d_config *cfg, const char *cfg_file); + +/** Write the full active configuration as INI to @p cfg_fp (for save or in-memory dump). */ +int x52d_config_write_ini(struct x52d_config *cfg, FILE *cfg_fp, const char *path_label); + void x52d_config_save(const char *cfg_file); int x52d_config_set(const char *section, const char *key, const char *value); const char *x52d_config_get(const char *section, const char *key); +/** + * Reload configuration using the canonical order: state file if present and readable, + * else system config if present, else in-memory defaults (plus CLI overrides once). + * + * @return 0 on success, or a positive errno-style code on failure. + */ +int x52d_config_reload_canonical(void); + +/** + * Load defaults, then load @p path (must be readable). Applies CLI overrides. + * + * @return 0 on success, or a positive errno-style code on failure. + */ +int x52d_config_load_from_path(const char *path); + +/** + * Reset active configuration to defaults and re-apply CLI overrides. + * + * @return 0 on success, or a positive errno-style code on failure. + */ +int x52d_config_reset_to_defaults(void); + +/** + * Serialize the active configuration as INI text into a heap buffer. + * On success, @p *out is NUL-terminated (length includes the final NUL in @p *out_len). + * + * @return 0 on success, or a positive errno-style code on failure (@p *out undefined). + */ +int x52d_config_dump_to_alloc(char **out, size_t *out_len); + +/** + * Atomically write the active configuration to @ref X52D_STATE_CFG_FILE + * (temp file in the same directory + rename). + * + * @return 0 on success, or a positive errno-style code on failure. + */ +int x52d_config_save_state_atomic(void); + +/** + * When non-NULL, @ref x52d_config_save_session writes to this path (same file as @c x52d -c). + * When NULL, @ref x52d_config_save_state_atomic is used. + */ +void x52d_config_set_ipc_save_path(const char *path); + +/** + * Save active configuration: session path if set, otherwise @ref x52d_config_save_state_atomic. + * + * @return 0 on success, or a positive errno-style code on failure. + */ +int x52d_config_save_session(void); + +/** + * Delete on-disk configuration selected by LIPC @c CONFIG_CLEAR index, then run + * @ref x52d_config_reload_canonical. @p target is @ref X52D_CONFIG_CLEAR_TARGET_STATE or + * @ref X52D_CONFIG_CLEAR_TARGET_SYSCONF. + * + * Unlink uses @c ENOENT as success (nothing to remove). If removal fails (e.g. read-only + * sysconf), the errno is stored in @p out_unlink_errno when non-@c NULL and reload still runs. + * + * @return @c 0 if reload succeeded, else a positive errno-style code from reload. + * @param out_unlink_errno optional; set to @c 0 if unlink succeeded or file was absent; + * otherwise set to errno from @c unlink (2); reload is still attempted. + */ +int x52d_config_clear_disk_then_reload(uint16_t target, int *out_unlink_errno); + #endif // !defined X52D_CONFIG_H diff --git a/daemon/config_dump.c b/daemon/config_dump.c index 4e053bf..957d374 100644 --- a/daemon/config_dump.c +++ b/daemon/config_dump.c @@ -93,11 +93,52 @@ static const char * date_format_dumper(const char *section, const char *key, str #undef CHECK_PARAMS #undef CONFIG_PTR +int x52d_config_write_ini(struct x52d_config *cfg, FILE *cfg_fp, const char *path_label) +{ + char *current_section = NULL; + const char *value = ""; + int out_rc = 0; + + if (cfg == NULL || cfg_fp == NULL || path_label == NULL) { + return EINVAL; + } + + PINELOG_TRACE("Writing configuration INI to %s", path_label); + #define CFG(section, key, name, type, def) do { \ + if (current_section == NULL || strcasecmp(current_section, #section)) { \ + if (current_section != NULL) { \ + free(current_section); \ + } \ + current_section = strdup(#section); \ + if (current_section == NULL) { \ + value = NULL; \ + out_rc = ENOMEM; \ + goto exit_write_ini; \ + } \ + PINELOG_TRACE("Printing section header %s", #section); \ + fprintf(cfg_fp, "[%s]\n", #section); \ + } \ + PINELOG_TRACE("Dumping " #section "." #key " to %s", path_label); \ + value = type ## _dumper(#section, #key, cfg, offsetof(struct x52d_config, name)); \ + if (value == NULL) { \ + PINELOG_ERROR(_("Failed to dump %s.%s to config stream %s"), \ + #section, #key, path_label); \ + out_rc = EIO; \ + goto exit_write_ini; \ + } \ + fprintf(cfg_fp, "%s = %s\n", #key, value); \ + } while (0); + #include + +exit_write_ini: + free(current_section); + return out_rc; +} + int x52d_config_save_file(struct x52d_config *cfg, const char *cfg_file) { FILE *cfg_fp; - char *current_section = NULL; - const char *value; + int rc; if (cfg == NULL || cfg_file == NULL) { return EINVAL; @@ -107,35 +148,14 @@ int x52d_config_save_file(struct x52d_config *cfg, const char *cfg_file) if (cfg_fp == NULL) { PINELOG_ERROR(_("Unable to save config file %s - code %d: %s"), cfg_file, errno, strerror(errno)); - return 1; + return errno != 0 ? errno : EIO; } - PINELOG_TRACE("Saving configuration to file %s", cfg_file); - #define CFG(section, key, name, type, def) do { \ - if (current_section == NULL || strcasecmp(current_section, #section)) { \ - if (current_section != NULL) { \ - free(current_section); \ - } \ - current_section = strdup(#section); \ - PINELOG_TRACE("Printing section header %s", #section); \ - fprintf(cfg_fp, "[%s]\n", #section); \ - } \ - PINELOG_TRACE("Dumping " #section "." #key " to file %s", cfg_file); \ - value = type ## _dumper(#section, #key, cfg, offsetof(struct x52d_config, name)); \ - if (value == NULL) { \ - PINELOG_ERROR(_("Failed to dump %s.%s to config file %s"), \ - #section, #key, cfg_file); \ - goto exit_dump; \ - } else { \ - fprintf(cfg_fp, "%s = %s\n", #key, value); \ - } \ - } while (0); - #include - -exit_dump: - free(current_section); - fclose(cfg_fp); - return (value == NULL); + rc = x52d_config_write_ini(cfg, cfg_fp, cfg_file); + if (fclose(cfg_fp) != 0 && rc == 0) { + rc = errno != 0 ? errno : EIO; + } + return rc; } const char *x52d_config_get_param(struct x52d_config *cfg, const char *section, const char *key) diff --git a/daemon/constants.h b/daemon/constants.h index b769e3d..7d59e8b 100644 --- a/daemon/constants.h +++ b/daemon/constants.h @@ -9,17 +9,30 @@ #ifndef X52D_CONST_H #define X52D_CONST_H +#include + #define X52D_APP_NAME "x52d" #define X52D_LOG_FILE LOGDIR "/" X52D_APP_NAME ".log" #define X52D_SYS_CFG_FILE SYSCONFDIR "/" X52D_APP_NAME "/" X52D_APP_NAME ".conf" +/** Persistent runtime configuration (not under ephemeral @c RUNDIR). */ +#define X52D_STATE_CFG_FILE LOCALSTATEDIR "/lib/" X52D_APP_NAME "/" X52D_APP_NAME ".conf" + +/** @deprecated Use @ref X52D_IPC_CONFIG_CLEAR_TARGET_STATE in @c libx52/x52d_ipc.h. */ +#define X52D_CONFIG_CLEAR_TARGET_STATE X52D_IPC_CONFIG_CLEAR_TARGET_STATE +/** @deprecated Use @ref X52D_IPC_CONFIG_CLEAR_TARGET_SYSCONF in @c libx52/x52d_ipc.h. */ +#define X52D_CONFIG_CLEAR_TARGET_SYSCONF X52D_IPC_CONFIG_CLEAR_TARGET_SYSCONF + #define X52D_PID_FILE RUNDIR "/" X52D_APP_NAME ".pid" #define X52D_SOCK_COMMAND RUNDIR "/" X52D_APP_NAME ".cmd" #define X52D_SOCK_NOTIFY RUNDIR "/" X52D_APP_NAME ".notify" +/** Framed IPC socket: RPC commands and async pushes share one path (legacy NUL \c .cmd / \c .notify remain until removal). */ +#define X52D_SOCK_IPC RUNDIR "/" X52D_APP_NAME ".socket" + #include "gettext.h" #define N_(x) gettext_noop(x) #define _(x) gettext(x) diff --git a/daemon/daemon.dox b/daemon/daemon.dox index 5d4a04f..c68a39f 100644 --- a/daemon/daemon.dox +++ b/daemon/daemon.dox @@ -9,6 +9,18 @@ the Windows X52 driver. It currently manages the following: - MFD brightness - Clock display on MFD +# Control and notification sockets + +The \b supported control plane and event stream use \b liblocalipc framing on a +single UNIX stream socket (default basename \c x52d.socket under the runtime +directory). See \ref x52d_protocol and \ref proto_lipc_framed. Use \c -S to +override the socket path. + +The historical \b NUL-separated command socket (\c x52d.cmd) and notify socket +(\c x52d.notify) remain for transitional callers; they are \b deprecated and +\b will be removed in a future release. Overrides use \c -s and \c -b. See +\ref x52d_protocol. + # Command line arguments - \c -f - Run daemon in foreground (default: no) @@ -18,8 +30,9 @@ the Windows X52 driver. It currently manages the following: - \c -c - Path to configuration file - \c -p - Path to PID file - \c -o - Configuration override - only applied during startup -- \c -s - Path to command socket (see \ref x52d_protocol) -- \c -b - Path to notify socket +- \c -S - Path to the \b unified framed-IPC socket (RPC and \c tid==0 push notifications such as \c DEVICE_STATE; default \c x52d.socket under the runtime directory). \b This is the supported integration path (see \ref x52d_protocol). +- \c -s - Path to the \b deprecated legacy NUL command socket (default \c x52d.cmd); \b will be removed once callers migrate to \c -S / LIPC. +- \c -b - Path to the \b deprecated legacy notify socket (default \c x52d.notify); \b will be removed in the same way. # Configuration file diff --git a/daemon/daemon_control.c b/daemon/daemon_control.c deleted file mode 100644 index 3f9606b..0000000 --- a/daemon/daemon_control.c +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Saitek X52 Pro MFD & LED driver - Daemon controller - * - * Copyright (C) 2021 Nirenjan Krishnan (nirenjan@nirenjan.org) - * - * SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 - */ - -/** -@page x52ctl Command Line controller to X52 daemon - -\htmlonly -x52ctl - Command line controller to X52 daemon -\endhtmlonly - -# SYNOPSIS -\b x52ctl [\a -i] [\a -s socket-path] [command] - -# DESCRIPTION - -x52ctl is a program that can be used to communicate with the X52 daemon. It can -be used either as a one-shot program that can be run from another program or -script, or it can be run interactively. - -Commands are sent to the running daemon, and responses are written to standard -output. - -If not running interactively, then you must specify a command, or the program -will exit with a failure exit code. If running interactively, the program will -request input and send that to the daemon, until the user either enters the -string "quit", or terminates input by using Ctrl+D. - -# OPTIONS - -- \b -i - Run in interactive mode. Any additional non-option arguments are ignored. - -- \b -s < \a socket-path > - Use the socket at the given path. If this is not specified, then it uses a - default socket. -*/ - -#include "build-config.h" -#include -#include -#include -#include -#include -#include - -#include -#include - -#define APP_NAME "x52ctl" -#if HAVE_FUNC_ATTRIBUTE_NORETURN -__attribute__((noreturn)) -#endif -static void usage(int exit_code) -{ - fprintf(stderr, _("Usage: %s [-i] [-s socket-path] [command]\n"), APP_NAME); - exit(exit_code); -} - -static int send_command(int sock_fd, int argc, char **argv) -{ - int rc; - char buffer[1024]; - int buflen; - - buflen = x52d_format_command(argc, (const char **)argv, buffer, sizeof(buffer)); - if (buflen < 0) { - if (errno == E2BIG) { - fprintf(stderr, _("Argument length too long\n")); - } - return -1; - } - - rc = x52d_send_command(sock_fd, buffer, buflen, sizeof(buffer)); - if (rc >= 0) { - if (write(STDOUT_FILENO, buffer, rc) < 0) { - perror("write"); - return -1; - } - } else { - perror("x52d_send_command"); - return -1; - } - - return 0; -} - -static void interactive_mode(int sock_fd) -{ - bool keep_running = true; - char buffer[1024]; - - fputs("> ", stdout); - while (keep_running && fgets(buffer, sizeof(buffer), stdin) != NULL) { - int sargc; - char *sargv[512] = { 0 }; - int pos; - - if (strcasecmp(buffer, "quit\n") == 0) { - keep_running = false; - } else { - /* Break the buffer into argc/argv */ - sargc = 0; - pos = 0; - while (buffer[pos]) { - if (isspace(buffer[pos])) { - buffer[pos] = '\0'; - pos++; - } else { - sargv[sargc] = &buffer[pos]; - sargc++; - for (; buffer[pos] && !isspace(buffer[pos]); pos++); - } - } - - if (send_command(sock_fd, sargc, sargv)) { - keep_running = false; - } - } - - if (keep_running) { - fputs("\n> ", stdout); - } - } -} - -int main(int argc, char **argv) -{ - bool interactive = false; - const char *socket_path = NULL; - int opt; - int sock_fd; - int rc = EXIT_SUCCESS; - - - /* - * Parse command line arguments - * - * -i Interactive - * -s Socket path - */ - while ((opt = getopt(argc, argv, "is:h")) != -1) { - switch (opt) { - case 'i': - interactive = true; - break; - - case 's': - socket_path = optarg; - break; - - case 'h': - usage(EXIT_SUCCESS); - break; - - default: - usage(EXIT_FAILURE); - break; - } - } - - if (!interactive && optind >= argc) { - usage(EXIT_FAILURE); - } - - /* Connect to the socket */ - sock_fd = x52d_dial_command(socket_path); - if (sock_fd < 0) { - perror("x52d_dial_command"); - return EXIT_FAILURE; - } - - if (interactive) { - if (optind < argc) { - fprintf(stderr, - _("Running in interactive mode, ignoring extra arguments\n")); - } - - interactive_mode(sock_fd); - } else { - if (send_command(sock_fd, argc - optind, &argv[optind])) { - rc = EXIT_FAILURE; - } - } - - close(sock_fd); - return rc; -} diff --git a/daemon/device.c b/daemon/device.c index 78f22db..9756219 100644 --- a/daemon/device.c +++ b/daemon/device.c @@ -7,6 +7,7 @@ */ #include "build-config.h" +#include #include #include #include @@ -50,8 +51,14 @@ static void *x52_dev_thr(void *param) sleep(DEV_ACQ_DELAY); } else { /* Successfully connected */ + uint16_t vid = 0; + uint16_t pid = 0; + const char *prod = ""; + PINELOG_INFO(_("Device connected, writing configuration")); - X52D_NOTIFY("CONNECTED"); + (void)libx52_get_usb_ids(x52_dev, &vid, &pid); + prod = libx52_get_product_string(x52_dev); + x52d_notify_device_state(1, vid, pid, prod, strlen(prod)); x52d_config_apply(); } } else { @@ -171,11 +178,15 @@ int x52d_dev_update(void) if (rc != LIBX52_SUCCESS) { if (rc == LIBX52_ERROR_NO_DEVICE) { + uint16_t vid = 0; + uint16_t pid = 0; + // Detach from the existing device, the next thread run will // pick it up. PINELOG_TRACE("Disconnecting detached device"); + (void)libx52_get_usb_ids(x52_dev, &vid, &pid); libx52_disconnect(x52_dev); - X52D_NOTIFY("DISCONNECTED"); + x52d_notify_device_state(0, vid, pid, NULL, 0); } else { PINELOG_ERROR(_("Error %d when updating X52 device: %s"), rc, libx52_strerror(rc)); diff --git a/daemon/install_copy_file.py b/daemon/install_copy_file.py new file mode 100644 index 0000000..c7a1e04 --- /dev/null +++ b/daemon/install_copy_file.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 +"""Meson install_script helper: copy one file to DESTDIR + absolute install path.""" +import os +import shutil +import sys + + +def main() -> None: + if len(sys.argv) != 3: + raise SystemExit('usage: install_copy_file.py SRC DEST_ABS') + src = sys.argv[1] + dest_abs = sys.argv[2] + destdir = os.environ.get('DESTDIR', '') + if destdir: + dest = os.path.normpath(os.path.join(destdir, dest_abs.lstrip(os.sep))) + else: + dest = dest_abs + os.makedirs(os.path.dirname(dest), exist_ok=True) + shutil.copyfile(src, dest) + + +if __name__ == '__main__': + main() diff --git a/daemon/ipc_lipc_handlers.c b/daemon/ipc_lipc_handlers.c new file mode 100644 index 0000000..9377760 --- /dev/null +++ b/daemon/ipc_lipc_handlers.c @@ -0,0 +1,367 @@ +/* + * Saitek X52 Pro MFD & LED driver - framed IPC opcode handlers + * + * 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 +#include +#include +#include +#include + +#define PINELOG_MODULE X52D_MOD_CLIENT +#include "pinelog.h" +#include +#include +#include +#include + +#include "config-defs.h" +#include "module-map.h" + +static lipc_status reply_err(lipc_server_reply_ctx *reply, lipc_status st, const char *msg) +{ + size_t len = (msg != NULL) ? strlen(msg) : 0; + + return lipc_server_reply(reply, (uint16_t)st, 0, 0, msg, len); +} + +static lipc_status reply_ok(lipc_server_reply_ctx *reply, const void *payload, size_t payload_len) +{ + return lipc_server_reply(reply, LIPC_OK, 0, 0, payload, payload_len); +} + +static lipc_status on_config_load(void *user, lipc_server_reply_ctx *reply, + const lipc_header *req_hdr, const uint8_t *payload, size_t payload_len) +{ + char *path; + int rc; + (void)user; + (void)req_hdr; + + path = malloc(payload_len + 1u); + if (path == NULL) { + return reply_err(reply, LIPC_INTERNAL_ERROR, strerror(ENOMEM)); + } + memcpy(path, payload, payload_len); + path[payload_len] = '\0'; + + rc = x52d_config_load_from_path(path); + free(path); + if (rc != 0) { + return reply_err(reply, LIPC_INVALID_PARAMS, strerror(rc)); + } + x52d_config_apply(); + return reply_ok(reply, NULL, 0); +} + +static lipc_status on_config_reload(void *user, lipc_server_reply_ctx *reply, + const lipc_header *req_hdr, const uint8_t *payload, size_t payload_len) +{ + int rc; + (void)user; + (void)req_hdr; + (void)payload; + (void)payload_len; + + rc = x52d_config_reload_canonical(); + if (rc != 0) { + return reply_err(reply, LIPC_INTERNAL_ERROR, strerror(rc)); + } + x52d_config_apply(); + return reply_ok(reply, NULL, 0); +} + +static lipc_status on_config_reset(void *user, lipc_server_reply_ctx *reply, + const lipc_header *req_hdr, const uint8_t *payload, size_t payload_len) +{ + int rc; + (void)user; + (void)req_hdr; + (void)payload; + (void)payload_len; + + rc = x52d_config_reset_to_defaults(); + if (rc != 0) { + return reply_err(reply, LIPC_INTERNAL_ERROR, strerror(rc)); + } + x52d_config_apply(); + return reply_ok(reply, NULL, 0); +} + +static lipc_status on_config_clear(void *user, lipc_server_reply_ctx *reply, + const lipc_header *req_hdr, const uint8_t *payload, size_t payload_len) +{ + int rc; + int unlink_err = 0; + (void)user; + (void)payload; + (void)payload_len; + + if (req_hdr->index != X52D_CONFIG_CLEAR_TARGET_STATE + && req_hdr->index != X52D_CONFIG_CLEAR_TARGET_SYSCONF) { + return reply_err(reply, LIPC_INVALID_PARAMS, "invalid CONFIG_CLEAR target index"); + } + + rc = x52d_config_clear_disk_then_reload((uint16_t)req_hdr->index, &unlink_err); + if (rc != 0) { + return reply_err(reply, LIPC_INTERNAL_ERROR, strerror(rc)); + } + x52d_config_apply(); + if (unlink_err != 0) { + return reply_err(reply, LIPC_IO_ERROR, strerror(unlink_err)); + } + return reply_ok(reply, NULL, 0); +} + +static lipc_status on_config_save(void *user, lipc_server_reply_ctx *reply, + const lipc_header *req_hdr, const uint8_t *payload, size_t payload_len) +{ + int rc; + (void)user; + (void)req_hdr; + (void)payload; + (void)payload_len; + + rc = x52d_config_save_session(); + if (rc != 0) { + return reply_err(reply, LIPC_IO_ERROR, strerror(rc)); + } + return reply_ok(reply, NULL, 0); +} + +static lipc_status on_config_dump(void *user, lipc_server_reply_ctx *reply, + const lipc_header *req_hdr, const uint8_t *payload, size_t payload_len) +{ + char *buf = NULL; + size_t len = 0; + int rc; + lipc_status st; + (void)user; + (void)req_hdr; + (void)payload; + (void)payload_len; + + rc = x52d_config_dump_to_alloc(&buf, &len); + if (rc != 0) { + return reply_err(reply, LIPC_INTERNAL_ERROR, strerror(rc)); + } + + st = reply_ok(reply, buf, len); + free(buf); + return st; +} + +static lipc_status on_config_set(void *user, lipc_server_reply_ctx *reply, + const lipc_header *req_hdr, const uint8_t *payload, size_t payload_len) +{ + const char *sec_name; + const char *opt_name; + char *val_copy; + int rc; + uint16_t option_id; + (void)user; + + if (req_hdr->value > UINT16_MAX) { + return reply_err(reply, LIPC_INVALID_PARAMS, "option id out of range"); + } + option_id = (uint16_t)req_hdr->value; + if (!x52d_config_registry_pair_valid(req_hdr->index, option_id)) { + return reply_err(reply, LIPC_INVALID_PARAMS, "invalid section or option id"); + } + + sec_name = section_names[req_hdr->index]; + opt_name = option_names[req_hdr->index][option_id]; + if (sec_name == NULL || opt_name == NULL) { + return reply_err(reply, LIPC_INVALID_PARAMS, "invalid configuration key"); + } + + val_copy = malloc(payload_len + 1u); + if (val_copy == NULL) { + return reply_err(reply, LIPC_INTERNAL_ERROR, strerror(ENOMEM)); + } + memcpy(val_copy, payload, payload_len); + val_copy[payload_len] = '\0'; + + rc = x52d_config_set(sec_name, opt_name, val_copy); + free(val_copy); + if (rc != 0) { + return reply_err(reply, LIPC_INVALID_PARAMS, strerror(rc)); + } + x52d_config_apply_immediate(sec_name, opt_name); + return reply_ok(reply, NULL, 0); +} + +static lipc_status on_config_get(void *user, lipc_server_reply_ctx *reply, + const lipc_header *req_hdr, const uint8_t *payload, size_t payload_len) +{ + const char *sec_name; + const char *opt_name; + const char *val; + uint16_t option_id; + (void)user; + (void)payload; + (void)payload_len; + + if (req_hdr->value > UINT16_MAX) { + return reply_err(reply, LIPC_INVALID_PARAMS, "option id out of range"); + } + option_id = (uint16_t)req_hdr->value; + if (!x52d_config_registry_pair_valid(req_hdr->index, option_id)) { + return reply_err(reply, LIPC_INVALID_PARAMS, "invalid section or option id"); + } + + sec_name = section_names[req_hdr->index]; + opt_name = option_names[req_hdr->index][option_id]; + val = x52d_config_get(sec_name, opt_name); + if (val == NULL) { + return reply_err(reply, LIPC_NOT_FOUND, "configuration key not found"); + } + + return reply_ok(reply, val, strlen(val)); +} + +static lipc_status on_logging_show(void *user, lipc_server_reply_ctx *reply, + const lipc_header *req_hdr, const uint8_t *payload, size_t payload_len) +{ + int level; + const char *label; + (void)user; + (void)payload; + (void)payload_len; + + if (!x52d_module_wire_valid(req_hdr->index)) { + return reply_err(reply, LIPC_INVALID_PARAMS, "invalid module id"); + } + + if (req_hdr->index == X52D_MOD_GLOBAL) { + level = pinelog_get_level(); + } else { + level = pinelog_get_module_level((int)req_hdr->index); + } + + label = lookup_level_by_id(level); + if (label == NULL) { + return reply_err(reply, LIPC_INTERNAL_ERROR, "unknown log level in effect"); + } + + return reply_ok(reply, label, strlen(label)); +} + +static lipc_status on_logging_set(void *user, lipc_server_reply_ctx *reply, + const lipc_header *req_hdr, const uint8_t *payload, size_t payload_len) +{ + int64_t wire = (int64_t)req_hdr->value; + (void)user; + (void)payload; + (void)payload_len; + + if (!x52d_module_wire_valid(req_hdr->index)) { + return reply_err(reply, LIPC_INVALID_PARAMS, "invalid module id"); + } + if (!x52d_log_level_wire_valid(req_hdr->value)) { + return reply_err(reply, LIPC_INVALID_PARAMS, "invalid log level id"); + } + + if (req_hdr->index == X52D_MOD_GLOBAL) { + pinelog_set_level((int)wire); + } else { + pinelog_set_module_level((int)req_hdr->index, (int)wire); + } + + return reply_ok(reply, NULL, 0); +} + +static const lipc_method_desc desc_config_load = { + .index = LIPC_FIELD_FORBIDDEN, + .value = LIPC_FIELD_FORBIDDEN, + .payload = LIPC_FIELD_REQUIRED, + .payload_min_len = 1, + .payload_max_len = PATH_MAX - 1, +}; + +static const lipc_method_desc desc_config_void = { + .index = LIPC_FIELD_FORBIDDEN, + .value = LIPC_FIELD_FORBIDDEN, + .payload = LIPC_FIELD_FORBIDDEN, + .payload_min_len = 0, + .payload_max_len = 0, +}; + +static const lipc_method_desc desc_config_clear = { + .index = LIPC_FIELD_REQUIRED, + .value = LIPC_FIELD_FORBIDDEN, + .payload = LIPC_FIELD_FORBIDDEN, + .payload_min_len = 0, + .payload_max_len = 0, +}; + +static const lipc_method_desc desc_config_dump = { + .index = LIPC_FIELD_FORBIDDEN, + .value = LIPC_FIELD_FORBIDDEN, + .payload = LIPC_FIELD_OPTIONAL, + .payload_min_len = 0, + .payload_max_len = UINT32_MAX, +}; + +static const lipc_method_desc desc_config_set = { + .index = LIPC_FIELD_REQUIRED, + .value = LIPC_FIELD_REQUIRED, + .payload = LIPC_FIELD_REQUIRED, + .payload_min_len = 1, + .payload_max_len = NAME_MAX, +}; + +static const lipc_method_desc desc_config_get = { + .index = LIPC_FIELD_REQUIRED, + .value = LIPC_FIELD_REQUIRED, + .payload = LIPC_FIELD_FORBIDDEN, + .payload_min_len = 0, + .payload_max_len = 0, +}; + +static const lipc_method_desc desc_logging_show = { + /* Module id 0 is valid (e.g. CONFIG); liblocalipc REQUIRED would incorrectly reject index==0. */ + .index = LIPC_FIELD_OPTIONAL, + /* SHOW reads level from pinelog only; @c value must be zero on the wire. */ + .value = LIPC_FIELD_FORBIDDEN, + .payload = LIPC_FIELD_FORBIDDEN, + .payload_min_len = 0, + .payload_max_len = 0, +}; + +static const lipc_method_desc desc_logging_set = { + .index = LIPC_FIELD_OPTIONAL, + /* FATAL is level id 0; REQUIRED would reject it — validate with @c x52d_log_level_wire_valid. */ + .value = LIPC_FIELD_OPTIONAL, + .payload = LIPC_FIELD_FORBIDDEN, + .payload_min_len = 0, + .payload_max_len = 0, +}; + +static const lipc_server_handler x52d_ipc_handlers[] = { + { X52D_IPC_CONFIG_LOAD, &desc_config_load, on_config_load }, + { X52D_IPC_CONFIG_RELOAD, &desc_config_void, on_config_reload }, + { X52D_IPC_CONFIG_RESET, &desc_config_void, on_config_reset }, + { X52D_IPC_CONFIG_CLEAR, &desc_config_clear, on_config_clear }, + { X52D_IPC_CONFIG_SAVE, &desc_config_void, on_config_save }, + { X52D_IPC_CONFIG_DUMP, &desc_config_dump, on_config_dump }, + { X52D_IPC_CONFIG_SET, &desc_config_set, on_config_set }, + { X52D_IPC_CONFIG_GET, &desc_config_get, on_config_get }, + { X52D_IPC_LOGGING_SHOW, &desc_logging_show, on_logging_show }, + { X52D_IPC_LOGGING_SET, &desc_logging_set, on_logging_set }, +}; + +const lipc_server_handler *x52d_ipc_handlers_table(void) +{ + return x52d_ipc_handlers; +} + +size_t x52d_ipc_handlers_count(void) +{ + return sizeof(x52d_ipc_handlers) / sizeof(x52d_ipc_handlers[0]); +} diff --git a/daemon/ipc_lipc_handlers.h b/daemon/ipc_lipc_handlers.h new file mode 100644 index 0000000..7d038bb --- /dev/null +++ b/daemon/ipc_lipc_handlers.h @@ -0,0 +1,19 @@ +/* + * Saitek X52 Pro MFD & LED driver - framed IPC opcode handlers + * + * Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org) + * + * SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 + */ + +#ifndef X52D_IPC_LIPC_HANDLERS_H +#define X52D_IPC_LIPC_HANDLERS_H + +#include +#include +#include + +const lipc_server_handler *x52d_ipc_handlers_table(void); +size_t x52d_ipc_handlers_count(void); + +#endif /* !defined X52D_IPC_LIPC_HANDLERS_H */ diff --git a/daemon/ipc_service.c b/daemon/ipc_service.c new file mode 100644 index 0000000..8129b0e --- /dev/null +++ b/daemon/ipc_service.c @@ -0,0 +1,119 @@ +/* + * Saitek X52 Pro MFD & LED driver - framed IPC listener (unified command + notify) + * + * 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 +#include +#include +#include + +#define PINELOG_MODULE X52D_MOD_CLIENT +#include "pinelog.h" +#include +#include +#include +#include +#include +#include + +static lipc_server *ipc_srv_ctx; +static pthread_t ipc_listen_thread; +static const char *ipc_path_stored; +static bool ipc_listen_thread_started; + +static void *ipc_listen_thread_main(void *arg) +{ + lipc_server *srv = (lipc_server *)arg; + (void)lipc_server_run(srv); + return NULL; +} + +static void ipc_stop_join(pthread_t *thr, bool *started, lipc_server **ctx, + const char *path_for_unlink) +{ + if (*ctx != NULL) { + (void)lipc_server_stop(*ctx); + } + if (*started) { + (void)pthread_join(*thr, NULL); + *started = false; + } + if (*ctx != NULL) { + lipc_server_destroy(*ctx); + *ctx = NULL; + } + if (path_for_unlink != NULL && path_for_unlink[0] != '\0') { + (void)unlink(path_for_unlink); + } +} + +void x52d_ipc_exit(void) +{ + ipc_stop_join(&ipc_listen_thread, &ipc_listen_thread_started, &ipc_srv_ctx, ipc_path_stored); + ipc_path_stored = NULL; +} + +void x52d_ipc_push_device_state(uint16_t index, uint64_t value, const void *payload, + size_t payload_len) +{ + lipc_server *srv = ipc_srv_ctx; + + if (srv == NULL) { + return; + } + if (index > 1u) { + return; + } + (void)lipc_server_broadcast_notify(srv, X52D_IPC_PUSH_DEVICE_STATE, (uint16_t)LIPC_OK, index, + value, payload, payload_len); +} + +int x52d_ipc_init(const char *sock_path) +{ + const char *path; + int listen_fd; + lipc_server *srv; + int rc; + + ipc_srv_ctx = NULL; + ipc_listen_thread_started = false; + ipc_path_stored = NULL; + + path = x52d_ipc_sock_path(sock_path); + + listen_fd = lipc_socket_listen(path, X52D_MAX_CLIENTS, LIPC_SOCKET_NONBLOCK); + if (listen_fd < 0) { + PINELOG_ERROR(_("Error listening on framed IPC socket %s: %s"), path, strerror(errno)); + return -1; + } + + srv = lipc_server_create(listen_fd, LIPC_MAX_PAYLOAD_DEFAULT, + x52d_ipc_handlers_table(), x52d_ipc_handlers_count(), NULL); + if (srv == NULL) { + PINELOG_ERROR(_("Error creating framed IPC server for %s: %s"), path, strerror(errno)); + close(listen_fd); + unlink(path); + return -1; + } + + ipc_path_stored = path; + ipc_srv_ctx = srv; + rc = pthread_create(&ipc_listen_thread, NULL, ipc_listen_thread_main, srv); + if (rc != 0) { + PINELOG_ERROR(_("Error %d starting framed IPC thread for %s: %s"), rc, path, strerror(rc)); + lipc_server_destroy(srv); + ipc_srv_ctx = NULL; + unlink(path); + ipc_path_stored = NULL; + return -1; + } + + ipc_listen_thread_started = true; + PINELOG_INFO(_("Framed IPC server listening on %s"), path); + return 0; +} diff --git a/daemon/ipc_service.h b/daemon/ipc_service.h new file mode 100644 index 0000000..c1923cc --- /dev/null +++ b/daemon/ipc_service.h @@ -0,0 +1,31 @@ +/* + * Saitek X52 Pro MFD & LED driver - framed IPC listener (unified command + notify) + * + * Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org) + * + * SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 + */ + +#ifndef X52D_IPC_SERVICE_H +#define X52D_IPC_SERVICE_H + +/** + * Start the framed-IPC server on the given path (NULL = default from constants.h). + * One UNIX socket serves both RPC-style requests and server-initiated pushes. + * + * @return 0 on success, -1 on failure (errno set where applicable). + */ +int x52d_ipc_init(const char *sock_path); + +/** Stop the listener (wake + join + close) and remove the bound socket path. */ +void x52d_ipc_exit(void); + +/** + * Broadcast DEVICE_STATE (\c X52D_IPC_PUSH_DEVICE_STATE) to all framed-IPC clients. + * No-op when the IPC server is not running. @p index: 0 = disconnected, 1 = connected; + * @p value lower 32 bits encode \c (vid<<16)|pid per @ref x52d_ipc_device_state_pack_usb. + */ +void x52d_ipc_push_device_state(uint16_t index, uint64_t value, const void *payload, + size_t payload_len); + +#endif /* !defined(X52D_IPC_SERVICE_H) */ diff --git a/daemon/main.c b/daemon/main.c index fd134f5..5a404dd 100644 --- a/daemon/main.c +++ b/daemon/main.c @@ -25,9 +25,9 @@ #include #include #include +#include #include #include -#include #include "pinelog.h" static volatile int flag_quit; @@ -96,7 +96,8 @@ static void usage(int exit_code) "\t[-l log-file] [-o override]\n" "\t[-c config-file] [-p pid-file]\n" "\t[-s command-socket-path]\n" - "\t[-b notify-socket-path]\n"), + "\t[-b notify-socket-path]\n" + "\t[-S framed-ipc-socket-path]\n"), X52D_APP_NAME); exit(exit_code); } @@ -214,6 +215,7 @@ int main(int argc, char **argv) const char *pid_file = NULL; const char *command_sock = NULL; const char *notify_sock = NULL; + const char *ipc_sock = NULL; int opt; int rc; sigset_t sigblockset; @@ -241,8 +243,9 @@ int main(int argc, char **argv) * -p path to PID file (only used if running in background) * -s path to command socket * -b path to notify socket + * -S path to framed IPC socket (commands and notifications) */ - while ((opt = getopt(argc, argv, "fvql:o:c:p:s:b:h")) != -1) { + while ((opt = getopt(argc, argv, "fvql:o:c:p:s:b:S:h")) != -1) { switch (opt) { case 'f': foreground = true; @@ -291,6 +294,10 @@ int main(int argc, char **argv) notify_sock = optarg; break; + case 'S': + ipc_sock = optarg; + break; + case 'h': usage(EXIT_SUCCESS); break; @@ -301,6 +308,8 @@ int main(int argc, char **argv) } } + x52d_config_set_ipc_save_path(conf_file); + PINELOG_DEBUG(_("Foreground = %s"), foreground ? _("true") : _("false")); PINELOG_DEBUG(_("Quiet = %s"), quiet ? _("true") : _("false")); PINELOG_DEBUG(_("Verbosity = %d"), verbosity); @@ -309,6 +318,7 @@ int main(int argc, char **argv) PINELOG_DEBUG(_("PID file = %s"), pid_file); PINELOG_DEBUG(_("Command socket = %s"), command_sock); PINELOG_DEBUG(_("Notify socket = %s"), notify_sock); + PINELOG_DEBUG(_("Framed IPC socket = %s"), ipc_sock); start_daemon(foreground, pid_file); @@ -330,6 +340,9 @@ int main(int argc, char **argv) goto cleanup; } x52d_notify_init(notify_sock); + if (x52d_ipc_init(ipc_sock) < 0) { + goto cleanup; + } x52d_io_init(); x52d_mouse_handler_init(); @@ -350,14 +363,22 @@ int main(int argc, char **argv) /* Check if we need to reload configuration */ if (flag_reload) { PINELOG_INFO(_("Reloading X52 configuration")); - x52d_config_load(conf_file); + if (x52d_config_reload_canonical() != 0) { + PINELOG_ERROR(_("Canonical configuration reload failed")); + } x52d_config_apply(); flag_reload = false; } if (flag_save_cfg) { PINELOG_INFO(_("Saving X52 configuration to disk")); - x52d_config_save(conf_file); + { + int save_rc = x52d_config_save_session(); + + if (save_rc != 0) { + PINELOG_ERROR(_("Error saving configuration: %s"), strerror(save_rc)); + } + } flag_save_cfg = false; } } @@ -369,6 +390,7 @@ cleanup: // Stop device threads x52d_clock_exit(); x52d_dev_exit(); + x52d_ipc_exit(); x52d_command_exit(); x52d_notify_exit(); x52d_mouse_handler_exit(); diff --git a/daemon/meson.build b/daemon/meson.build index 1b66d20..a9d27ca 100644 --- a/daemon/meson.build +++ b/daemon/meson.build @@ -22,6 +22,9 @@ module_defs = custom_target('module-defs', command: [python, meson.current_source_dir() / 'x52d_gen_module.py', '@OUTPUT0@', '@OUTPUT1@']) +# Header for generated config section/option ids (build tree). +dep_config_defs_gen = declare_dependency(sources: config_defs[0]) + # Header only: ordering on module-map.h without compiling module-map.c per target. dep_module_map_gen = declare_dependency(sources: module_defs[0]) @@ -32,6 +35,8 @@ slib_comm_defs = static_library('x52dcommdefs', 'name-id-map.c', ) +dep_threads = dependency('threads') + libx52dcomm_version = '1.0.0' libx52dcomm_sources = [ @@ -42,7 +47,7 @@ libx52dcomm_sources = [ root_includes = include_directories('..') lib_libx52dcomm = library('x52dcomm', libx52dcomm_sources, - dependencies: [dep_intl, dep_config_h, dep_module_map_gen], + dependencies: [dep_intl, dep_config_h, dep_module_map_gen, dep_localipc, dep_threads], version: libx52dcomm_version, c_args: sym_hidden_cargs, install: true, @@ -52,10 +57,13 @@ pkgconfig.generate(lib_libx52dcomm, name: 'x52dcomm', description: 'Client library for communicating with the x52d X52 daemon.', version: libx52dcomm_version, + requires: ['localipc'], ) x52d_sources = [ 'main.c', + 'ipc_service.c', + 'ipc_lipc_handlers.c', 'config_parser.c', 'config_dump.c', 'config.c', @@ -74,12 +82,10 @@ x52d_sources = [ 'crc32.c', ] -dep_threads = dependency('threads') - -# Comm sources are compiled into x52d (same as Autotools); libx52dcomm is only for x52ctl. +# Comm sources are compiled into x52d (same as Autotools); libx52dcomm is for external clients and x52ctl (Python + ctypes). x52d_linkwith = [lib_libx52, lib_vkm, lib_libx52io, slib_comm_defs] x52d_deps = [dep_pinelog, dep_inih, dep_threads, dep_math, dep_intl, dep_config_h, - dep_module_map_gen] + dep_module_map_gen, dep_config_defs_gen, dep_localipc] x52d_cflags = [] exe_x52d = executable('x52d', x52d_sources + libx52dcomm_sources, @@ -89,11 +95,58 @@ exe_x52d = executable('x52d', x52d_sources + libx52dcomm_sources, dependencies: x52d_deps, link_with: x52d_linkwith) -exe_x52ctl = executable('x52ctl', 'daemon_control.c', - install: true, - dependencies: [dep_intl, dep_config_h, dep_module_map_gen], - include_directories: [includes, root_includes], - link_with: lib_libx52dcomm) +x52ctl_script = configure_file( + input: 'x52ctl.in', + output: 'x52ctl', + configuration: { + 'PYTHON': python.full_path(), + 'X52D_PY_PKG': meson.current_build_dir(), + }, + install: false, +) + +x52d_python_install_dir = join_paths(get_option('datadir'), 'x52d', 'python') +x52ctl_pymain_abs = join_paths(get_option('prefix'), x52d_python_install_dir, 'x52ctl_main.py') + +x52ctl_installed_sh = configure_file( + input: 'x52ctl-installed.in', + output: 'x52ctl.inst', + configuration: { + 'PYTHON': '/usr/bin/env python3', + 'X52CTL_PYMAIN': x52ctl_pymain_abs, + }, + install: false, +) + +x52ctl_buildpath = join_paths(meson.current_build_dir(), 'x52ctl') +x52ctl_test_dep = custom_target( + 'x52ctl-test-dep', + command: [ + find_program('sh'), '-c', 'chmod 755 "$1" && touch "$2"', + '_', x52ctl_buildpath, '@OUTPUT@', + ], + output: 'x52ctl-test.stamp', + depend_files: [x52ctl_script], +) + +install_data( + x52ctl_installed_sh, + install_dir: get_option('bindir'), + install_mode: 'rwxr-xr-x', + rename: 'x52ctl', +) + +install_data( + files('command_defs.py', 'module_defs.py', 'x52ctl_main.py'), + install_dir: x52d_python_install_dir, +) + +meson.add_install_script( + python, + files('install_copy_file.py'), + join_paths(meson.current_build_dir(), 'config_defs.py'), + join_paths(get_option('prefix'), x52d_python_install_dir, 'config_defs.py'), +) install_data('x52d.conf', install_dir: join_paths(get_option('sysconfdir'), 'x52d')) @@ -112,7 +165,7 @@ us_x52l = custom_target( install_dir: join_paths(get_option('datadir'), 'x52d')) test('daemon-communication', files('test_daemon_comm.py')[0], - depends: [exe_x52d, exe_x52ctl], protocol: 'tap') + depends: [exe_x52d, config_defs, x52ctl_test_dep], protocol: 'tap') x52d_mouse_test_sources = ['mouse_test.c', 'mouse.c'] x52d_mouse_test = executable('x52d-mouse-test', x52d_mouse_test_sources, diff --git a/daemon/notify.c b/daemon/notify.c index 8f74e9d..c5b497e 100644 --- a/daemon/notify.c +++ b/daemon/notify.c @@ -14,9 +14,13 @@ #define PINELOG_MODULE X52D_MOD_NOTIFY #include "pinelog.h" #include +#include #include #include +#include +#define X52DCOMM_NO_DEPRECATED_ATTR 1 #include +#undef X52DCOMM_NO_DEPRECATED_ATTR #include static pthread_t notify_thr; @@ -107,6 +111,23 @@ static void * x52_notify_thr(void * param) return NULL; } +void x52d_notify_device_state(int connected, uint16_t vid, uint16_t pid, const char *product_utf8, + size_t product_len) +{ + uint64_t val = x52d_ipc_device_state_pack_usb(vid, pid); + + if (connected) { + X52D_NOTIFY("CONNECTED"); + } else { + X52D_NOTIFY("DISCONNECTED"); + } + + if (product_utf8 == NULL) { + product_len = 0; + } + x52d_ipc_push_device_state(connected ? 1u : 0u, val, product_utf8, product_len); +} + void x52d_notify_send(int argc, const char **argv) { char buffer[X52D_BUFSZ + sizeof(uint16_t)]; diff --git a/daemon/notify.h b/daemon/notify.h index fade651..a1990c1 100644 --- a/daemon/notify.h +++ b/daemon/notify.h @@ -13,6 +13,16 @@ void x52d_notify_init(const char *notify_sock_path); void x52d_notify_exit(void); void x52d_notify_send(int argc, const char **argv); +/** + * Emit legacy NUL notify plus framed IPC DEVICE_STATE. + * @param connected Non-zero when the device is connected, zero on disconnect. + * @param vid @c idVendor (meaningful when @p connected; on disconnect, pass last device ids). + * @param pid @c idProduct + * @param product_utf8 Optional product name (connect only); may be NULL when @p product_len is 0. + */ +void x52d_notify_device_state(int connected, uint16_t vid, uint16_t pid, const char *product_utf8, + size_t product_len); + #define X52D_NOTIFY(...) do { \ const char *argv ## __LINE__ [] = {__VA_ARGS__}; \ x52d_notify_send(sizeof(argv ## __LINE__ )/sizeof(argv ## __LINE__ [0]), argv ## __LINE__ ); \ diff --git a/daemon/protocol.dox b/daemon/protocol.dox index 3c50de2..5aae469 100644 --- a/daemon/protocol.dox +++ b/daemon/protocol.dox @@ -1,24 +1,56 @@ /** -@page x52d_protocol X52 daemon socket communication protocol +@page x52d_protocol X52 daemon control and notification protocols -The X52 daemon creates a Unix domain stream socket, by default at -`$(LOCALSTATEDIR)/run/x52d.cmd` and listens for connection requests from -clients at this location. This can be overridden by passing the -s flag when -starting the daemon. +@section proto_supported Primary path: framed LIPC (liblocalipc) -# Protocol Overview +The **supported** integration surface for daemon control and for **push** +notifications is **liblocalipc** on a single UNIX **stream** socket. By default +the daemon listens at `$(LOCALSTATEDIR)/run/x52d.socket` (same layout as other +runtime files under the configured runtime directory). The path is overridden +with the daemon’s \c -S option. -\b x52d requires that clients send it commands as a series of NUL terminated +Clients should use \ref x52d_dial_ipc and \ref x52d_ipc_call from +\ref x52dcomm.h (and opcode / field definitions in \ref x52d_ipc.h). RPC +replies carry a non-zero transaction id (\c lipc_header.tid) matching the +request. **Server push** frames use \c tid == \c 0; see \ref proto_lipc_framed +and especially \ref lipc_push_device_state. + +For a full description of opcodes, configuration save paths, reload order, and +logging wire ids, see @subpage proto_lipc_framed. + +@section proto_deprecated Legacy NUL-separated sockets (deprecated) + +The **legacy command** socket (\c x52d.cmd) and **legacy notify** socket +(\c x52d.notify) use a NUL-terminated “argv” style wire format. They remain +available **only during migration** of existing tools and scripts. + +\b These legacy sockets and their wire format are \b deprecated and \b will be +\b removed in a future release. New code must use the framed LIPC socket +described above. + +Default paths are under `$(LOCALSTATEDIR)/run/` (see \c X52D_SOCK_COMMAND and +\c X52D_SOCK_NOTIFY in the daemon). The daemon overrides them with \c -s +(command) and \c -b (notify). + +The legacy client helpers in \ref x52dcomm.h (\c x52d_dial_command, +\c x52d_format_command, \c x52d_send_command, \c x52d_dial_notify, +\c x52d_recv_notification) are deprecated with the **same removal policy** as +the sockets. Use \c x52d_ipc_socket_path, \c x52d_dial_ipc, and \c x52d_ipc_call +instead. + +@subsection proto_legacy_cmd Legacy NUL command protocol + +\b x52d requires that clients send commands as a series of NUL terminated strings, without any interleaving space. The command should be sent in a -single `send` call, and the client may expect a response in a single `recv` +single \c send call, and the client may expect a response in a single \c recv call. -The `send` call must send exactly the number of bytes in the command text. +The \c send call must send exactly the number of bytes in the command text. Extra bytes will be treated as additional arguments, which would cause the -command to fail. It is recommended that the `recv` call uses a 1024 byte buffer +command to fail. It is recommended that the \c recv call uses a 1024 byte buffer to read the data. Responses will never exceed this length. -# Responses +@subsubsection proto_legacy_resp Responses The daemon sends the response as a series of NUL terminated strings, without any interleaving space. The first string is always one of the following: @@ -30,40 +62,52 @@ any interleaving space. The first string is always one of the following: This determines whether the request was successful or not, and subsequent strings describe the action, error or requested data. -# Examples - -## Reloading configuration +@subsubsection proto_legacy_ex Examples +@par Reloading configuration - \b send config\0reload\0 - \b recv OK\0config\0reload\0 -## Reading mouse speed - +@par Reading mouse speed - \b send config\0get\0mouse\0speed\0 - \b recv DATA\0mouse\0speed\010\0 -## Sending an invalid command - +@par Sending an invalid command - \b send config reload - \b recv ERR\0Unknown command 'config reload'\0 -# Commands +@subsection proto_legacy_vocab Legacy command vocabulary \b x52d commands are arranged in a hierarchical fashion as follows: -``` +@code{.unparsed} [ [ [...]]] [] -``` +@endcode -The list of supported commands are shown below: +The list of supported **legacy NUL** commands are shown below (superseded on +the wire by @ref proto_lipc_framed where an opcode exists): - @subpage proto_config - @subpage proto_logging +@subsection proto_legacy_notify Legacy notify socket (deprecated) + +Subscribers connect to the legacy notify socket (\c x52d.notify by default). +The daemon broadcasts short NUL-framed messages (same packing style as the +legacy command responses) when internal events occur. Notably, device connect +and disconnect were historically reported as string events such as +\c CONNECTED and \c DISCONNECTED. + +On the **framed LIPC** socket, the same information is carried by the +\c DEVICE_STATE push; see @ref lipc_push_device_state. Prefer subscribing on +the unified LIPC path before legacy removal. */ /** -@page proto_config Configuration management +@page proto_config Configuration management (legacy NUL command socket) + +@note This page documents the **deprecated** NUL-separated \c x52d.cmd protocol. + For new integrations use @ref proto_lipc_framed (\c CONFIG_* opcodes). The \c config commands deal with \b x52d configuration subsystem, and have the following subcommands. @@ -223,7 +267,10 @@ ERR\0Error 22 setting 'led.fire'='none': Invalid argument\0 */ /** -@page proto_logging Logging management +@page proto_logging Logging management (legacy NUL command socket) + +@note This page documents the **deprecated** NUL-separated \c x52d.cmd protocol. + For new integrations use @ref proto_lipc_framed (\c LOGGING_SHOW / \c LOGGING_SET). The \c logging commands allow the user to fine tune the logging configuration of \c x52d as well as adjust the log levels for either all the modules, or for @@ -306,3 +353,110 @@ otherwise. - \a module-name (if specified) - \a log-level */ + +/** +@page proto_lipc_framed Framed LIPC control and notify (x52d.socket) + +The daemon exposes **liblocalipc** on a UNIX stream socket (default under +`$(LOCALSTATEDIR)/run/`, basename \c x52d.socket, overridable with \c -S). This +is the **only** supported long-term path for control-plane RPC and for **push** +notifications. Request opcodes for configuration and logging match +\ref x52d_ipc.h; field semantics and on-disk layout are summarized here. + +@section lipc_push_device_state LIPC push: DEVICE_STATE (\c tid == 0) + +When the USB device connects or disconnects, the daemon may emit a **push** +frame: \c lipc_header.tid is **zero**, and \c lipc_header.request identifies the +push type \c DEVICE_STATE, wire id \c X52D_IPC_PUSH_DEVICE_STATE (\c 0x8001) in +\ref x52d_ipc.h — distinct from RPC opcodes \c 0x01–\c 0x08 and \c 0x11–\c 0x12. + +Semantics: + +- \c lipc_header.index — **connection state**: \c 0 = disconnected, \c 1 = connected. +- \c lipc_header.value — USB identity in the **lower 32 bits**: + \c (uint32_t)((idVendor << 16) | idProduct) with 16-bit USB \c idVendor and + \c idProduct (upper bits reserved, zero unless extended later). On **connect** + (\c index == 1), this reflects the **current** device. On **disconnect** + (\c index == 0), the daemon **preserves the last connected** vendor/product id + so clients know **which** device dropped; if no device was ever connected in + this process lifetime, the lower 32 bits are \c 0. +- **Payload** — optional UTF-8. When **connected** (\c index == 1), the daemon + **may** include the USB **product** string (\c lipc_header.length > 0). Clients + must accept **zero-length** payload. When **disconnected**, payload is + normally empty. +- \c lipc_header.status — typically \c LIPC_OK for a well-formed push. + +This push supersedes the legacy notify strings \c CONNECTED / \c DISCONNECTED +on the deprecated \c x52d.notify socket. + +@section lipc_state_path Runtime configuration file + +Persistent settings written by @c CONFIG_SAVE (and removed by @c CONFIG_CLEAR +when targeting state) live at: + +@code +$(LOCALSTATEDIR)/lib/x52d/x52d.conf +@endcode + +(\c X52D_STATE_CFG_FILE in daemon sources). The daemon writes this path with a temporary file in +the same directory followed by @c rename(2) so readers never see a torn file. +Static defaults shipped by the distribution use \c X52D_SYS_CFG_FILE under +@c $(SYSCONFDIR). + +@section lipc_config_get_set CONFIG_GET and CONFIG_SET (registry ids) + +@c CONFIG_SET (@c 0x07) and @c CONFIG_GET (@c 0x08) address a single configuration key using +numeric identifiers from the build-time registry (@c config_registry.json → @c config-defs.h / +@c config-defs.c), not free-form section/option strings on the wire: + +- @c lipc_header.index — section id (e.g. @c CFG_SECTION_CLOCK, @c CFG_SECTION_LED, ...). +- @c lipc_header.value — option id within that section (e.g. @c CFG_OPTION_CLOCK_ENABLED). + The value must fit in 16 bits; garbage in the upper bits of the 64-bit wire field is rejected. + +@c CONFIG_SET request payload is the new value as a UTF-8 string (length = @c lipc_header.length). +The daemon validates @c (index, value) with @c x52d_config_registry_pair_valid(), resolves names +via @c section_names / @c option_names, then applies the same parsing as file-based configuration +(@c x52d_config_set + @c x52d_config_apply_immediate). + +@c CONFIG_GET uses the same @c index / @c value selection; the reply payload is a single string, +the same logical text as other dump paths (@c x52d_config_get / dumpers), not the legacy +NUL-separated @c DATA argv shape. + +@section lipc_config_clear CONFIG_CLEAR targets + +The LIPC @c CONFIG_CLEAR request selects a file via @c lipc_header.index: + +- @c X52D_IPC_CONFIG_CLEAR_TARGET_STATE (@c 1): delete the runtime state file + (\ref lipc_state_path). +- @c X52D_IPC_CONFIG_CLEAR_TARGET_SYSCONF (@c 2): delete the system configuration file + (\c X52D_SYS_CFG_FILE under \c $(SYSCONFDIR)). + +After attempting @c unlink(2), the daemon always runs the same reload sequence +as @c CONFIG_RELOAD: load the state file if it exists, else the sysconf file if +it exists, else defaults (then apply CLI overrides). If deleting the sysconf file +fails (e.g. read-only filesystem), the reply uses a non-success status with the +unlink error in the payload, but reload and apply still run so runtime state +stays consistent. + +@section lipc_config_reload_order Canonical reload order + +@c CONFIG_RELOAD and the post-@c CONFIG_CLEAR reload both prefer the state file +when present and readable, then the system configuration file, otherwise +in-memory defaults. + +@section lipc_logging_show_set LOGGING_SHOW (@c 0x11) and LOGGING_SET (@c 0x12) + +Logging control uses numeric ids from the same build-time tables as the legacy +NUL command path (@c module_defs.py → @c module-map.h / @c module-map.c and +@c loglevel_map / @c lookup_level_by_id). There are no module or level names on the LIPC wire. + +- @c lipc_header.index — module id (@c X52D_MOD_* values, @c 0 … @c X52D_MOD_MAX - 1), or + @c X52D_MOD_GLOBAL (@c 0xFF) for the global log level. Clients must use @c x52d_module_wire_valid() + semantics (or the equivalent): any other @c index is invalid. +- @c lipc_header.value — for @c LOGGING_SET only, the log level id in the same numeric space as + @c lookup_level_by_id (including @c FATAL as @c 0 and negative ids such as @c NOTSET where present + in the map, encoded in the 64-bit field as two's-complement). Validated with @c x52d_log_level_wire_valid(). + For @c LOGGING_SHOW, @c value must be zero (@c LIPC_FIELD_FORBIDDEN); the current level is read from pinelog. + +@c LOGGING_SHOW reply payload is a single string (the level label, e.g. @c info), not the legacy @c DATA argv shape. +*/ diff --git a/daemon/test_daemon_comm.py b/daemon/test_daemon_comm.py index 50ec7b4..3b6f4a3 100755 --- a/daemon/test_daemon_comm.py +++ b/daemon/test_daemon_comm.py @@ -49,7 +49,7 @@ class TestCase: print(out) cmd = [suite.find_control_program(), - '-s', suite.command, '--', + '-s', suite.ipc_socket, *self.in_cmd] testcase = subprocess.run(cmd, stdout=subprocess.PIPE, check=False) @@ -75,6 +75,7 @@ class Test: self.tmpdir = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with self.command = os.path.join(self.tmpdir.name, "x52d.cmd") self.notify = os.path.join(self.tmpdir.name, "x52d.notify") + self.ipc_socket = os.path.join(self.tmpdir.name, "x52d.socket") self.daemon = None self.testcases = [] @@ -128,6 +129,7 @@ class Test: "-p", os.path.join(self.tmpdir.name, "x52d.pid"), # PID file "-s", self.command, # Command socket path "-b", self.notify, # Notification socket path + "-S", self.ipc_socket, # Unified framed IPC socket path ] # Create empty config file diff --git a/daemon/x52ctl-installed.in b/daemon/x52ctl-installed.in new file mode 100644 index 0000000..e4faab5 --- /dev/null +++ b/daemon/x52ctl-installed.in @@ -0,0 +1,3 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 +exec "@PYTHON@" "@X52CTL_PYMAIN@" "$@" diff --git a/daemon/x52ctl.dox b/daemon/x52ctl.dox new file mode 100644 index 0000000..acbe666 --- /dev/null +++ b/daemon/x52ctl.dox @@ -0,0 +1,42 @@ +/** +@page x52ctl Command line controller to X52 daemon (Python) + +\htmlonly +x52ctl - Command line controller to X52 daemon +\endhtmlonly + +# SYNOPSIS +\b x52ctl [\a -i] [\a -s PATH] \b config \a ... +
+\b x52ctl [\a -i] [\a -s PATH] \b logging \a ... + +# DESCRIPTION + +x52ctl sends framed LIPC requests to the running x52d daemon on its unified +control socket (see @ref x52d_ipc.h and @ref proto_lipc_framed). It can be used +as a one-shot program from scripts or interactively. + +Responses are written to standard output in the same NUL-separated form as the +legacy command socket used historically (\c OK / \c ERR / \c DATA followed by +NUL-terminated string fields), so existing parsers can keep working. + +If not running interactively, a full command (e.g. \c config reload) is required +or the program exits with a non-zero status. In interactive mode (\c -i), +lines are read from standard input and executed until \c quit or end-of-file. + +# OPTIONS + +- \b -i, \b --interactive + Run in interactive mode. Extra non-option arguments are ignored. + +- \b -s \a PATH, \b --socket \a PATH + Use the daemon framed IPC socket at \a PATH. If omitted, the default path + compiled into libx52dcomm is used (same layout as the x52d \c RUNDIR socket). + +# COMMANDS + +Subcommands are grouped under \b config and \b logging (see \c x52ctl --help). + +# SEE ALSO +@ref x52d, @ref x52dcomm, @ref proto_lipc_framed +*/ diff --git a/daemon/x52ctl.in b/daemon/x52ctl.in new file mode 100644 index 0000000..8ceb95a --- /dev/null +++ b/daemon/x52ctl.in @@ -0,0 +1,31 @@ +#!@PYTHON@ +# SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 +"""Uninstalled launcher: prepend build dir for generated config_defs.""" +import os +import sys + +_BUILD_PKG = r'@X52D_PY_PKG@' +if _BUILD_PKG and _BUILD_PKG not in sys.path: + sys.path.insert(0, _BUILD_PKG) + +_here = os.path.dirname(os.path.abspath(__file__)) +if 'X52DCOMM_LIB' not in os.environ: + for _name in ( + 'libx52dcomm.so', + 'libx52dcomm.so.1', + 'libx52dcomm.so.1.0.0', + 'libx52dcomm.dylib', + ): + _cand = os.path.join(_here, _name) + if os.path.isfile(_cand): + os.environ['X52DCOMM_LIB'] = _cand + break + +_src_dir = os.path.normpath(os.path.join(_here, '..', '..', 'daemon')) +if os.path.isfile(os.path.join(_src_dir, 'x52ctl_main.py')) and _src_dir not in sys.path: + sys.path.insert(0, _src_dir) + +from x52ctl_main import main + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/daemon/x52ctl_main.py b/daemon/x52ctl_main.py new file mode 100644 index 0000000..df06adb --- /dev/null +++ b/daemon/x52ctl_main.py @@ -0,0 +1,615 @@ +# SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 +"""x52ctl: CLI for x52d using framed LIPC (libx52dcomm).""" + +from __future__ import annotations + +import argparse +import errno +import os +import shlex +import sys + +_pkg = os.path.dirname(os.path.abspath(__file__)) +if _pkg not in sys.path: + sys.path.insert(0, _pkg) +from ctypes import ( + CDLL, + POINTER, + Structure, + byref, + c_char_p, + c_int, + c_size_t, + c_uint16, + c_uint32, + c_uint64, + c_void_p, + cast, + create_string_buffer, + get_errno, +) + +import module_defs +from command_defs import ConfigClearTarget, IpcRequest, X52D_MOD_GLOBAL + +LIPC_OK = 0 + + +class LIPCHeader(Structure): + _fields_ = [ + ('magic', c_uint32), + ('version', c_uint16), + ('request', c_uint16), + ('status', c_uint16), + ('index', c_uint16), + ('tid', c_uint32), + ('length', c_uint32), + ('checksum', c_uint32), + ('value', c_uint64), + ] + + +def _load_comm_lib() -> CDLL: + path = os.environ.get('X52DCOMM_LIB') + names = (path, 'libx52dcomm.so.1', 'libx52dcomm.so') + last: OSError | None = None + for cand in names: + if not cand: + continue + try: + return CDLL(cand) + except OSError as exc: # pragma: no cover + last = exc + raise OSError(last or 'cannot load libx52dcomm') + + +_LIB = None + + +def _lib() -> CDLL: + global _LIB + if _LIB is None: + _LIB = _load_comm_lib() + _LIB.x52d_dial_ipc.argtypes = [c_char_p] + _LIB.x52d_dial_ipc.restype = c_int + _LIB.x52d_ipc_call.argtypes = [ + c_int, + c_uint16, + c_uint16, + c_uint64, + c_void_p, + c_size_t, + POINTER(LIPCHeader), + c_void_p, + c_size_t, + POINTER(c_size_t), + ] + _LIB.x52d_ipc_call.restype = c_int + return _LIB + + +def _nul_out(parts: list[str]) -> bytes: + return b'\0'.join(p.encode('utf-8') for p in parts) + b'\0' + + +def _ipc_call( + fd: int, + request: int, + index: int, + value: int, + payload: bytes | None, + reply_cap: int = 512 * 1024, +) -> tuple[int, LIPCHeader, bytes]: + pl = payload or b'' + hdr = LIPCHeader() + rbuf = create_string_buffer(reply_cap) + rlen = c_size_t(0) + if pl: + pl_buf = create_string_buffer(pl, len(pl)) + pl_ptr = cast(pl_buf, c_void_p) + pl_len = len(pl) + else: + pl_ptr = None + pl_len = 0 + st = _lib().x52d_ipc_call( + fd, + request, + index, + value, + pl_ptr, + pl_len, + byref(hdr), + rbuf, + reply_cap, + byref(rlen), + ) + raw = bytes(rbuf[: rlen.value]) + if int(st) != LIPC_OK: + return int(st), hdr, raw + # lipc_client_call returns LIPC_OK when a reply frame arrived; check wire status. + if int(hdr.status) != LIPC_OK: + return int(hdr.status), hdr, raw + return LIPC_OK, hdr, raw + + +def _errno_from_strerror(msg: str) -> int | None: + for code in range(1, 200): + try: + if os.strerror(code) == msg: + return code + except OSError: + break + return None + + +def _format_config_set_err(sec: str, opt: str, val: str, srv: str) -> str: + n = _errno_from_strerror(srv) + if n is not None: + return f"Error {n} setting '{sec}.{opt}'='{val}': {srv}" + return srv + + +def _map_config_get_err(sec: str, opt: str, srv: str) -> str: + if 'configuration key not found' in srv or srv == 'configuration key not found': + return f"Error getting '{sec}.{opt}'" + return srv + + +def _import_config_defs(): + import config_defs as cd # pylint: disable=import-outside-toplevel + + return cd + + +def _config_wire(sec: str, opt: str): + cd = _import_config_defs() + sec_id = getattr(cd.Section, sec.upper()).value + opt_cls = getattr(cd, cd.Section(sec_id).name) + opt_id = getattr(opt_cls, opt.upper()).value + return sec_id, opt_id + + +def _lookup_module(name: str) -> int | None: + try: + return module_defs.Module[name.upper()].value + except KeyError: + return None + + +def _lookup_level(name: str) -> int | None: + key = name.lower() + for lvl in module_defs.LogLevel: + lbl = 'default' if lvl == module_defs.LogLevel.NOTSET else lvl.name.lower() + if lbl == key: + return int(lvl.value) + return None + + +def _level_to_wire(level: int) -> int: + return level & ((1 << 64) - 1) + + +def _open_ipc(sock_path: str | None) -> int: + arg = sock_path.encode('utf-8') if sock_path else None + fd = _lib().x52d_dial_ipc(arg) + if fd < 0: + err = get_errno() + raise OSError(err, os.strerror(err)) + return fd + + +def cmd_config_load(fd: int, path: str) -> bytes: + if not path: + return _nul_out(['ERR', "Invalid file '' for 'config load' command"]) + try: + if not os.path.exists(path) or not os.access(path, os.R_OK): + return _nul_out(['ERR', f"Invalid file '{path}' for 'config load' command"]) + except OSError: + return _nul_out(['ERR', f"Invalid file '{path}' for 'config load' command"]) + st, _hdr, pl = _ipc_call(fd, IpcRequest.CONFIG_LOAD, 0, 0, os.fsencode(path)) + if st != LIPC_OK: + return _nul_out(['ERR', os.fsdecode(pl) if pl else f'IPC error {st}']) + return _nul_out(['OK', 'config', 'load', path]) + + +def cmd_config_reload(fd: int) -> bytes: + st, _h, pl = _ipc_call(fd, IpcRequest.CONFIG_RELOAD, 0, 0, None) + if st != LIPC_OK: + return _nul_out(['ERR', pl.decode('utf-8', 'replace') if pl else f'IPC error {st}']) + return _nul_out(['OK', 'config', 'reload']) + + +def cmd_config_reset(fd: int) -> bytes: + st, _h, pl = _ipc_call(fd, IpcRequest.CONFIG_RESET, 0, 0, None) + if st != LIPC_OK: + return _nul_out(['ERR', pl.decode('utf-8', 'replace') if pl else f'IPC error {st}']) + return _nul_out(['OK', 'config', 'reset']) + + +def cmd_config_clear(fd: int, target: str) -> bytes: + tmap = {'state': ConfigClearTarget.STATE, 'sysconf': ConfigClearTarget.SYSCONF} + if target not in tmap: + return _nul_out(['ERR', f'unknown clear target {target!r}']) + st, _h, pl = _ipc_call(fd, IpcRequest.CONFIG_CLEAR, int(tmap[target]), 0, None) + if st != LIPC_OK: + return _nul_out(['ERR', pl.decode('utf-8', 'replace') if pl else f'IPC error {st}']) + return _nul_out(['OK', 'config', 'clear', target]) + + +def cmd_config_save(fd: int) -> bytes: + st, _h, pl = _ipc_call(fd, IpcRequest.CONFIG_SAVE, 0, 0, None) + if st != LIPC_OK: + return _nul_out(['ERR', pl.decode('utf-8', 'replace') if pl else f'IPC error {st}']) + return _nul_out(['OK', 'config', 'save']) + + +def cmd_config_dump(fd: int, out_path: str) -> bytes: + if not out_path: + return _nul_out(['ERR', "Invalid file '' for 'config dump' command"]) + parent = os.path.dirname(os.path.abspath(out_path)) or '/' + if not os.path.isdir(parent): + return _nul_out(['ERR', f"Invalid file '{out_path}' for 'config dump' command"]) + st, _h, pl = _ipc_call(fd, IpcRequest.CONFIG_DUMP, 0, 0, None) + if st != LIPC_OK: + return _nul_out(['ERR', pl.decode('utf-8', 'replace') if pl else f'IPC error {st}']) + try: + with open(out_path, 'wb') as out_f: + out_f.write(pl) + except OSError as exc: + return _nul_out(['ERR', str(exc)]) + return _nul_out(['OK', 'config', 'dump', out_path]) + + +def cmd_config_get(fd: int, sec: str, opt: str) -> bytes: + try: + sid, oid = _config_wire(sec, opt) + except (AttributeError, KeyError, ValueError): + return _nul_out(['ERR', _map_config_get_err(sec, opt, 'configuration key not found')]) + st, _h, pl = _ipc_call(fd, IpcRequest.CONFIG_GET, sid, oid, None) + if st != LIPC_OK: + msg = pl.decode('utf-8', 'replace') if pl else '' + return _nul_out(['ERR', _map_config_get_err(sec, opt, msg)]) + val = pl.decode('utf-8', 'replace') + return _nul_out(['DATA', sec, opt, val]) + + +def cmd_config_set(fd: int, sec: str, opt: str, val: str) -> bytes: + try: + sid, oid = _config_wire(sec, opt) + except (AttributeError, KeyError, ValueError): + n = errno.EINVAL + return _nul_out(['ERR', _format_config_set_err(sec, opt, val, os.strerror(n))]) + st, _h, pl = _ipc_call(fd, IpcRequest.CONFIG_SET, sid, oid, val.encode('utf-8')) + if st != LIPC_OK: + msg = pl.decode('utf-8', 'replace') if pl else '' + return _nul_out(['ERR', _format_config_set_err(sec, opt, val, msg)]) + return _nul_out(['OK', 'config', 'set', sec, opt, val]) + + +def cmd_logging_show(fd: int, module: str | None) -> bytes: + if module is None: + idx = X52D_MOD_GLOBAL + label = 'global' + else: + mid = _lookup_module(module) + if mid is None: + return _nul_out(['ERR', f"Invalid module '{module}'"]) + idx = mid + label = module.lower() + st, _h, pl = _ipc_call(fd, IpcRequest.LOGGING_SHOW, idx, 0, None) + if st != LIPC_OK: + return _nul_out(['ERR', pl.decode('utf-8', 'replace') if pl else f'IPC error {st}']) + lvl_name = pl.decode('utf-8', 'replace').strip() + return _nul_out(['DATA', label, lvl_name]) + + +def cmd_logging_set(fd: int, module: str | None, level_name: str) -> bytes: + level = _lookup_level(level_name) + if level is None: + return _nul_out(['ERR', f"Unknown level '{level_name}' for 'logging set' command"]) + if module is None: + if level == int(module_defs.LogLevel.NOTSET.value): + return _nul_out(["ERR", "'default' level is not valid without a module"]) + st, _h, pl = _ipc_call( + fd, IpcRequest.LOGGING_SET, X52D_MOD_GLOBAL, _level_to_wire(level), None + ) + if st != LIPC_OK: + return _nul_out(['ERR', pl.decode('utf-8', 'replace') if pl else f'IPC error {st}']) + return _nul_out(['OK', 'logging', 'set', level_name.lower()]) + mid = _lookup_module(module) + if mid is None: + return _nul_out(['ERR', f"Invalid module '{module}'"]) + st, _h, pl = _ipc_call(fd, IpcRequest.LOGGING_SET, mid, _level_to_wire(level), None) + if st != LIPC_OK: + return _nul_out(['ERR', pl.decode('utf-8', 'replace') if pl else f'IPC error {st}']) + return _nul_out(['OK', 'logging', 'set', module.lower(), level_name.lower()]) + + +def _legacy_argc_errors(argv: list[str]) -> bytes | None: + if not argv: + return None + if argv[0] == 'config': + if len(argv) < 2: + return _nul_out(['ERR', "Insufficient arguments for 'config' command"]) + sub = argv[1] + if sub == 'load': + if len(argv) != 3: + return _nul_out( + [ + 'ERR', + f"Unexpected arguments for 'config load' command; got {len(argv)}, expected 3", + ] + ) + elif sub == 'reload': + if len(argv) != 2: + return _nul_out( + [ + 'ERR', + f"Unexpected arguments for 'config reload' command; got {len(argv)}, expected 2", + ] + ) + elif sub == 'dump': + if len(argv) != 3: + return _nul_out( + [ + 'ERR', + f"Unexpected arguments for 'config dump' command; got {len(argv)}, expected 3", + ] + ) + elif sub == 'save': + if len(argv) != 2: + return _nul_out( + [ + 'ERR', + f"Unexpected arguments for 'config save' command; got {len(argv)}, expected 2", + ] + ) + elif sub == 'clear': + if len(argv) != 3: + return _nul_out( + [ + 'ERR', + f"Unexpected arguments for 'config clear' command; got {len(argv)}, expected 3", + ] + ) + elif sub == 'get': + if len(argv) != 4: + return _nul_out( + [ + 'ERR', + f"Unexpected arguments for 'config get' command; got {len(argv)}, expected 4", + ] + ) + elif sub == 'set': + if len(argv) != 5: + return _nul_out( + [ + 'ERR', + f"Unexpected arguments for 'config set' command; got {len(argv)}, expected 5", + ] + ) + elif sub == '': + return _nul_out(['ERR', "Unknown subcommand '' for 'config' command"]) + elif sub not in ('load', 'reload', 'reset', 'save', 'dump', 'clear', 'get', 'set'): + return _nul_out(['ERR', f"Unknown subcommand '{sub}' for 'config' command"]) + if argv[0] == 'logging': + if len(argv) < 2: + return _nul_out(['ERR', "Insufficient arguments for 'logging' command"]) + sub = argv[1] + if sub not in ('show', 'set'): + return _nul_out(['ERR', f"Unknown subcommand '{sub}' for 'logging' command"]) + if sub == 'show' and len(argv) > 3: + return _nul_out( + [ + 'ERR', + f"Unexpected arguments for 'logging show' command; got {len(argv)}, expected 2 or 3", + ] + ) + if sub == 'set': + if len(argv) < 3: + return _nul_out( + [ + 'ERR', + "Unexpected arguments for 'logging set' command; got 2, expected 3 or 4", + ] + ) + if len(argv) > 4: + return _nul_out( + [ + 'ERR', + f"Unexpected arguments for 'logging set' command; got {len(argv)}, expected 3 or 4", + ] + ) + return None + + +def _build_cmd_parser() -> argparse.ArgumentParser: + """Parse `config` / `logging` subcommands; trailing tokens go in `extras` where listed.""" + kw: dict = {'prog': 'x52ctl', 'add_help': False} + if sys.version_info >= (3, 9): + kw['exit_on_error'] = False + p = argparse.ArgumentParser(**kw) + sub = p.add_subparsers(dest='cmd', required=True) + + pc = sub.add_parser('config') + pcs = pc.add_subparsers(dest='sub', required=True) + + pr = pcs.add_parser('reload') + pr.add_argument('extras', nargs='*', default=[]) + + prs = pcs.add_parser('reset') + prs.add_argument('extras', nargs='*', default=[]) + + psa = pcs.add_parser('save') + psa.add_argument('extras', nargs='*', default=[]) + + pld = pcs.add_parser('load') + pld.add_argument('path') + pld.add_argument('extras', nargs='*', default=[]) + + pcl = pcs.add_parser('clear') + pcl.add_argument('target', choices=('state', 'sysconf')) + pcl.add_argument('extras', nargs='*', default=[]) + + pdu = pcs.add_parser('dump') + pdu.add_argument('path') + pdu.add_argument('extras', nargs='*', default=[]) + + pge = pcs.add_parser('get') + pge.add_argument('section') + pge.add_argument('option') + pge.add_argument('extras', nargs='*', default=[]) + + pst = pcs.add_parser('set') + pst.add_argument('section') + pst.add_argument('option') + pst.add_argument('value') + pst.add_argument('extras', nargs='*', default=[]) + + pl = sub.add_parser('logging') + pls = pl.add_subparsers(dest='sub', required=True) + + psh = pls.add_parser('show') + psh.add_argument('module', nargs='?', default=None) + psh.add_argument('extras', nargs='*', default=[]) + + pset = pls.add_parser('set') + pset.add_argument('tail', nargs='+', metavar='ARG') + + return p + + +_CMD_PARSER: argparse.ArgumentParser | None = None + +_CMD_PARSE_EXC: tuple[type[BaseException], ...] = (SystemExit,) +if sys.version_info >= (3, 9): + _CMD_PARSE_EXC += (argparse.ArgumentError,) + + +def _cmd_parser() -> argparse.ArgumentParser: + global _CMD_PARSER + if _CMD_PARSER is None: + _CMD_PARSER = _build_cmd_parser() + return _CMD_PARSER + + +def _dispatch_ns(fd: int, ns: argparse.Namespace) -> bytes: + if ns.cmd == 'config': + sub = ns.sub + if sub == 'reload': + return cmd_config_reload(fd) + if sub == 'reset': + return cmd_config_reset(fd) + if sub == 'save': + return cmd_config_save(fd) + if sub == 'load': + return cmd_config_load(fd, ns.path) + if sub == 'clear': + return cmd_config_clear(fd, ns.target) + if sub == 'dump': + return cmd_config_dump(fd, ns.path) + if sub == 'get': + return cmd_config_get(fd, ns.section, ns.option) + if sub == 'set': + return cmd_config_set(fd, ns.section, ns.option, ns.value) + if ns.cmd == 'logging': + if ns.sub == 'show': + return cmd_logging_show(fd, ns.module) + if ns.sub == 'set': + tail = ns.tail + if len(tail) == 1: + return cmd_logging_set(fd, None, tail[0]) + return cmd_logging_set(fd, tail[0], tail[1]) + return _nul_out(['ERR', f"Unknown command '{ns.cmd}'"]) + + +def run_line(fd: int, argv: list[str]) -> bytes: + if not argv: + return b'' + pre = _legacy_argc_errors(argv) + if pre: + return pre + try: + ns = _cmd_parser().parse_args(argv) + except _CMD_PARSE_EXC: + return _nul_out(['ERR', f"Unknown command '{argv[0]}'"]) + if ns.cmd == 'logging' and ns.sub == 'set' and len(ns.tail) > 2: + return _nul_out( + [ + 'ERR', + f"Unexpected arguments for 'logging set' command; got {len(argv)}, expected 3 or 4", + ] + ) + return _dispatch_ns(fd, ns) + + +def run_interactive(fd: int) -> int: + sys.stdout.write('> ') + sys.stdout.flush() + for line in sys.stdin: + if line.strip().lower() == 'quit': + break + parts = shlex.split(line, comments=False) + if not parts: + sys.stdout.write('\n> ') + sys.stdout.flush() + continue + out = run_line(fd, parts) + sys.stdout.buffer.write(out) + sys.stdout.write('\n> ') + sys.stdout.flush() + return 0 + + +def _build_main_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog='x52ctl', add_help=True) + p.add_argument( + '-s', + '--socket', + metavar='PATH', + default=None, + help='path to the daemon framed IPC socket (default: compiled-in path)', + ) + p.add_argument( + '-i', + '--interactive', + action='store_true', + help='read commands from stdin until EOF or quit', + ) + p.add_argument( + 'args', + nargs='*', + help='config … or logging … (see subcommands)', + ) + return p + + +def main() -> int: + m = _build_main_parser() + a = m.parse_args() + + try: + fd = _open_ipc(a.socket) + except OSError as exc: + print(f'x52d_dial_ipc: {exc.strerror}', file=sys.stderr) + return 1 + + try: + if a.interactive: + if a.args: + print( + 'Running in interactive mode, ignoring extra arguments', + file=sys.stderr, + ) + return run_interactive(fd) + if not a.args: + m.print_usage(file=sys.stderr) + return 2 + out = run_line(fd, a.args) + sys.stdout.buffer.write(out) + finally: + try: + os.close(fd) + except OSError: + pass + return 0 diff --git a/daemon/x52d_gen_module.py b/daemon/x52d_gen_module.py index 87da710..63bd0ea 100755 --- a/daemon/x52d_gen_module.py +++ b/daemon/x52d_gen_module.py @@ -20,6 +20,8 @@ def main(): include_guard = os.path.basename(sys.argv[1]).replace('-', '_').replace('.', '_').upper() print(f"#ifndef {include_guard}", file=out_fd) print(f"#define {include_guard}\n", file=out_fd) + print("#include ", file=out_fd) + print("#include \n", file=out_fd) for mod in module_defs.Module: print(f"#define X52D_MOD_{mod.name} {mod.value}", file=out_fd) @@ -32,6 +34,35 @@ def main(): print(f"int lookup_level_by_name(const char *name);", file=out_fd) print(f"const char * lookup_level_by_id(int id);", file=out_fd) + print( + """ +/** True if @p mod is a valid LIPC logging module selector: @c X52D_MOD_GLOBAL or @c 0 … @c X52D_MOD_MAX - 1. */ +static inline bool x52d_module_wire_valid(uint16_t mod) +{ + if (mod == X52D_MOD_GLOBAL) { + return true; + } + return mod < X52D_MOD_MAX; +} + +/** + * True if @p wire_u is a valid log level id for LIPC @c LOGGING_SET (same numeric space as @c loglevel_map / @c lookup_level_by_id). + * The wire field is unsigned; values such as @c FATAL (@c 0) and @c NOTSET (negative) are carried as two's-complement in @c uint64_t. + */ +static inline bool x52d_log_level_wire_valid(uint64_t wire_u) +{ + int64_t wire = (int64_t)wire_u; + int v = (int)wire; + + if ((int64_t)v != wire) { + return false; + } + return lookup_level_by_id(v) != NULL; +} +""", + file=out_fd, + ) + print(f"\n#endif // !defined {include_guard}", file=out_fd) with open(sys.argv[2], 'w', encoding='utf-8') as out_fd: diff --git a/daemon/x52d_map_config.py b/daemon/x52d_map_config.py index a88340c..4c5b9c0 100755 --- a/daemon/x52d_map_config.py +++ b/daemon/x52d_map_config.py @@ -113,6 +113,9 @@ communication protocol may break.""" print(f"#ifndef {include_guard}", file=out_fd) print(f"#define {include_guard}", file=out_fd) print(file=out_fd) + print("#include ", file=out_fd) + print("#include ", file=out_fd) + print(file=out_fd) max_sec_val = max(self.sections.values()) + 1 max_opt_val_global = 0 @@ -131,19 +134,25 @@ communication protocol may break.""" print("extern const char * section_names[CFG_SECTION_MAX];", file=out_fd) print("extern const char * option_names[CFG_SECTION_MAX][CFG_SECTION_MAX_OPT_VAL];", file=out_fd) + print(file=out_fd) + print("/** True if (section_id, option_id) is a key from the build-time config registry.", file=out_fd) + print(" * Used by LIPC CONFIG_GET / CONFIG_SET to validate wire ids against config-defs.", file=out_fd) + print(" */", file=out_fd) + print("bool x52d_config_registry_pair_valid(uint16_t section_id, uint16_t option_id);", file=out_fd) print(f"#endif // !defined {include_guard}", file=out_fd) with open(output_source, 'w', encoding='utf-8') as out_fd: print("// Autogenerated config string table - DO NOT EDIT\n", file=out_fd) print(f'#include "{os.path.basename(output_header)}"', file=out_fd) + print("#include ", file=out_fd) print("const char * section_names[CFG_SECTION_MAX] = {", file=out_fd) for section, value in self.sections.items(): print(f' [{value}] = "{section.lower()}",', file=out_fd) print("};\n", file=out_fd) - print("const char * options_names[CFG_SECTION_MAX][CFG_SECTION_MAX_OPT_VAL] = {", file=out_fd) + print("const char * option_names[CFG_SECTION_MAX][CFG_SECTION_MAX_OPT_VAL] = {", file=out_fd) for section, value in self.sections.items(): print(f' [{value}] =', '{', file=out_fd) for option, value in self.options[section].items(): @@ -151,6 +160,17 @@ communication protocol may break.""" print(' },', file=out_fd) print("};\n", file=out_fd) + print("bool x52d_config_registry_pair_valid(uint16_t section_id, uint16_t option_id)", file=out_fd) + print("{", file=out_fd) + print(" if (section_id < 1u || section_id >= CFG_SECTION_MAX) {", file=out_fd) + print(" return false;", file=out_fd) + print(" }", file=out_fd) + print(" if (option_id < 1u || option_id >= CFG_SECTION_MAX_OPT_VAL) {", file=out_fd) + print(" return false;", file=out_fd) + print(" }", file=out_fd) + print(" return option_names[section_id][option_id] != NULL;", file=out_fd) + print("}\n", file=out_fd) + def generate_py_definitions(self, output_file): """Generate the Python definitions""" diff --git a/daemon/x52dcomm-internal.h b/daemon/x52dcomm-internal.h index a78d78c..e5d08ff 100644 --- a/daemon/x52dcomm-internal.h +++ b/daemon/x52dcomm-internal.h @@ -19,6 +19,7 @@ const char *x52d_command_sock_path(const char *sock_path); int x52d_setup_command_sock(const char *sock_path, struct sockaddr_un *remote); const char *x52d_notify_sock_path(const char *sock_path); int x52d_setup_notify_sock(const char *sock_path, struct sockaddr_un *remote); +const char *x52d_ipc_sock_path(const char *sock_path); int x52d_set_socket_nonblocking(int sock_fd); int x52d_listen_socket(struct sockaddr_un *local, int len, int sock_fd); void x52d_split_args(int *argc, char **argv, char *buffer, int buflen); diff --git a/docs/main.dox b/docs/main.dox index 9729476..e4f6cec 100644 --- a/docs/main.dox +++ b/docs/main.dox @@ -49,6 +49,7 @@ functions. - libx52/libx52io.h - libx52/libx52util.h - vkm/vkm.h +- libx52/x52d_ipc.h - libx52/x52dcomm.h */ diff --git a/include/libx52/libx52.h b/include/libx52/libx52.h index 75523fe..96f1f61 100644 --- a/include/libx52/libx52.h +++ b/include/libx52/libx52.h @@ -638,6 +638,26 @@ LIBX52_API int libx52_set_blink(libx52_device *x52, uint8_t state); * @{ */ +/** + * @brief Read USB vendor and product id of the connected device + * + * @param[out] vid Filled with USB idVendor when connected + * @param[out] pid Filled with USB idProduct when connected + * + * @returns \ref LIBX52_SUCCESS, or \ref LIBX52_ERROR_NO_DEVICE if not connected, + * or another \ref libx52_error_code on failure + */ +LIBX52_API int libx52_get_usb_ids(libx52_device *x52, uint16_t *vid, uint16_t *pid); + +/** + * @brief USB product string for the connected device + * + * The string is ASCII as returned by libusb. The pointer is valid until the + * device disconnects or @ref libx52_exit; it must not be freed. Returns an + * empty string when not connected or when the device has no product string. + */ +LIBX52_API const char *libx52_get_product_string(libx52_device *x52); + /** * @brief Update the X52 * diff --git a/include/libx52/x52dcomm.h b/include/libx52/x52dcomm.h index 787f1f7..1cc3339 100644 --- a/include/libx52/x52dcomm.h +++ b/include/libx52/x52dcomm.h @@ -15,12 +15,32 @@ * daemon communication library. These functions allow a client application to * communicate with a running X52 daemon, execute commands and retrieve data. * + * @par Primary API: framed LIPC (\c x52d.socket) + * Use @ref x52d_ipc_socket_path, @ref x52d_dial_ipc, and @ref x52d_ipc_call with + * opcodes and field semantics from @ref x52d_ipc.h. This is the **only** supported + * long-term path for daemon RPC and for **push** notifications on the same + * socket (frames with \c lipc_header.tid == \c 0), including **DEVICE_STATE** + * (USB connect/disconnect; see @ref proto_lipc_framed / @ref lipc_push_device_state + * in the daemon protocol documentation). + * + * @par Deprecated: legacy NUL sockets + * The helpers @ref x52d_dial_command, @ref x52d_format_command, @ref x52d_send_command, + * @ref x52d_dial_notify, and @ref x52d_recv_notification target the historical + * \c x52d.cmd and \c x52d.notify NUL-terminated protocols. They remain for + * migration only, are **deprecated**, and **will be removed** in a future release + * when those sockets are dropped. New code must use the framed IPC entry points + * above. + * * @author Nirenjan Krishnan (nirenjan@nirenjan.org) */ #ifndef X52DCOMM_H #define X52DCOMM_H #include +#include + +#include +#include #ifndef X52DCOMM_API # if defined(__GNUC__) && (__GNUC__ * 100 + __GNUC_MINOR__) >= 303 @@ -32,6 +52,21 @@ # endif #endif +/** + * Define @c X52DCOMM_NO_DEPRECATED_ATTR before including this header to compile + * without @c deprecated attributes on legacy entry points (for in-tree sources + * that still call them until migration completes). + */ +#if defined(X52DCOMM_NO_DEPRECATED_ATTR) +# define X52DCOMM_DEPRECATED +#elif defined(__GNUC__) || defined(__clang__) +# define X52DCOMM_DEPRECATED __attribute__((deprecated("use x52d_ipc_socket_path, x52d_dial_ipc, and x52d_ipc_call"))) +#elif defined(_MSC_VER) +# define X52DCOMM_DEPRECATED __declspec(deprecated("use x52d_ipc_socket_path, x52d_dial_ipc, and x52d_ipc_call")) +#else +# define X52DCOMM_DEPRECATED +#endif + #ifdef __cplusplus extern "C" { #endif @@ -44,94 +79,99 @@ extern "C" { * @{ */ +/** + * @brief Resolve the framed IPC UNIX socket path used by x52d. + * + * If @p sock_path is NULL, returns the default path compiled into the library + * (same layout as the running daemon’s @c RUNDIR socket). + * + * @param[in] sock_path Optional override path, or NULL for the default. + * @return Pointer to a NUL-terminated path string (must not be freed). + */ +X52DCOMM_API const char *x52d_ipc_socket_path(const char *sock_path); + +/** + * @brief Open a blocking connection to the daemon framed IPC socket. + * + * Use @ref x52d_ipc_call to issue requests. Close the descriptor with @c close(2) + * when finished. + * + * @param[in] sock_path Optional path override, or NULL for the default path from @ref x52d_ipc_socket_path. + * @returns Connected socket fd on success, or @c -1 with @c errno set. + */ +X52DCOMM_API int x52d_dial_ipc(const char *sock_path); + +/** + * @brief Perform one synchronous framed IPC RPC on a connected fd. + * + * Allocates a short-lived liblocalipc client, sends one request, blocks until the + * matching reply (dispatching unrelated @c tid==0 pushes while waiting), then returns. + * Unrelated notifications are accepted and ignored unless liblocalipc is extended + * later with explicit dispatch hooks. + * + * @param[in] fd Connected stream socket from @ref x52d_dial_ipc. + * @param[in] request_id Wire @c request opcode (e.g. @c X52D_IPC_CONFIG_DUMP). + * @param[in] index @c lipc_header.index (opcode-specific). + * @param[in] value @c lipc_header.value (opcode-specific). + * @param[in] payload Request payload, or NULL when @p payload_len is 0. + * @param[in] payload_len Request payload length in bytes. + * @param[out] reply_hdr Decoded reply header, or NULL if not needed. + * @param[out] reply_payload Buffer for reply payload, or NULL when @p reply_payload_cap is 0. + * @param[in] reply_payload_cap Capacity of @p reply_payload. + * @param[out] reply_len Stored reply payload length; may be NULL. + * + * @return \c LIPC_OK when a reply was received and captured; other \c lipc_status + * values on protocol or I/O failure (\c errno may apply for \c LIPC_IO_ERROR). + */ +X52DCOMM_API lipc_status x52d_ipc_call(int fd, uint16_t request_id, uint16_t index, uint64_t value, + const void *payload, size_t payload_len, + lipc_header *reply_hdr, void *reply_payload, size_t reply_payload_cap, size_t *reply_len); + +/** + * @brief Decode a DEVICE_STATE server push (@c X52D_IPC_PUSH_DEVICE_STATE, @c tid == 0). + * + * On success, @p connected is 1 when @c lipc_header.index indicates connected, else 0. + * @p name_utf8 and @p name_len describe the optional UTF-8 product substring in @p payload + * (not necessarily NUL-terminated; use @p name_len). Either name output may be NULL. + * + * @return 0 on success, -1 when @p hdr is not a well-formed DEVICE_STATE push. + */ +X52DCOMM_API int x52d_ipc_device_state_decode(const lipc_header *hdr, const void *payload, + size_t payload_len, int *connected, uint16_t *vid, uint16_t *pid, const char **name_utf8, + size_t *name_len); + /** * @brief Open a connection to the daemon command socket. * - * This method opens a socket connection to the daemon command socket. This - * socket allows the client to issue commands and retrieve data. The \p sock_path - * parameter may be NULL, in which case, it will use the default socket path. - * - * The client will need to use the returned descriptor to communicate with the - * daemon using \ref x52d_send_command. Once finished, the client may use the - * \c close(2) method to close the file descriptor. - * - * @param[in] sock_path Path to the daemon command socket. - * - * @returns Non-negative socket file descriptor on success. - * @returns -1 on failure, and set \c errno accordingly. - * - * @exception E2BIG returned if the passed socket path is too big + * @deprecated Legacy NUL-separated command socket; use @ref x52d_dial_ipc and + * @ref x52d_ipc_call instead. This entry point will be removed when + * legacy sockets are dropped. */ -X52DCOMM_API int x52d_dial_command(const char *sock_path); +X52DCOMM_API X52DCOMM_DEPRECATED int x52d_dial_command(const char *sock_path); /** * @brief Open a connection to the daemon notify socket. * - * This method opens a socket connection to the daemon notify socket. This - * socket allows the client to receive notifications from the daemon. Thej - * \p sock_path parameter may be NULL, in which case, it will use the default - * socket path. - * - * The client will need to use the returned descriptor to communicate with the - * daemon using \ref x52d_recv_notification. Once finished, the client may use - * the \c close(2) method to close the file descriptor. - * - * @param[in] sock_path Path to the daemon command socket. - * - * @returns Non-negative socket file descriptor on success. - * @returns -1 on failure, and set \c errno accordingly. - * - * @exception E2BIG returned if the passed socket path is too big + * @deprecated Legacy NUL notify socket (\c x52d.notify). Subscribe on the unified + * framed IPC socket (\ref x52d_dial_ipc) and handle \c tid==0 pushes + * (e.g. \c DEVICE_STATE; see @ref proto_lipc_framed). Removed in a future + * release with the legacy socket. */ -X52DCOMM_API int x52d_dial_notify(const char *sock_path); +X52DCOMM_API X52DCOMM_DEPRECATED int x52d_dial_notify(const char *sock_path); /** * @brief Format a series of command strings into a buffer * - * The client sends the command and parameters as a series of NUL terminated - * strings. This function concatenates the commands into a single buffer that - * can be passed to \ref x52d_send_command. - * - * \p buffer should be at least 1024 bytes long. - * - * @param[in] argc Number of arguments to fit in the buffer - * @param[in] argv Pointer to an array of arguments. - * @param[out] buffer Buffer to store the formatted command - * @param[in] buflen Length of the buffer - * - * @returns number of bytes in the formatted command - * @returns -1 on an error condition, and \c errno is set accordingly. + * @deprecated Legacy command encoding for @ref x52d_send_command; use @ref x52d_ipc_call. */ -X52DCOMM_API int x52d_format_command(int argc, const char **argv, char *buffer, size_t buflen); +X52DCOMM_API X52DCOMM_DEPRECATED int x52d_format_command(int argc, const char **argv, char *buffer, size_t buflen); /** * @brief Send a command to the daemon and retrieve the response. * - * The client sends the command and parameters as a series of NUL terminated - * strings, and retrieves the response in the same manner. Depending on the - * result, the return status is either a positive integer or -1, and \c errno - * is set accordingly. - * - * \p buffer should contain sufficient space to accomodate the returned - * response string. - * - * This is a blocking function and will not return until either a response is - * received from the server, or an exception condition occurs. - * - * @param[in] sock_fd Socket descriptor returned from - * \ref x52d_dial_command - * - * @param[inout] buffer Pointer to the string containing the command and - * parameters. This is also used to save the returned - * response. - * - * @param[in] bufin Length of the command in the input buffer - * @param[in] bufout Maximum length of the response - * - * @returns number of bytes returned from the server - * @returns -1 on an error condition, and \c errno is set accordingly. + * @deprecated Legacy NUL-separated command protocol; use @ref x52d_ipc_call. */ -X52DCOMM_API int x52d_send_command(int sock_fd, char *buffer, size_t bufin, size_t bufout); +X52DCOMM_API X52DCOMM_DEPRECATED int x52d_send_command(int sock_fd, char *buffer, size_t bufin, size_t bufout); /** * @brief Notification callback function type @@ -141,28 +181,13 @@ typedef int (* x52d_notify_callback_fn)(int argc, char **argv); /** * @brief Receive a notification from the daemon * - * This function blocks until it receives a notification from the daemon. Once - * it receives a notification successfully, it will call the callback function - * with the arguments as string pointers. It will return the return value of - * the callback function, if it was called. - * - * This is a blocking function and will not return until either a notification - * is received from the server, or an exception condition occurs. - * - * @param[in] sock_fd Socket descriptor returned from - * \ref x52d_dial_notify - * - * @param[in] callback Pointer to the callback function - * - * @returns return code of the callback function on success - * @returns -1 on an error condition, and \c errno is set accordingly. + * @deprecated Legacy NUL notify protocol; use framed IPC (\c tid==0) pushes on the + * same socket as @ref x52d_dial_ipc. */ -X52DCOMM_API int x52d_recv_notification(int sock_fd, x52d_notify_callback_fn callback); +X52DCOMM_API X52DCOMM_DEPRECATED int x52d_recv_notification(int sock_fd, x52d_notify_callback_fn callback); /** @} */ #ifdef __cplusplus } #endif -#endif // !defined X52DCOMM_H - - +#endif /* X52DCOMM_H */ diff --git a/include/meson.build b/include/meson.build index ecfe772..9d1559c 100644 --- a/include/meson.build +++ b/include/meson.build @@ -33,6 +33,7 @@ install_headers( 'libx52/libx52.h', 'libx52/libx52io.h', 'libx52/libx52util.h', + 'libx52/x52d_ipc.h', 'libx52/x52dcomm.h', subdir: 'libx52' ) diff --git a/install-doxygen-docs.sh b/install-doxygen-docs.sh index 4ffbdd4..3770127 100755 --- a/install-doxygen-docs.sh +++ b/install-doxygen-docs.sh @@ -6,7 +6,8 @@ set -e doc_html="$1" mandir="$2" -WANTED_PAGES="man1/x52cli.1 man1/x52bugreport.1" +# Installed manual pages from the Doxygen man tree (see GENERATE_MAN in Doxyfile.in). +WANTED_PAGES="man1/x52cli.1 man1/x52bugreport.1 man1/x52d.1 man1/x52d_protocol.1 man1/x52dcomm.1 man1/proto_lipc_framed.1 man1/x52ctl.1" if [ -d "$MESON_BUILD_ROOT/docs/html" ]; then mkdir -p "$MESON_INSTALL_DESTDIR_PREFIX/$doc_html" diff --git a/libx52/common.h b/libx52/common.h index 918f002..3a7b9c0 100644 --- a/libx52/common.h +++ b/libx52/common.h @@ -54,6 +54,9 @@ struct libx52_device { libusb_hotplug_callback_handle hotplug_handle; int handle_registered; + + /** USB product string (ASCII from libusb), valid while @c hdl is non-NULL */ + char usb_product[256]; }; /** Flag bits */ diff --git a/libx52/core.c b/libx52/core.c index cd61c06..8408f39 100644 --- a/libx52/core.c +++ b/libx52/core.c @@ -96,6 +96,38 @@ bool libx52_is_connected(libx52_device *dev) return false; } +int libx52_get_usb_ids(libx52_device *dev, uint16_t *vid, uint16_t *pid) +{ + struct libusb_device_descriptor desc; + libusb_device *udev; + + if (!dev || !vid || !pid) { + return LIBX52_ERROR_INVALID_PARAM; + } + if (!dev->hdl) { + *vid = 0; + *pid = 0; + return LIBX52_ERROR_NO_DEVICE; + } + udev = libusb_get_device(dev->hdl); + if (!udev || libusb_get_device_descriptor(udev, &desc) != 0) { + *vid = 0; + *pid = 0; + return LIBX52_ERROR_USB_FAILURE; + } + *vid = desc.idVendor; + *pid = desc.idProduct; + return LIBX52_SUCCESS; +} + +const char *libx52_get_product_string(libx52_device *dev) +{ + if (!dev || !dev->hdl) { + return ""; + } + return dev->usb_product; +} + int libx52_disconnect(libx52_device *dev) { if (!dev) { @@ -107,6 +139,7 @@ int libx52_disconnect(libx52_device *dev) dev->hdl = NULL; dev->flags = 0; dev->handle_registered = 0; + dev->usb_product[0] = '\0'; } return LIBX52_SUCCESS; @@ -147,6 +180,14 @@ int libx52_connect(libx52_device *dev) } dev->hdl = hdl; + dev->usb_product[0] = '\0'; + if (desc.iProduct != 0) { + int plen = libusb_get_string_descriptor_ascii(hdl, desc.iProduct, + (unsigned char *)dev->usb_product, (int)sizeof(dev->usb_product)); + if (plen < 0) { + dev->usb_product[0] = '\0'; + } + } if (libx52_device_is_x52pro(desc.idProduct)) { set_bit(&(dev->flags), X52_FLAG_IS_PRO); diff --git a/subprojects/localipc/include/localipc/lipc.h b/subprojects/localipc/include/localipc/lipc.h index 0c973e1..5ccfd85 100644 --- a/subprojects/localipc/include/localipc/lipc.h +++ b/subprojects/localipc/include/localipc/lipc.h @@ -449,6 +449,25 @@ LIPC_API lipc_status lipc_server_stop(lipc_server *server); */ LIPC_API lipc_status lipc_server_run(lipc_server *server); +/** + * Broadcast one server-initiated notify frame (\c tid == 0) to every connected client. + * + * Threading: safe to call from threads other than @ref lipc_server_run while the server + * is active. Snapshots client file descriptors under the server mutex; framed writes run + * without holding the mutex. + * + * @param request @c lipc_header.request for subscribers (match client notify route id). + * @param status Wire status (often @ref LIPC_OK for application pushes). + * @param index Method-specific 16-bit field. + * @param value Method-specific 64-bit field. + * @param payload Optional payload (NULL allowed when @p payload_len is 0). + * @return @ref LIPC_BAD_HEADER if @p server is NULL, @ref LIPC_BAD_LENGTH if @p payload_len + * is not representable as @c uint32_t, else @ref LIPC_OK (per-client write failures + * such as @ref LIPC_WOULD_BLOCK are ignored). + */ +LIPC_API lipc_status lipc_server_broadcast_notify(lipc_server *server, uint16_t request, + uint16_t status, uint16_t index, uint64_t value, const void *payload, size_t payload_len); + /** * Send one response frame echoing request @c tid and @c request from @p reply->request_hdr. * diff --git a/subprojects/localipc/lipc_dispatch.c b/subprojects/localipc/lipc_dispatch.c index 6fef88a..d31343f 100644 --- a/subprojects/localipc/lipc_dispatch.c +++ b/subprojects/localipc/lipc_dispatch.c @@ -378,6 +378,48 @@ static lipc_status lipc_server_drain_client_frames(lipc_server *server, size_t i } } +enum { LIPC_BROADCAST_MAX_FDS = 256 }; + +lipc_status lipc_server_broadcast_notify(lipc_server *server, uint16_t request, uint16_t status, + uint16_t index, uint64_t value, const void *payload, size_t payload_len) +{ + int fds[LIPC_BROADCAST_MAX_FDS]; + size_t nfds; + size_t i; + + if (!server) { + return LIPC_BAD_HEADER; + } + if (payload_len > UINT32_MAX) { + return LIPC_BAD_LENGTH; + } + + pthread_mutex_lock(&server->lock); + nfds = server->nclients; + if (nfds > sizeof fds / sizeof fds[0]) { + pthread_mutex_unlock(&server->lock); + return LIPC_BAD_LENGTH; + } + for (i = 0; i < nfds; i++) { + fds[i] = server->clients[i].fd; + } + pthread_mutex_unlock(&server->lock); + + lipc_header h = { + .request = request, + .status = status, + .index = index, + .tid = 0, + .length = (uint32_t)payload_len, + .value = value, + }; + + for (i = 0; i < nfds; i++) { + (void)lipc_frame_write(fds[i], &h, payload, payload_len); + } + return LIPC_OK; +} + lipc_status lipc_server_run(lipc_server *server) { int listen_fd;