/*** Copyright (C) 2016-2025 Denis Arnst (Sapd) This file is part of HeadsetControl. HeadsetControl is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. HeadsetControl is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with HeadsetControl. If not, see . ***/ #include "argument_parser.hpp" #include "capability_descriptors.hpp" #include "dev.hpp" #include "device.hpp" #include "device_registry.hpp" #include "devices/hid_device.hpp" #include "feature_handlers.hpp" #include "feature_utils.hpp" #include "headsetcontrol.hpp" #include "hid_utility.hpp" #include "output.hpp" #include "result_types.hpp" #include "utility.hpp" #include "version.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // ============================================================================ // Namespace imports // ============================================================================ using headsetcontrol::DeviceRegistry; using headsetcontrol::HIDDevice; using headsetcontrol::make_battery_result; using headsetcontrol::make_error; using headsetcontrol::make_success; // Forward declaration for C++ device registry initialization extern "C" void init_cpp_devices(); namespace { // NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) // Must be non-const: modified by signal handler for graceful shutdown volatile sig_atomic_t g_follow_running = false; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) // ============================================================================ // Output helpers // ============================================================================ template void print(std::format_string fmt, Args&&... args) { std::cout << std::format(fmt, std::forward(args)...); } template void println(std::format_string fmt, Args&&... args) { std::cout << std::format(fmt, std::forward(args)...) << '\n'; } inline void println() { std::cout << '\n'; } template void eprintln(std::format_string fmt, Args&&... args) { std::cerr << std::format(fmt, std::forward(args)...) << '\n'; } // Convert platform bitmask to string for README table output [[nodiscard]] constexpr const char* platformsToTableString(uint8_t platforms) { if (platforms == PLATFORM_ALL) return " All "; if (platforms == (PLATFORM_LINUX | PLATFORM_MACOS)) return " L/M "; if (platforms == (PLATFORM_LINUX | PLATFORM_WINDOWS)) return " L/W "; if (platforms == PLATFORM_LINUX) return " L "; return " ? "; } // ============================================================================ // Command-line options - Clean data structure // ============================================================================ struct Options { // Device selection uint16_t vendor_id = 0; uint16_t product_id = 0; // Mode flags bool show_help = false; bool show_help_all = false; bool show_version = false; bool print_udev_rules = false; bool print_capabilities = false; bool dev_mode = false; bool test_device = false; bool follow_mode = false; bool request_connected = false; unsigned follow_seconds = 2; // Output format OutputType output_format = OUTPUT_STANDARD; // Feature settings std::optional sidetone_level; std::optional notification_sound; std::optional lights_enabled; std::optional inactive_time; std::optional voice_prompts_enabled; std::optional rotate_to_mute_enabled; std::optional equalizer_preset; std::optional mic_mute_led_brightness; std::optional mic_volume; std::optional volume_limiter_enabled; std::optional bt_when_powered_on; std::optional bt_call_volume; std::optional noise_filter; // Info requests bool request_battery = false; bool request_chatmix = false; bool request_sidetone = false; // Complex settings std::optional equalizer; std::optional parametric_equalizer; // Helper [[nodiscard]] bool hasDeviceFilter() const { return vendor_id != 0 || product_id != 0; } [[nodiscard]] bool matchesDevice(uint16_t vid, uint16_t pid) const { return (vendor_id == 0 || vendor_id == vid) && (product_id == 0 || product_id == pid); } }; // ============================================================================ // Argument parser configuration - Declarative option definitions // ============================================================================ std::optional configureParser(cli::ArgumentParser& parser, Options& opts) { // Output format choices static const std::unordered_map output_formats = { { "JSON", OUTPUT_JSON }, { "YAML", OUTPUT_YAML }, { "ENV", OUTPUT_ENV }, { "STANDARD", OUTPUT_STANDARD }, { "SHORT", OUTPUT_SHORT } }; parser // === Device Selection === .custom('d', "device", cli::ArgRequirement::Required, [&opts](std::optional arg) -> std::optional { if (!arg) return cli::ParseError { "requires vendor:product", "device" }; auto ids = headsetcontrol::parse_two_ids(*arg); if (!ids) { return cli::ParseError { "format: vendorid:productid", "device" }; } opts.vendor_id = static_cast(ids->first); opts.product_id = static_cast(ids->second); return std::nullopt; }, "Select device by vendor:product ID") // === Help & Version === .flag('h', "help", opts.show_help, "Show help message") .long_flag("help-all", opts.show_help_all, "Show all options including advanced") .long_flag("version", opts.show_version, "Show version information") // === Feature Controls === .custom('s', "sidetone", cli::ArgRequirement::Optional, [&opts](std::optional arg) -> std::optional { if (!arg || arg->empty()) { opts.sidetone_level.reset(); opts.request_sidetone = true; return std::nullopt; } uint8_t level {}; if (auto error = cli::parseInteger(*arg, level, uint8_t(0), uint8_t(128), "sidetone")) { return error; } opts.sidetone_level = level; opts.request_sidetone = false; return std::nullopt; }, "Get current sidetone level, or set it to LEVEL", "LEVEL") .flag('b', "battery", opts.request_battery, "Check battery level") .toggle('l', "light", opts.lights_enabled, "Turn lights off (0) or on (1)") .toggle('v', "voice-prompt", opts.voice_prompts_enabled, "Turn voice prompts off (0) or on (1)") .value('i', "inactive-time", opts.inactive_time, uint8_t(0), uint8_t(90), "Set inactive time in minutes", "MINUTES") .flag('m', "chatmix", opts.request_chatmix, "Get chat-mix level") .value('n', "notificate", opts.notification_sound, uint8_t(0), uint8_t(255), "Play notification sound", "SOUNDID") .toggle('r', "rotate-to-mute", opts.rotate_to_mute_enabled, "Toggle rotate to mute") .value('p', "equalizer-preset", opts.equalizer_preset, uint8_t(0), uint8_t(255), "Set equalizer preset", "PRESET") .long_value("noise-filter", opts.noise_filter, uint8_t(0), uint8_t(2), "Set microphone noise filter level", "LEVEL") // === Equalizer (custom parsing) === .custom('e', "equalizer", cli::ArgRequirement::Required, [&opts](std::optional arg) -> std::optional { if (!arg) return cli::ParseError { "requires equalizer values", "equalizer" }; auto values = headsetcontrol::parse_float_data(*arg); if (values.empty()) { return cli::ParseError { "no band values specified", "equalizer" }; } opts.equalizer = EqualizerSettings(std::move(values)); return std::nullopt; }, "Set equalizer curve", "VALUES") // === Parametric Equalizer === .long_custom("parametric-equalizer", cli::ArgRequirement::Required, [&opts](std::optional arg) -> std::optional { if (!arg) return cli::ParseError { "requires band settings", "parametric-equalizer" }; auto peq = headsetcontrol::parse_parametric_equalizer_settings(*arg); // Note: if any band failed to parse, it won't be in the list // This allows partial success - valid bands are kept opts.parametric_equalizer = std::move(peq); return std::nullopt; }, "Set parametric EQ bands", "BANDS") // === Microphone === .long_value("microphone-mute-led-brightness", opts.mic_mute_led_brightness, uint8_t(0), uint8_t(3), "Set mic mute LED brightness", "LEVEL") .long_value("microphone-volume", opts.mic_volume, uint8_t(0), uint8_t(128), "Set microphone volume", "VOLUME") // === Volume === .long_toggle("volume-limiter", opts.volume_limiter_enabled, "Toggle volume limiter") // === Bluetooth === .long_toggle("bt-when-powered-on", opts.bt_when_powered_on, "Bluetooth on at power-on") .long_value("bt-call-volume", opts.bt_call_volume, uint8_t(0), uint8_t(255), "Set bluetooth call volume", "VOLUME") // === Output Format === .choice('o', "output", opts.output_format, output_formats, "Output format") .custom('c', "short-output", cli::ArgRequirement::None, [&opts](auto) -> std::optional { opts.output_format = OUTPUT_SHORT; return std::nullopt; }, "Short output format") // === Follow Mode === .optional_value('f', "follow", opts.follow_mode, opts.follow_seconds, 2u, 1u, 3600u, "Re-run commands periodically", "SECS") // === Advanced === .flag('u', "udev", opts.print_udev_rules, "Output udev rules") .long_flag("capabilities", opts.print_capabilities, "List device capabilities") .long_flag("caps", opts.print_capabilities, "") .long_flag("connected", opts.request_connected, "Check if device connected") .long_flag("dev", opts.dev_mode, "Development mode") // === Test Device === .long_custom("test-device", cli::ArgRequirement::Optional, [&opts](std::optional arg) -> std::optional { opts.test_device = true; if (arg && !arg->empty()) { long val = 0; auto [ptr, ec] = std::from_chars(arg->data(), arg->data() + arg->size(), val); if (ec == std::errc {} && ptr == arg->data() + arg->size() && val >= 0 && val <= 255) { headsetcontrol::setTestProfile(static_cast(val)); } } return std::nullopt; }, "Use test device", "PROFILE") // === Timeout === .long_custom("timeout", cli::ArgRequirement::Required, [](std::optional arg) -> std::optional { if (!arg) return cli::ParseError { "requires timeout value", "timeout" }; long val = 0; auto [ptr, ec] = std::from_chars(arg->data(), arg->data() + arg->size(), val); if (ec != std::errc {} || ptr != arg->data() + arg->size() || val < 0 || val > 100000) { return cli::ParseError { "invalid timeout (0-100000)", "timeout" }; } headsetcontrol::setDeviceTimeout(static_cast(val)); return std::nullopt; }, "Set timeout in ms", "MS") // === Readme Helper (exits immediately) === .long_custom("readme-helper", cli::ArgRequirement::None, [](auto) -> std::optional { init_cpp_devices(); // Print table (inline for simplicity) std::cout << "| Device | Platform |"; for (int j = 0; j < NUM_CAPABILITIES; j++) { std::cout << " " << capability_to_string(static_cast(j)) << " |"; } std::cout << "\n| --- | --- |"; for (int j = 0; j < NUM_CAPABILITIES; j++) { std::cout << " --- |"; } std::cout << '\n'; for (const auto& device_ptr : DeviceRegistry::instance().getAllDevices()) { auto* device = device_ptr.get(); std::cout << "| " << device->getDeviceName() << " |"; uint8_t platforms = device->getSupportedPlatforms(); std::cout << platformsToTableString(platforms) << "|"; // getCapabilities() may depend on the matched product ID, so ask // each variant: capabilities all of them report get an "x", ones // only some report get an "x*" (explained by a footnote below the // table in the README). int caps_any = 0; int caps_all = 0; bool first = true; for (uint16_t pid : device->getProductIds()) { device->setMatchedProductId(pid); const int caps = device->getCapabilities(); caps_any |= caps; caps_all = first ? caps : (caps_all & caps); first = false; } device->setMatchedProductId(0); if (first) { // device without product IDs caps_any = caps_all = device->getCapabilities(); } for (int j = 0; j < NUM_CAPABILITIES; j++) { if (caps_all & B(j)) { std::cout << " x |"; } else if (caps_any & B(j)) { std::cout << " x* |"; } else { std::cout << " |"; } } std::cout << '\n'; } std::exit(0); }, "Output README table"); return std::nullopt; } // ============================================================================ // RAII wrapper for HID connection // ============================================================================ class HIDConnection { public: HIDConnection() = default; ~HIDConnection() { close(); } HIDConnection(const HIDConnection&) = delete; HIDConnection& operator=(const HIDConnection&) = delete; HIDConnection(HIDConnection&& other) noexcept : handle_(other.handle_) , path_(std::move(other.path_)) { other.handle_ = nullptr; } HIDConnection& operator=(HIDConnection&& other) noexcept { if (this != &other) { close(); handle_ = other.handle_; path_ = std::move(other.path_); other.handle_ = nullptr; } return *this; } [[nodiscard]] bool isOpen() const { return handle_ != nullptr; } [[nodiscard]] hid_device* get() const { return handle_; } bool open(const std::string& new_path) { if (path_ == new_path && handle_) return true; close(); handle_ = hid_open_path(new_path.c_str()); if (handle_) { path_ = new_path; return true; } return false; } void close() { if (handle_) { hid_close(handle_); handle_ = nullptr; } path_.clear(); } private: hid_device* handle_ = nullptr; std::string path_; }; // ============================================================================ // Device discovery // ============================================================================ struct DiscoveredDevice { HIDDevice* device = nullptr; uint16_t product_id = 0; HIDConnection connection; std::vector feature_requests; std::wstring vendor_name; std::wstring product_name; [[nodiscard]] uint16_t vendorId() const { return device ? device->getVendorId() : 0; } [[nodiscard]] bool matchesFilter(const Options& opts) const { return device && opts.matchesDevice(device->getVendorId(), product_id); } [[nodiscard]] bool hasCapability(capabilities cap) const { return device && (device->getCapabilities() & B(cap)) != 0; } }; // RAII wrapper for hid_enumerate result class HIDEnumeration { public: explicit HIDEnumeration(uint16_t vid = 0, uint16_t pid = 0) : devices_(hid_enumerate(vid, pid)) { } ~HIDEnumeration() { if (devices_) hid_free_enumeration(devices_); } HIDEnumeration(const HIDEnumeration&) = delete; HIDEnumeration& operator=(const HIDEnumeration&) = delete; hid_device_info* get() const { return devices_; } private: hid_device_info* devices_; }; std::vector discoverDevices(const Options& opts) { std::vector devices; auto& registry = DeviceRegistry::instance(); if (opts.test_device) { if (auto* test_dev = registry.getDevice(VENDOR_TESTDEVICE, PRODUCT_TESTDEVICE)) { DiscoveredDevice dev; dev.device = test_dev; dev.product_id = PRODUCT_TESTDEVICE; dev.vendor_name = L"HeadsetControl"; dev.product_name = L"Test Device"; devices.push_back(std::move(dev)); } } HIDEnumeration enumeration(opts.vendor_id, opts.product_id); for (auto* cur = enumeration.get(); cur; cur = cur->next) { bool duplicate = std::any_of(devices.begin(), devices.end(), [&](const DiscoveredDevice& d) { return d.vendorId() == cur->vendor_id && d.product_id == cur->product_id; }); if (duplicate) continue; auto* device = registry.getDevice(cur->vendor_id, cur->product_id); if (!device) continue; DiscoveredDevice dev; dev.device = device; dev.product_id = cur->product_id; if (cur->manufacturer_string) dev.vendor_name = cur->manufacturer_string; if (cur->product_string) dev.product_name = cur->product_string; devices.push_back(std::move(dev)); } return devices; } // ============================================================================ // Feature handling // ============================================================================ hid_device* connectForCapability(HIDConnection& conn, const HIDDevice* device, uint16_t product_id, capabilities cap) { auto detail = device->getCapabilityDetail(cap); auto hid_path = headsetcontrol::get_hid_path(device->getVendorId(), product_id, detail.interface_id, detail.usagepage, detail.usageid); if (!hid_path) return nullptr; return conn.open(*hid_path) ? conn.get() : nullptr; } // Convert FeatureOutput to FeatureResult for output formatting FeatureResult convertToFeatureResult(const headsetcontrol::FeatureOutput& output) { FeatureResult result; result.status = FEATURE_SUCCESS; result.value = output.value; result.message = output.message; // Handle battery special case with extended info if (output.battery) { const auto& b = *output.battery; result.value = b.level_percent; result.status2 = static_cast(b.status); // Copy extended battery info if (b.voltage_mv.has_value()) result.battery_voltage_mv = b.voltage_mv; if (b.time_to_full_min.has_value()) result.battery_time_to_full_min = b.time_to_full_min; if (b.time_to_empty_min.has_value()) result.battery_time_to_empty_min = b.time_to_empty_min; } // Handle chatmix special case if (output.chatmix.has_value()) { result.value = output.chatmix->level; } // Handle sidetone special case if (output.sidetone) { result.value = output.sidetone->current_level; result.sidetone_device_level = output.sidetone->device_level; result.sidetone_level_name = output.sidetone->level_name; } return result; } // Handle a feature request via the handler registry FeatureResult handleFeature(DiscoveredDevice& dev, capabilities cap, const FeatureParam& param) { // Validate parameter if (auto error = headsetcontrol::validateFeatureParam(cap, param)) { return make_error(-1, *error); } // Check device support if (!dev.hasCapability(cap)) { const auto& desc = headsetcontrol::getCapabilityDescriptor(cap); return make_error(-1, std::format("This headset doesn't support {}", desc.name)); } bool is_test = (dev.product_id == PRODUCT_TESTDEVICE); hid_device* handle = nullptr; // Connect to device (unless test device) if (!is_test) { handle = connectForCapability(dev.connection, dev.device, dev.product_id, cap); if (!handle) { return make_error(-1, "Could not open device"); } } // Execute via handler registry (no more giant switch!) auto result = headsetcontrol::FeatureHandlerRegistry::instance().execute( cap, dev.device, handle, param); if (result.hasError()) { return make_error(-1, result.error().message); } return convertToFeatureResult(result.value()); } // ============================================================================ // Help output // ============================================================================ namespace help { // ANSI terminal formatting namespace ansi { constexpr std::string_view bold = "\033[1m"; constexpr std::string_view dim = "\033[2m"; constexpr std::string_view green = "\033[32m"; constexpr std::string_view reset = "\033[0m"; } // namespace ansi // Get value hint from capability descriptor (single source of truth) [[nodiscard]] inline std::string getValueHint(capabilities cap) { const auto& desc = headsetcontrol::getCapabilityDescriptor(cap); return std::string(desc.value_hint); } // Option definition for help display struct Option { char short_opt = '\0'; std::string_view long_opt; std::string arg; // Owns string data (for dynamic value hints from descriptors) std::string_view description; std::optional required_cap = std::nullopt; // Show only if device has this bool advanced_only = false; // Show only in --help-all }; // Section with its options struct Section { std::string_view title; std::vector