From 2a3b8bbeaa3e2fca65ae0255d5a652f2654f8daa Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Sun, 19 Apr 2026 14:16:49 -0700 Subject: [PATCH] Refactor G522 RGB to match keyboard/mouse LED Control pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the ad-hoc HeadsetRGBHostMode / HeadsetRGBColor pair with three settings that mirror Solaar's existing RGB UX for keyboards and mice: HeadsetLEDControl — Device/Solaar dropdown (ChoicesValidator, same style as LEDControl / RGBControl). HeadsetLEDsPrimary — gtk color picker via HeteroValidator, one Static effect with a single COLOR field. Writes apply to all discovered zones, then re-apply per-zone overrides on top. HeadsetPerZoneLighting — Settings + ChoicesMapValidator (mirrors PerKeyLighting syntax, labeled "Per-zone" since the firmware/spec uses "zone"). Uses COLORSPLUS so "No change" inherits the Primary color. Adds lib/logitech_receiver/headset_rgb.py with two reusable helpers: discover_zones(device) — one-shot zone enumeration run at setting build time, briefly claiming Solaar host mode and restoring the prior state; cached on the device. write_zone_map(device, map) — shared write path that groups zones by final color, emits one SetRgbZonesSingleValue per unique color, then FrameEnd(0x01) to commit. Any future RGB headset presenting 0x0620 picks up these settings automatically — the new module is feature-keyed, not G522-specific. Drops the stale logivoice.py import block (missing blank line between stdlib and typing imports) that failed ruff's isort check in CI. --- lib/logitech_receiver/headset_rgb.py | 211 ++++++++++++ lib/logitech_receiver/logivoice.py | 1 + lib/logitech_receiver/settings_templates.py | 345 ++++++++++++-------- 3 files changed, 420 insertions(+), 137 deletions(-) create mode 100644 lib/logitech_receiver/headset_rgb.py diff --git a/lib/logitech_receiver/headset_rgb.py b/lib/logitech_receiver/headset_rgb.py new file mode 100644 index 00000000..ebac1097 --- /dev/null +++ b/lib/logitech_receiver/headset_rgb.py @@ -0,0 +1,211 @@ +## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/ +## +## This program 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 2 of the License, or +## (at your option) any later version. +## +## This program 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 this program; if not, write to the Free Software Foundation, Inc., +## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +"""Shared helpers for devices exposing Feature 0x0620 HEADSET_RGB_HOSTMODE. + +The G522 is currently the only Solaar-supported device advertising this +feature, but anything else presenting 0x0620 will pick the same code path +automatically. The module deliberately avoids G522-specific assumptions +so future RGB-capable headsets can reuse it. + +Two entry points the settings templates rely on: + +- `discover_zones(device)` — one-shot zone enumeration run at setting + build time. Briefly claims Solaar host control so GetRGBZoneInfo + returns a non-empty zone list, then restores the previous host-mode + state. Result is cached on the device. +- `write_zone_map(device, zone_color_map)` — the shared write path used + by both the "LEDs Primary" and "Per-zone Lighting" settings. Groups + zones by final RGB color and emits one SetRgbZonesSingleValue per + unique color, then a single FrameEnd to commit. +""" + +from __future__ import annotations + +import logging + +from typing import Iterable + +from .hidpp20_constants import SupportedFeature + +logger = logging.getLogger(__name__) + +# Function IDs on Feature 0x0620 we actually use. +FN_GET_RGB_ZONE_INFO = 0x10 +FN_SET_RGB_ZONES_SINGLE = 0x50 +FN_FRAME_END = 0x60 +FN_GET_HOST_MODE_STATE = 0x70 +FN_SET_HOST_MODE_STATE = 0x80 + +# Frame type sent with FrameEnd. 0x01 = transient commit (re-applies on the +# next refresh). 0x02 would be persistent, but G522 firmware rejects it +# with LOGITECH_INTERNAL (0x05) unless an onboard profile precondition we +# haven't mapped yet is satisfied. +FRAME_TYPE_TRANSIENT = 0x01 + +_HOST_MODE_SOLAAR = 1 +_HOST_MODE_DEVICE = 0 + + +def _device_cache_attr() -> str: + return "_headset_rgb_zone_ids" + + +def _read_host_mode(device) -> int | None: + """Read the current host-mode state byte, or None on any failure.""" + try: + resp = device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_GET_HOST_MODE_STATE) + except Exception as e: + logger.warning("headset_rgb: GetHostModeState raised %s", e) + return None + if not resp or len(resp) < 1: + return None + return resp[0] + + +def _set_host_mode(device, value: int) -> bool: + try: + device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_SET_HOST_MODE_STATE, bytes([value & 0xFF])) + except Exception as e: + logger.warning("headset_rgb: SetHostModeState(%d) raised %s", value, e) + return False + return True + + +def _parse_zone_info(resp: bytes) -> list[int]: + """Parse a GetRGBZoneInfo response into a zone-id list. + + Two formats observed: "tight" ([count, zone_ids...]) on G522, and + the canonical protocol-doc layout (3-byte gap + 1-byte reserved + before zone IDs). Both are tried; whichever yields exactly `count` + IDs wins. Zone id 0 isn't filtered — some devices may use it. + """ + if not resp or len(resp) < 1: + return [] + zone_count = resp[0] + tight = list(resp[1 : 1 + zone_count]) if 1 <= zone_count <= len(resp) - 1 else [] + if tight and len(tight) == zone_count: + return tight + gap = list(resp[5 : 5 + zone_count]) if len(resp) >= 5 + zone_count else [] + if gap and len(gap) == zone_count: + return gap + return [] + + +def discover_zones(device) -> list[int] | None: + """Return the list of RGB zone IDs on `device`, or None on failure. + + Caches the result on `device._headset_rgb_zone_ids` so subsequent + callers don't repeat the round-trip. Briefly claims Solaar host mode + if needed — GetRGBZoneInfo has been observed to return count=0 when + the device is still under firmware control — and restores the prior + state afterward so user-configured onboard effects resume. + """ + cached = getattr(device, _device_cache_attr(), None) + if cached: + return cached + if not getattr(device, "online", False): + return None + + prior_mode = _read_host_mode(device) + claimed = False + if prior_mode != _HOST_MODE_SOLAAR: + if not _set_host_mode(device, _HOST_MODE_SOLAAR): + return None + claimed = True + + try: + try: + resp = device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_GET_RGB_ZONE_INFO) + except Exception as e: + logger.warning("headset_rgb: GetRGBZoneInfo raised %s", e) + return None + zones = _parse_zone_info(bytes(resp) if resp else b"") + if not zones: + logger.warning( + "headset_rgb: GetRGBZoneInfo returned no zones (raw=%s)", + resp.hex() if resp else resp, + ) + return None + logger.info("headset_rgb: discovered %d zone(s) %s", len(zones), [f"0x{z:02X}" for z in zones]) + setattr(device, _device_cache_attr(), zones) + return zones + finally: + if claimed and prior_mode is not None: + _set_host_mode(device, prior_mode) + + +def _split_rgb(color_int: int) -> tuple[int, int, int]: + return (color_int >> 16) & 0xFF, (color_int >> 8) & 0xFF, color_int & 0xFF + + +def write_zone_map(device, zone_color_map: dict) -> bool: + """Apply a zone->RGB mapping to the device. + + `zone_color_map` maps zone id (int) to 24-bit RGB color (int, + `(r<<16)|(g<<8)|b`). Claims host mode, groups zones by color, + emits one SetRgbZonesSingleValue per unique color, then a single + FrameEnd. Returns True on success, False on any transport error. + """ + if not zone_color_map: + return False + if not getattr(device, "online", False): + logger.info("headset_rgb: device offline, skipping write") + return False + + # Group zones by color for batched writes. + groups: dict[int, list[int]] = {} + for zone, color in zone_color_map.items(): + groups.setdefault(int(color), []).append(int(zone)) + + try: + _set_host_mode(device, _HOST_MODE_SOLAAR) + for color_int, zones in groups.items(): + r, g, b = _split_rgb(color_int) + # SetRgbZonesSingleValue: [R, G, B, count, zone_ids...] + payload = bytes([r, g, b, len(zones)]) + bytes(zones) + device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_SET_RGB_ZONES_SINGLE, payload) + logger.info( + "headset_rgb: set (%02X,%02X,%02X) on %d zone(s) %s", + r, + g, + b, + len(zones), + [f"0x{z:02X}" for z in zones], + ) + # FrameEnd commits the pending per-zone updates. Transient commit + # only — persistent (0x02) requires onboard-profile preconditions + # that aren't mapped yet. + device.feature_request( + SupportedFeature.HEADSET_RGB_HOSTMODE, + FN_FRAME_END, + bytes([FRAME_TYPE_TRANSIENT, 0x00, 0x00, 0x00]), + ) + except Exception as e: + logger.warning("headset_rgb: write_zone_map failed: %s", e) + return False + return True + + +def zone_named_ints(zones: Iterable[int]): + """Build a list of NamedInt keys suitable for a ChoicesMap setting. + + Factored out so settings code can import without pulling common.NamedInt + at module-load time if preferred. + """ + from . import common + + return [common.NamedInt(int(z), f"Zone {int(z)}") for z in zones] diff --git a/lib/logitech_receiver/logivoice.py b/lib/logitech_receiver/logivoice.py index e47d2b36..cea7f5ec 100644 --- a/lib/logitech_receiver/logivoice.py +++ b/lib/logitech_receiver/logivoice.py @@ -40,6 +40,7 @@ from __future__ import annotations import logging import struct + from typing import Iterable from .hidpp20_constants import SupportedFeature diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index aaf88393..0e925f25 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -33,6 +33,7 @@ from . import descriptors from . import desktop_notifications from . import diversion from . import exceptions +from . import headset_rgb from . import hidpp20 from . import hidpp20_constants from . import logivoice @@ -1979,172 +1980,241 @@ class HeadsetAdvancedEQ(settings.RangeFieldSetting): return None -class HeadsetRGBHostMode(settings.Setting): - """Toggle host control of headset RGB lighting. +_NO_CHANGE_COLOR = int(special_keys.COLORSPLUS["No change"]) - When enabled, solaar drives the LEDs via HeadsetRGBColor. When disabled, - firmware-driven onboard/signature effects resume. + +def _headset_setting_by_name(device, name): + for s in getattr(device, "settings", None) or []: + if getattr(s, "name", None) == name: + return s + return None + + +def _headset_primary_color(device, default=0xFFFFFF): + """Resolve the currently-saved Primary color, or `default` if absent.""" + s = _headset_setting_by_name(device, HeadsetLEDsPrimary.name) + if s is None: + return default + value = getattr(s, "_value", None) + color = getattr(value, "color", None) if value is not None else None + return int(color) if color is not None else default + + +def _headset_per_zone_overrides(device): + """Return `{zone_id: color_int}` for zones with explicit (non-'No change') + colors set via the Per-zone Lighting setting, or `None` if the setting + isn't built/present.""" + s = _headset_setting_by_name(device, HeadsetPerZoneLighting.name) + if s is None: + return None + value = getattr(s, "_value", None) + if not isinstance(value, dict): + return None + overrides = {} + for zone, color in value.items(): + try: + color_int = int(color) + except (TypeError, ValueError): + continue + if color_int != _NO_CHANGE_COLOR: + overrides[int(zone)] = color_int + return overrides + + +class _HeadsetStaticEffectOption: + """Minimal stand-in for `hidpp20.LEDEffectInfo`. + + `HeteroValidator` only inspects `.ID` and `.index` on its `options` + list; we don't need the full device-query machinery here because the + headset wire protocol is handled by `headset_rgb.write_zone_map`. """ - name = "headset-rgb-hostmode" - label = _("Headset RGB Host Control") - description = _("Enable software control of headset RGB lighting.") + ID = 0x01 # matches hidpp20.LEDEffects[0x01] = Static + index = 0x01 + + +class HeadsetLEDControl(settings.Setting): + """Switch headset LED control between device and Solaar. + + Mirrors the `LEDControl` / `RGBControl` pattern used for keyboards and + mice. When set to Solaar, the `LEDs Primary` and `Per-zone Lighting` + settings drive the LEDs; when set to Device, firmware-driven onboard + and signature effects resume. + """ + + name = "headset_led_control" + label = _("LED Control") + description = _("Switch control of LED zones between device and Solaar") feature = _F.HEADSET_RGB_HOSTMODE rw_options = {"read_fnid": 0x70, "write_fnid": 0x80} - validator_class = settings_validator.BooleanValidator + choices_universe = common.NamedInts(Device=0, Solaar=1) + validator_class = settings_validator.ChoicesValidator + validator_options = {"choices": choices_universe} -class HeadsetRGBColor(settings.Setting): - """Pick a color from the shared `special_keys.COLORS` palette and apply it - to all headset RGB zones via HeadsetRGBHostmode (0x0620). +class HeadsetLEDsPrimary(settings.Setting): + """Primary headset LED color, rendered as a GTK color picker. - On write: enables host mode, queries zone IDs via GetRGBZoneInfo, sets - them all to the chosen RGB color via SetRgbZonesSingleValue, commits via - FrameEnd. Picking `black` effectively turns the LEDs off; fully disabling - host control is done through the separate headset-rgb-hostmode toggle. + Mirrors the `LEDZoneSetting` / `RGBEffectSetting` shape: a + `HeteroValidator` with a single "Static" effect whose only visible + field is the color. Write applies the chosen color across all zones + discovered at build time, then re-applies any per-zone overrides on + top so they aren't clobbered. + + Read support is deliberately disabled — the feature exposes no "get + current color" function, so we rely on the persister. """ - name = "headset-rgb-color" - label = _("Headset RGB Color") - description = _("Set headset LED color (all zones, from the shared color palette).") + name = "headset_leds_primary" + label = _("LEDs") + " " + _("Primary") + description = _( + "Set the primary color applied to every headset LED zone.\n" "LED Control needs to be set to Solaar to be effective." + ) feature = _F.HEADSET_RGB_HOSTMODE - choices_universe = special_keys.COLORS - validator_class = settings_validator.ChoicesValidator persist = True - # Write-only: we don't read back the current color (0x0620 doesn't expose it). rw_options = {"read_fnid": None, "write_fnid": None} + # HeteroKeyControl renders exactly these fields; ID is hidden + # (`label=None`) but kept so setup_visibles can key off it. + color_field = {"name": hidpp20.LEDParam.color, "kind": settings.Kind.COLOR, "label": _("Color")} + possible_fields = [ + { + "name": "ID", + "kind": settings.Kind.CHOICE, + "label": None, + "choices": [common.NamedInt(0x01, _("Static"))], + }, + color_field, + ] + # HeteroKeyControl.setup_visibles looks up fields_map[effect_id][1] to + # decide which fields to show — we only expose the color. + fields_map = {0x01: [common.NamedInt(0x01, _("Static")), {hidpp20.LEDParam.color: 0}]} + @classmethod def build(cls, device): + zones = headset_rgb.discover_zones(device) + if not zones: + return None rw = settings.FeatureRW(cls.feature) - validator = settings_validator.ChoicesValidator(choices=cls.choices_universe) + validator = settings_validator.HeteroValidator( + data_class=hidpp20.LEDEffectSetting, + options=[_HeadsetStaticEffectOption()], + readable=False, + ) return cls(device, rw, validator) def read(self, cached=True): - # Write-only; return persisted/cached value if we have one. - if cached and getattr(self, "_value", None) is not None: + # Feature 0x0620 doesn't expose a "current primary color" read — + # pull from the persister via _pre_read, fall back to white so + # the picker opens on a sane starting color. + self._pre_read(cached) + if self._value is not None: return self._value - return None + self._value = hidpp20.LEDEffectSetting(ID=common.NamedInt(0x01, _("Static")), color=0xFFFFFF) + return self._value def write(self, value, save=True): - if value is None: + color = getattr(value, "color", None) + if color is None: return None - try: - color_int = int(value) - except (TypeError, ValueError): - return None - if color_int not in special_keys.COLORS: - return None - name = str(special_keys.COLORS[color_int]) - r = (color_int >> 16) & 0xFF - g = (color_int >> 8) & 0xFF - b = color_int & 0xFF device = self._device if not device.online: - logger.info("HeadsetRGBColor: device offline, skipping write of %s", name) return None - try: - # Order per protocol doc: claim host mode FIRST, then enumerate zones, - # then set colors, then commit. GetRGBZoneInfo returns zero counts if - # we query before claiming control. - resp = device.feature_request(_F.HEADSET_RGB_HOSTMODE, 0x80, b"\x01") - logger.info("HeadsetRGBColor: SetHostModeState(1) resp=%s", resp.hex() if resp else resp) - zone_ids = self._zone_ids(device) - if not zone_ids: - logger.warning("HeadsetRGBColor: no zones available; cannot set color %s", name) + zones = headset_rgb.discover_zones(device) + if not zones: + return None + primary = int(color) + zone_map = {int(z): primary for z in zones} + # Re-apply any non-"No change" per-zone overrides on top of the + # fresh Primary baseline so the user's explicit zone choices stick + # when they change the bulk color. + overrides = _headset_per_zone_overrides(device) or {} + zone_map.update(overrides) + if headset_rgb.write_zone_map(device, zone_map): + self.update(value, save) + return value + return None + + +class HeadsetPerZoneLighting(settings.Settings): + """Per-zone LED color overrides. + + Mirrors `PerKeyLighting`'s `Settings` + `ChoicesMapValidator` style — + one dropdown per discovered zone with the `COLORSPLUS` palette. The + "No change" entry means "inherit the current `LEDs Primary` color", + so users can paint individual zones without losing the base setup. + """ + + name = "headset_per_zone_lighting" + label = _("Per-zone Lighting") + description = _( + "Override individual zone colors. 'No change' inherits the LEDs Primary color.\n" + "LED Control needs to be set to Solaar to be effective." + ) + feature = _F.HEADSET_RGB_HOSTMODE + persist = True + choices_universe = special_keys.COLORSPLUS + + class rw_class(settings.FeatureRWMap): + pass + + class validator_class(settings_validator.ChoicesMapValidator): + @classmethod + def build(cls, setting_class, device): + zones = headset_rgb.discover_zones(device) + if not zones: return None - logger.info( - "HeadsetRGBColor: setting color %s RGB=(%02X,%02X,%02X) on %d zone(s): %s", - name, - r, - g, - b, - len(zone_ids), - [f"0x{z:02X}" for z in zone_ids], - ) - # SetRgbZonesSingleValue: [R, G, B, count, zone_ids...] - payload = bytes([r, g, b, len(zone_ids)]) + bytes(zone_ids) - resp = device.feature_request(_F.HEADSET_RGB_HOSTMODE, 0x50, payload) - logger.info( - "HeadsetRGBColor: SetRgbZonesSingleValue payload=%s resp=%s", - payload.hex(), - resp.hex() if resp else resp, - ) - # FrameEnd: commit the frame. - # Byte 0 is frame_type: 0x01 = transient commit, 0x02 = persistent. - # G522 firmware rejects 0x02 with LOGITECH_INTERNAL (0x05) — the - # persistent commit path may require additional state (e.g. onboard - # profile activation) that isn't mapped yet. Stick with 0x01 so the - # LEDs refresh visually; revisit persistence once the preconditions - # are known. - resp = device.feature_request(_F.HEADSET_RGB_HOSTMODE, 0x60, bytes([0x01, 0x00, 0x00, 0x00])) - frame_type = 0x01 - logger.info( - "HeadsetRGBColor: FrameEnd frame_type=0x%02X resp=%s", - frame_type, - resp.hex() if resp else resp, - ) - except Exception as e: - logger.warning("HeadsetRGBColor write failed for %s: %s", name, e) + choices_map = { + common.NamedInt(int(z), _("Zone") + " " + str(int(z))): setting_class.choices_universe for z in zones + } + return cls(choices_map) if choices_map else None + + def read(self, cached=True): + self._pre_read(cached) + if cached and self._value is not None: + return self._value + # Device doesn't expose current per-zone state; default every + # zone to "No change" so the primary color shows through. + reply_map = {int(key): _NO_CHANGE_COLOR for key in self._validator.choices} + self._value = reply_map + return reply_map + + def _resolve_zone_map(self, map_, primary): + """Substitute 'No change' entries with the primary color.""" + resolved = {} + for key, value in map_.items(): + try: + v = int(value) + except (TypeError, ValueError): + continue + resolved[int(key)] = primary if v == _NO_CHANGE_COLOR else v + return resolved + + def write(self, map_, save=True): + device = self._device + if not device.online: return None - self.update(value, save) - return value + self.update(map_, save) + primary = _headset_primary_color(device) + zone_map = self._resolve_zone_map(map_, primary) + if not zone_map: + return None + if headset_rgb.write_zone_map(device, zone_map): + return map_ + return None - @staticmethod - def _zone_ids(device): - """Query GetRGBZoneInfo (function 1) and return list of zone IDs. - - Caller must have enabled host mode first — querying before SetHostModeState - has been observed to return all zeros on the G522. - """ - cached = getattr(device, "_headset_rgb_zone_ids", None) - if cached: - return cached + def write_key_value(self, key, value, save=True): + result = super().write_key_value(int(key), value, save) + device = self._device + if not device.online: + return result try: - resp = device.feature_request(_F.HEADSET_RGB_HOSTMODE, 0x10) - except Exception as e: - logger.warning("HeadsetRGBColor: GetRGBZoneInfo raised %s", e) - resp = None - if not resp or len(resp) < 1: - logger.warning( - "HeadsetRGBColor: GetRGBZoneInfo returned %s; NOT caching so we retry next write", - resp, - ) - return [] - zone_count = resp[0] - # Tight format observed on G522: [count, zone_ids...] with no reserved gap. - # The protocol doc shows 3-byte + 1-byte reserved gaps before zone IDs, but - # the G522 sub-device packs them immediately after the count. - # Don't filter zone_id==0 — on some devices 0 is a valid zone ID. We trust - # the device to report sane zone IDs and let the device reject nonsense. - tight = list(resp[1 : 1 + zone_count]) if 1 <= zone_count <= len(resp) - 1 else [] - if tight and len(tight) == zone_count: - if logger.isEnabledFor(logging.DEBUG): - logger.debug( - "HeadsetRGBColor: discovered %d zone(s) %s (tight format)", - len(tight), - [f"0x{z:02X}" for z in tight], - ) - device._headset_rgb_zone_ids = tight - return tight - # Try the doc's format (with reserved gap) as fallback - gap = list(resp[5 : 5 + zone_count]) if len(resp) >= 5 + zone_count else [] - if gap and len(gap) == zone_count: - if logger.isEnabledFor(logging.DEBUG): - logger.debug( - "HeadsetRGBColor: discovered %d zone(s) %s (doc format)", - len(gap), - [f"0x{z:02X}" for z in gap], - ) - device._headset_rgb_zone_ids = gap - return gap - # Neither format parsed cleanly; don't cache so next write retries. - logger.warning( - "HeadsetRGBColor: GetRGBZoneInfo ambiguous count=%d resp=%s; not caching", - zone_count, - resp.hex(), - ) - return [] + v = int(value) + except (TypeError, ValueError): + return result + effective = _headset_primary_color(device) if v == _NO_CHANGE_COLOR else v + headset_rgb.write_zone_map(device, {int(key): int(effective)}) + return result # ---------------------------------------------------------------------------- @@ -2822,8 +2892,9 @@ SETTINGS: list[settings.Setting] = [ HeadsetAutoSleep, HeadsetOnboardEQ, HeadsetAdvancedEQ, - HeadsetRGBHostMode, - HeadsetRGBColor, + HeadsetLEDControl, + HeadsetLEDsPrimary, + HeadsetPerZoneLighting, *_LOGIVOICE_SETTINGS, ]