Skip to content

Commit ca98ed4

Browse files
authored
cli: let devices refine the product name via getMetadata() (#575)
For dongles that do not create sub-devices for the headset paired to them, the HID metadata is where the actual model is known. The CLI now calls getMetadata() once per device, the first time it opens a handle, and uses the product name it reports. The default getMetadata() returns the HID descriptor strings, so devices without extra knowledge are unaffected; the Jabra Link 390 reports the headset paired to it rather than the dongle. Also fixes how those strings are converted. refineProductName() originally widened the name with std::wstring(s.begin(), s.end()), which sign-extends any byte above 0x7F into a negative wchar_t and turns a multi-byte UTF-8 sequence into one wide character per byte - surviving on macOS only because wcstombs() masks it back, and producing mojibake on Windows. Decoding it properly is not enough by itself either, because the value is converted straight back with wstring_to_string(), which goes through wcstombs() and can only represent what the current locale encodes; nothing calls setlocale(), so in the default C locale every non-ASCII character became '?'. DeviceMetadata and the HID descriptor strings are defined to be UTF-8, so an explicit locale-independent pair - string_to_wstring() and wstring_to_utf8() - now handles them wherever they are converted, with surrogate pairs handled for the 16-bit wchar_t on Windows and malformed input replaced by U+FFFD rather than truncating the name. The pair is an exact inverse, covered by round-trip tests and verified under C, en_US.UTF-8 and de_DE.UTF-8. hid_error() keeps wstring_to_string(): those are platform strings in the local encoding, not UTF-8. This also fixes non-ASCII manufacturer and product strings read from the HID descriptor, which were already being mangled beforehand.
1 parent 456c972 commit ca98ed4

8 files changed

Lines changed: 285 additions & 13 deletions

File tree

‎cli/dev.cpp‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,13 @@ void print_devices(uint16_t vendorid, uint16_t productid)
8383
cur->vendor_id, cur->product_id, cur->path);
8484

8585
if (cur->serial_number) {
86-
std::cout << " Serial: " << headsetcontrol::wstring_to_string(cur->serial_number) << '\n';
86+
std::cout << " Serial: " << headsetcontrol::wstring_to_utf8(cur->serial_number) << '\n';
8787
}
8888
if (cur->manufacturer_string) {
89-
std::cout << " Manufacturer: " << headsetcontrol::wstring_to_string(cur->manufacturer_string) << '\n';
89+
std::cout << " Manufacturer: " << headsetcontrol::wstring_to_utf8(cur->manufacturer_string) << '\n';
9090
}
9191
if (cur->product_string) {
92-
std::cout << " Product: " << headsetcontrol::wstring_to_string(cur->product_string) << '\n';
92+
std::cout << " Product: " << headsetcontrol::wstring_to_utf8(cur->product_string) << '\n';
9393
}
9494

9595
std::cout << std::format(

‎cli/main.cpp‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
#include "hid_utility.hpp"
3030
#include "output.hpp"
3131
#include "result_types.hpp"
32+
#include "string_utils.hpp"
3233
#include "utility.hpp"
3334
#include "version.h"
3435

@@ -440,6 +441,7 @@ struct DiscoveredDevice {
440441
std::vector<FeatureRequest> feature_requests;
441442
std::wstring vendor_name;
442443
std::wstring product_name;
444+
bool metadata_queried = false;
443445

444446
[[nodiscard]] uint16_t vendorId() const
445447
{
@@ -522,6 +524,23 @@ std::vector<DiscoveredDevice> discoverDevices(const Options& opts)
522524
// Feature handling
523525
// ============================================================================
524526

527+
/**
528+
* @brief Let the device refine the product name once it is open.
529+
*
530+
* A device may know more than its USB strings, e.g. which headset is currently
531+
* paired to a generic dongle. The default getMetadata() returns the HID strings,
532+
* so devices without such knowledge are unaffected.
533+
*/
534+
static void refineProductName(DiscoveredDevice& dev, hid_device* handle)
535+
{
536+
if (dev.metadata_queried)
537+
return;
538+
dev.metadata_queried = true;
539+
540+
if (auto meta = dev.device->getMetadata(handle); meta && !meta->product.empty())
541+
dev.product_name = headsetcontrol::string_to_wstring(meta->product);
542+
}
543+
525544
hid_device* connectForCapability(HIDConnection& conn, const HIDDevice* device, uint16_t product_id, capabilities cap)
526545
{
527546
auto detail = device->getCapabilityDetail(cap);
@@ -593,6 +612,7 @@ FeatureResult handleFeature(DiscoveredDevice& dev, capabilities cap, const Featu
593612
if (!handle) {
594613
return make_error(-1, "Could not open device");
595614
}
615+
refineProductName(dev, handle);
596616
}
597617

598618
// Execute via handler registry (no more giant switch!)

‎cli/output/output.cpp‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,8 @@ void outputYaml(const OutputData& data)
269269
s.writeListItem("status", statusToString(dev.status));
270270
s.pushIndent(1); // Align subsequent keys with "status" after "- "
271271
s.write("device", dev.device_name);
272-
s.write("vendor", dev.vendor_name.empty() ? "" : headsetcontrol::wstring_to_string(dev.vendor_name.c_str()));
273-
s.write("product", dev.product_name.empty() ? "" : headsetcontrol::wstring_to_string(dev.product_name.c_str()));
272+
s.write("vendor", dev.vendor_name.empty() ? "" : headsetcontrol::wstring_to_utf8(dev.vendor_name));
273+
s.write("product", dev.product_name.empty() ? "" : headsetcontrol::wstring_to_utf8(dev.product_name));
274274
s.write("id_vendor", dev.vendor_id);
275275
s.write("id_product", dev.product_id);
276276

@@ -463,7 +463,7 @@ void outputStandard(const OutputData& data, bool print_capabilities)
463463

464464
for (const auto& dev : data.devices) {
465465
if (!dev.product_name.empty()) {
466-
s.println(" {} ({}) [{}:{}]", dev.device_name, headsetcontrol::wstring_to_string(dev.product_name.c_str()), dev.vendor_id, dev.product_id);
466+
s.println(" {} ({}) [{}:{}]", dev.device_name, headsetcontrol::wstring_to_utf8(dev.product_name), dev.vendor_id, dev.product_id);
467467
} else {
468468
s.println(" {} [{}:{}]", dev.device_name, dev.vendor_id, dev.product_id);
469469
}

‎cli/output/output_data.hpp‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,8 @@ struct DeviceData {
209209
s.beginObject("");
210210
s.write("status", statusToString(status));
211211
s.write("device", device_name);
212-
s.write("vendor", vendor_name.empty() ? "" : headsetcontrol::wstring_to_string(vendor_name.c_str()));
213-
s.write("product", product_name.empty() ? "" : headsetcontrol::wstring_to_string(product_name.c_str()));
212+
s.write("vendor", vendor_name.empty() ? "" : headsetcontrol::wstring_to_utf8(vendor_name));
213+
s.write("product", product_name.empty() ? "" : headsetcontrol::wstring_to_utf8(product_name));
214214
s.write("id_vendor", vendor_id);
215215
s.write("id_product", product_id);
216216

‎lib/devices/hid_device.hpp‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,15 +185,15 @@ class HIDDevice {
185185
wchar_t serial[128] = {};
186186

187187
if (hid_get_manufacturer_string(device_handle, manufacturer, 128) == 0) {
188-
meta.manufacturer = wstring_to_string(manufacturer);
188+
meta.manufacturer = wstring_to_utf8(manufacturer);
189189
}
190190

191191
if (hid_get_product_string(device_handle, product, 128) == 0) {
192-
meta.product = wstring_to_string(product);
192+
meta.product = wstring_to_utf8(product);
193193
}
194194

195195
if (hid_get_serial_number_string(device_handle, serial, 128) == 0 && serial[0] != 0) {
196-
meta.serial_number = wstring_to_string(serial);
196+
meta.serial_number = wstring_to_utf8(serial);
197197
}
198198

199199
return meta;

‎lib/headsetcontrol.cpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ namespace {
2828

2929
std::string hidStringOrEmpty(const wchar_t* value)
3030
{
31-
return value ? wstring_to_string(value) : std::string();
31+
return value ? wstring_to_utf8(value) : std::string();
3232
}
3333

3434
class LibraryState {

‎lib/string_utils.hpp‎

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
#pragma once
22

3+
#include <cstddef>
34
#include <cstdlib>
45
#include <string>
6+
#include <string_view>
7+
#include <type_traits>
58

69
namespace headsetcontrol {
710

@@ -60,4 +63,174 @@ inline std::string wstring_to_string(const wchar_t* wstr)
6063
#endif
6164
}
6265

66+
/**
67+
* @brief Convert UTF-8 string to wide string
68+
*
69+
* Inverse of wstring_to_string() for the UTF-8 strings carried in DeviceMetadata.
70+
* The decoding is written out rather than delegated to mbstowcs() so that it does
71+
* not depend on the process locale: a device-supplied name arrives as raw bytes
72+
* off the wire and never passes through the C library's conversion on the way in.
73+
*
74+
* Widening byte by byte instead would produce one wide character per byte, so a
75+
* name like "Muller" spelled with an umlaut would come back out mangled.
76+
*
77+
* Malformed input is replaced with U+FFFD rather than rejected, so one bad byte
78+
* in a name read from a device does not discard the rest of it.
79+
*
80+
* @param str UTF-8 string
81+
* @return Wide string: UTF-16 where wchar_t is 16 bits, UTF-32 where it is wider
82+
*/
83+
inline std::wstring string_to_wstring(std::string_view str)
84+
{
85+
constexpr char32_t REPLACEMENT = 0xFFFD;
86+
constexpr char32_t MAX_CODEPOINT = 0x10FFFF;
87+
88+
std::wstring result;
89+
result.reserve(str.size());
90+
91+
auto append = [&result](char32_t codepoint) {
92+
if constexpr (sizeof(wchar_t) >= 4) {
93+
result += static_cast<wchar_t>(codepoint);
94+
} else if (codepoint <= 0xFFFF) {
95+
result += static_cast<wchar_t>(codepoint);
96+
} else {
97+
// Split into a UTF-16 surrogate pair, as on Windows where wchar_t is 16 bits
98+
const char32_t offset = codepoint - 0x10000;
99+
result += static_cast<wchar_t>(0xD800 + (offset >> 10));
100+
result += static_cast<wchar_t>(0xDC00 + (offset & 0x3FF));
101+
}
102+
};
103+
104+
for (std::size_t i = 0; i < str.size();) {
105+
const auto lead = static_cast<unsigned char>(str[i]);
106+
107+
// Length of the sequence, and the smallest codepoint it may legally encode -
108+
// a larger sequence than a codepoint needs is an overlong encoding
109+
std::size_t continuations = 0;
110+
char32_t codepoint = 0;
111+
char32_t minimum = 0;
112+
if (lead < 0x80) {
113+
codepoint = lead;
114+
} else if ((lead & 0xE0) == 0xC0) {
115+
continuations = 1;
116+
codepoint = lead & 0x1FU;
117+
minimum = 0x80;
118+
} else if ((lead & 0xF0) == 0xE0) {
119+
continuations = 2;
120+
codepoint = lead & 0x0FU;
121+
minimum = 0x800;
122+
} else if ((lead & 0xF8) == 0xF0) {
123+
continuations = 3;
124+
codepoint = lead & 0x07U;
125+
minimum = 0x10000;
126+
} else {
127+
append(REPLACEMENT);
128+
++i;
129+
continue;
130+
}
131+
132+
if (i + continuations >= str.size()) {
133+
append(REPLACEMENT);
134+
++i;
135+
continue;
136+
}
137+
138+
bool valid = true;
139+
for (std::size_t k = 1; k <= continuations; ++k) {
140+
const auto continuation = static_cast<unsigned char>(str[i + k]);
141+
if ((continuation & 0xC0) != 0x80) {
142+
valid = false;
143+
break;
144+
}
145+
codepoint = (codepoint << 6) | (continuation & 0x3FU);
146+
}
147+
148+
// Surrogates are not valid on their own, and are not encodable in UTF-8
149+
const bool is_surrogate = codepoint >= 0xD800 && codepoint <= 0xDFFF;
150+
if (!valid || codepoint < minimum || codepoint > MAX_CODEPOINT || is_surrogate) {
151+
append(REPLACEMENT);
152+
++i;
153+
continue;
154+
}
155+
156+
append(codepoint);
157+
i += continuations + 1;
158+
}
159+
160+
return result;
161+
}
162+
163+
/**
164+
* @brief Convert wide string to UTF-8 string
165+
*
166+
* Counterpart to string_to_wstring(), and an exact inverse of it. Unlike
167+
* wstring_to_string() this does not consult the locale: it is for strings that are
168+
* defined to be UTF-8, such as the device names in DeviceMetadata, where going
169+
* through wcstombs() would replace anything outside the current locale's encoding
170+
* with '?' - which in the default C locale means every non-ASCII character.
171+
*
172+
* Where wchar_t is 16 bits a surrogate pair is recombined into the codepoint it
173+
* encodes; an unpaired surrogate is replaced with U+FFFD.
174+
*
175+
* @param str Wide string
176+
* @return UTF-8 string
177+
*/
178+
inline std::string wstring_to_utf8(std::wstring_view str)
179+
{
180+
constexpr char32_t REPLACEMENT = 0xFFFD;
181+
constexpr char32_t MAX_CODEPOINT = 0x10FFFF;
182+
183+
std::string result;
184+
result.reserve(str.size());
185+
186+
auto append = [&result](char32_t codepoint) {
187+
if (codepoint < 0x80) {
188+
result += static_cast<char>(codepoint);
189+
} else if (codepoint < 0x800) {
190+
result += static_cast<char>(0xC0 | (codepoint >> 6));
191+
result += static_cast<char>(0x80 | (codepoint & 0x3F));
192+
} else if (codepoint < 0x10000) {
193+
result += static_cast<char>(0xE0 | (codepoint >> 12));
194+
result += static_cast<char>(0x80 | ((codepoint >> 6) & 0x3F));
195+
result += static_cast<char>(0x80 | (codepoint & 0x3F));
196+
} else {
197+
result += static_cast<char>(0xF0 | (codepoint >> 18));
198+
result += static_cast<char>(0x80 | ((codepoint >> 12) & 0x3F));
199+
result += static_cast<char>(0x80 | ((codepoint >> 6) & 0x3F));
200+
result += static_cast<char>(0x80 | (codepoint & 0x3F));
201+
}
202+
};
203+
204+
for (std::size_t i = 0; i < str.size(); ++i) {
205+
// Mask rather than cast: char32_t is unsigned, but wchar_t is signed on
206+
// some platforms, and a plain conversion would sign-extend
207+
auto codepoint = static_cast<char32_t>(
208+
static_cast<std::make_unsigned_t<wchar_t>>(str[i]));
209+
210+
if (codepoint >= 0xD800 && codepoint <= 0xDBFF) {
211+
// High surrogate: needs the matching low surrogate to mean anything
212+
const bool has_low = i + 1 < str.size();
213+
const auto low = has_low
214+
? static_cast<char32_t>(static_cast<std::make_unsigned_t<wchar_t>>(str[i + 1]))
215+
: char32_t { 0 };
216+
if (has_low && low >= 0xDC00 && low <= 0xDFFF) {
217+
codepoint = 0x10000 + ((codepoint - 0xD800) << 10) + (low - 0xDC00);
218+
++i;
219+
} else {
220+
codepoint = REPLACEMENT;
221+
}
222+
} else if (codepoint >= 0xDC00 && codepoint <= 0xDFFF) {
223+
// Low surrogate without a high one before it
224+
codepoint = REPLACEMENT;
225+
}
226+
227+
if (codepoint > MAX_CODEPOINT) {
228+
codepoint = REPLACEMENT;
229+
}
230+
append(codepoint);
231+
}
232+
233+
return result;
234+
}
235+
63236
} // namespace headsetcontrol

0 commit comments

Comments
 (0)