diff --git a/docs/ENE_PROTOCOL.md b/docs/ENE_PROTOCOL.md new file mode 100644 index 0000000..ce693f0 --- /dev/null +++ b/docs/ENE_PROTOCOL.md @@ -0,0 +1,233 @@ +# ENE K5130 keyboard lighting protocol — Acer Predator Helios Neo 16S AI (PHN16S-71) + +Reverse-engineered notes for the LED controller that actually drives the +keyboard backlight on this model. Everything below was verified on real +hardware against a fully dark baseline. + +> **AI assistance.** This protocol was reverse-engineered in a session assisted +> by an AI agent (Claude). The agent proposed the experiments, wrote the probing +> tools and the code; a human operator ran them on the affected machine and +> reported what the keyboard actually did. **Every behavioural claim here rests +> on a human observing the hardware, not on model inference.** Several early +> conclusions were wrong and were corrected by that operator; the notes below +> reflect the corrected state. + +--- + +## 1. Why this exists + +The ACPI-WMI lighting path on this model is **partially implemented in +firmware**. Within a single `SET_GAMING_KB_BACKLIGHT` call: + +| Field | Result | +|---|---| +| brightness | **applied** | +| RGB | **discarded** | +| effect mode | **discarded** | + +And the per-zone call (`SET_GAMING_RGB_KB`) stores the colours, returns them +faithfully on read-back, and never applies them to the LEDs. + +That combination is what makes the bug so quiet: every layer above reports +success. **On this model a sysfs read-back is not evidence that anything +happened.** Confirmed by writing red/green/blue/white at full brightness with +all competing writers stopped: read-back exact, keyboard unchanged. + +## 2. Transport + +| Item | Value | +|---|---| +| Chip | ENE K5130, HID id `0018:0CF2:5130` | +| Bus | I²C-1, address `0x50`, 400 kHz, GpioInt with wake | +| Protocol | standard HID-over-I2C, **feature reports only** (no Input, no Output) | +| Access | `HIDIOCSFEATURE` on the ENE's hidraw node | +| Readable | only `0xA1`. `0xA2`/`0xA3`/`0xA4` are **write-only** — a GET returns an echo of the command, not data | + +**Resolve the node by HID identity, never by hidraw number.** The numbering is +not stable here: the ENE's reset times out during probe so it enumerates last, +and the touchpad can rebind and reclaim `hidraw0`. + +## 3. Devices + +`0xA1` (4 bytes, read-only) returns `03 65 21 83` — a device count followed by +the device ids. + +| Device id | Physical element | How it was verified | +|---|---|---| +| `0x65` | performance-mode button LED | mode 2 → took the exact red requested | +| `0x21` | **keyboard**, 4 zones | mode 2 → bright static green from a dark baseline | +| `0x83` | **lid logo** | mode 2 → follows the requested RGB exactly, from an off baseline | + +## 4. Required sequence + +``` +1) 0xA2 = select device (1 byte) +2) 0xA4 = apply (10 bytes) +``` + +**Step 1 is mandatory.** Without a prior selection using a *valid* device id, +`0xA4` has no effect. Writing an arbitrary value such as `1` is not a +selection — `1` is not one of the enumerated ids — and the handshake silently +fails. + +## 5. Report `0xA4` — 10 bytes + +| Byte | Usage | Field | Range | Notes | +|---|---|---|---|---| +| 0 | `0x21` | device id | — | same id selected in `0xA2` | +| 1 | `0x41` | **mode** | 0–31 | see §6. **Semantics are per device** | +| 2 | `0x43` | **brightness** | 0–100 | | +| 3 | `0x42` | **speed** | 0–9 | monotonically faster; see §6.1 | +| 4 | `0x44` | **direction** | 1–2 | see §6.1 | +| 5 | `0x45` | **R** | 0–255 | | +| 6 | `0x46` | **G** | 0–255 | | +| 7 | `0x47` | **B** | 0–255 | | +| 8–9 | `0x48` | **zone bitmask**, 16-bit LE | — | see §7 | + +## 6. Modes + +**Mode 2 is static colour on all three devices.** Beyond that the map is per +device and does **not** carry over: on the keyboard mode 1 turns it off, on the +button LED mode 1 does nothing at all and mode 6 turns it off. Assuming +otherwise produces silent no-ops — an earlier reading put the button's static +at mode 1, and every write to it quietly did nothing until that was caught. + +### Keyboard (`0x21`) — verified + +Numbering is the ENE's own and does **not** match the WMI mode numbers other +Acer tools document (0 Static, 1 Breath, 2 Neon, 3 Wave, 4 Shifting, 5 Zoom). +Names below are matched to the effects PredatorSense advertises, **by observed +behaviour**; there is no documented mapping. Modes 8 and 10 are the least +certain: 10 is read as `Shifting` because it is the one with a visible +direction, which matches the documented "shifting light effect, full control +over speed, direction, and colour", leaving 8 as `Meteor`. + +| Mode | Observed | Name | +|---|---|---| +| 1 | off | — | +| **2** | **static colour** | Static | +| 4 | smooth fade through pure colours | Breathing | +| 5 | whole board shifts colour at once, no lateral sweep | Neon | +| 6 | as 5, faster | Neon (fast) | +| 7 | lateral rainbow (factory default) | Wave | +| 8 | a point flares at random, then the whole board flashes | Meteor *(tentative)* | +| 9 | circular, outside inward, darkening, colour drifting each cycle | Zoom | +| 10 | light crosses left-to-right then back, board dark behind it | Shifting *(tentative)* | +| 11 | two of the four segments lit at random, then off | Twinkling | +| 12 | static | variant of Static | +| 13–31 | nothing visible | — | + +### 6.1 Speed and direction + +| Byte | Field | Values | +|---|---|---| +| 3 | speed | `0`–`9`, rising monotonically | +| 4 | direction | `1` left→right · `2` right→left | + +⚠️ **The direction encoding is the reverse of the convention used by Archer and +the Linuwu-Sense docs**, where `2` means left-to-right. Anything bridging the +two must translate, or the UI label ends up inverted. + +Value `3` in byte 4 produced a trailing-comet variant rather than a third +direction. Not characterised. + +### Lid logo (`0x83`) — verified + +| Mode | Effect | +|---|---| +| 1 | off | +| **2** | **static colour, honours RGB** | +| 3 | off | +| 4 | fixed red, RGB ignored | +| 5 | slow colour cycle | +| 6 | fixed yellow, RGB ignored | +| 7 | off | + +An earlier round concluded the logo did not read the RGB bytes at all, because +the same green was sent with modes 1, 2 and 4 and produced off, green and red. +Three modes with three different behaviours, read as one capricious colour. +Re-tested with the mode held constant at 2 the logo follows red, green, blue +and white exactly, and repeating an identical write repeats the identical +result — so there is no hidden counter either. + +### Performance-mode button (`0x65`) — verified + +| Mode | Effect | +|---|---| +| 1 | nothing | +| **2** | **static colour, honours RGB** | +| 3 | nothing | +| 4 | breathing, holding the colour | +| 5 | colour cycle | +| 6 | off — and briefly darkens the keyboard too, which the EC restores after a second or two | +| 7 | nothing | +| **≥ 8** | ⚠️ coincided with fans starting and stopping. Probably reaches the performance profile, not just the LED. **Do not sweep this range blindly.** | + +## 7. Zone bitmask (bytes 8–9) — verified exhaustively + +**A bitmask of the low four bits.** Zones combine in a single write. + +| Value | Observed | +|---|---| +| `0x0001` | first quarter (leftmost) | +| `0x0002` | second quarter | +| `0x0004` | third quarter | +| `0x0008` | fourth quarter (rightmost) | +| `0x0003` | **left half** — the test that proves it is a mask, not an index | +| `0x000F` | whole keyboard | +| `0xFFFF` | whole keyboard | +| `0x0100` | **no effect** — the high byte is ignored | + +## 8. What does not work + +| Path | Result | +|---|---| +| Per-zone colour over **WMI** (`SET_GAMING_RGB_KB`) | stored, read back faithfully, **not applied** | +| Effect mode over **WMI** (`set_kb_status`) | ignored | +| Brightness over **WMI** | ✅ genuinely applied | +| Per-zone buffer via report `0xA3` | ignored — `0xA4` wins. Loading four distinct colours and applying blue produced an all-blue keyboard | + +## 9. Worked example — four zones, four colours + +``` +# select the keyboard, then paint each zone with its own mask +0xA2 = 21 +0xA4 = 21 02 64 00 00 FF 00 00 01 00 # zone 1 red +0xA4 = 21 02 64 00 00 00 FF 00 02 00 # zone 2 green +0xA4 = 21 02 64 00 00 00 00 FF 04 00 # zone 3 blue +0xA4 = 21 02 64 00 00 FF FF FF 08 00 # zone 4 white +``` + +## 10. Competing writers + +Three independent actors impose keyboard state on every boot. Any experiment +must silence them or the results are uninterpretable. + +| Actor | When | What | +|---|---|---| +| EC / firmware | always | animates over a private channel; can reassert itself | +| `linuwu_sense` | at module load | restores `/etc/four_zone_kb_state` | +| `archer-daemon` | ~2 s after the login screen | restores `/etc/archer/settings.json` | + +## 11. Method note + +Two conclusions in this work were initially wrong, both for the same reason: +**no baseline was recorded before the first write.** A finding about the lid +logo had to be withdrawn entirely, and a mode value verified on the button LED +was wrongly assumed to hold for the keyboard, invalidating a whole round of +zone testing. + +Everything above was re-derived with the keyboard, button LED and lid logo all +dark before each experiment, and with each observation labelled by the exact +parameter that produced it. If you extend this work, keep that discipline: the +failure mode of this hardware is *silent* no-ops, so "nothing changed" carries +almost no information unless the starting state is known. + +## 12. Open questions + +- Byte 4 value `3`: a trailing-comet variant, not characterised. +- Acer's real per-profile colours for the button LED. Not published, and the + factory firmware stops driving the LED once this backend takes over, so + there is nothing left to read them from. The defaults shipped here are a + choice, not a discovery; only the purple on `balanced-performance` came + from observed hardware. diff --git a/gui/archer/pages/keyboard.py b/gui/archer/pages/keyboard.py index 342c72d..3e46979 100644 --- a/gui/archer/pages/keyboard.py +++ b/gui/archer/pages/keyboard.py @@ -137,10 +137,16 @@ def __init__(self, client): self.effects_group = Adw.PreferencesGroup(title="Lighting Effects") content.append(self.effects_group) - # Effect mode combo + # Effect mode combo. + # These are the effects actually verified on the ENE K5130 controller, + # in the order the daemon maps them (see archer_ene.EFFECTS). The old + # list came from the WMI documentation, whose effect field this + # firmware ignores, so those entries did nothing at all. The names here + # are matched to the effects PredatorSense advertises, by observed + # behaviour rather than by any documented mapping. effect_modes = Gtk.StringList.new([ - "Static", "Breathing", "Neon", "Wave", - "Shifting", "Zoom", "Meteor", "Twinkling", + "Static", "Breathing", "Neon", "Neon (fast)", "Wave", + "Meteor", "Zoom", "Shifting", "Twinkling", ]) self.effect_mode_row = Adw.ComboRow( title="Effect Mode", @@ -161,6 +167,7 @@ def __init__(self, client): self.speed_scale.set_value(5) speed_box.append(self.speed_scale) self.effects_group.add(speed_box) + # Byte 3 of ENE report 0xA4, verified: 0-9, monotonically faster. # Effect colour color_box = Gtk.Box(spacing=12, margin_top=4) @@ -179,6 +186,8 @@ def __init__(self, client): model=direction_model, ) self.effects_group.add(self.direction_row) + # Byte 4 of report 0xA4, verified. The daemon translates this to the + # controller's own convention, which is the reverse of Archer's. # Apply effect button apply_effect_btn = Gtk.Button( @@ -241,7 +250,11 @@ def load_settings(self, data): effect = saved.get("four_zone_mode") if effect and has_effects: mode = effect.get("mode", 0) - self.effect_mode_row.set_selected(mode) + # The effect list shrank when the WMI-derived entries were replaced + # by the verified ENE ones, so a settings file written by an older + # build can hold an index past the end of the model. Clamp it. + n_effects = self.effect_mode_row.get_model().get_n_items() + self.effect_mode_row.set_selected(min(mode, n_effects - 1)) speed = effect.get("speed", 5) self.speed_scale.set_value(speed) r = effect.get("red", 0) diff --git a/gui/archer_daemon.py b/gui/archer_daemon.py index 77b1466..76000e4 100755 --- a/gui/archer_daemon.py +++ b/gui/archer_daemon.py @@ -16,6 +16,15 @@ from logging.handlers import RotatingFileHandler from pathlib import Path +# Direct ENE K5130 backlight backend. On PHN16S-71 the WMI path applies +# brightness but silently discards colour and effect mode, so keyboard +# lighting has to go straight to the LED controller. Optional import: if the +# module or the chip is absent we fall back to the sysfs/WMI path. +try: + import archer_ene +except Exception: # pragma: no cover - absence is a supported configuration + archer_ene = None + # --- Configuration --- # /run/archer is created by systemd via RuntimeDirectory=archer in the unit # file. The PID location matches PIDFile= in archer-daemon.service. @@ -355,14 +364,23 @@ def _detect_features(self): # Thermal profiles if os.path.exists(PLATFORM_PROFILE): self.features.append("thermal_profiles") - # Keyboard features (under driver_base/four_zoned_kb/) - if self.driver_base: + # Keyboard features. The ENE backend is preferred when the controller + # is present: it is the only path that actually applies colour on this + # model. The sysfs check stays as the fallback for other hardware. + self.ene_ready = bool(archer_ene and archer_ene.available()) + if self.ene_ready: + self.features.append("keyboard_per_zone") + self.features.append("keyboard_effects") + logger.info("Keyboard lighting: ENE K5130 backend active") + elif self.driver_base: kb_base = os.path.join(self.driver_base, "four_zoned_kb") if os.path.isdir(kb_base): if os.path.exists(os.path.join(kb_base, "per_zone_mode")): self.features.append("keyboard_per_zone") if os.path.exists(os.path.join(kb_base, "four_zone_mode")): self.features.append("keyboard_effects") + logger.info("Keyboard lighting: sysfs/WMI fallback " + "(colour may not be applied by firmware)") # Sense-specific features (under predator_sense/ or nitro_sense/) if self.sense_base: sense_features = { @@ -517,8 +535,30 @@ def set_thermal_profile(self, profile): if profile not in choices: return False, f"Invalid profile '{profile}'. Available: {choices}" ok = write_sysfs(PLATFORM_PROFILE, profile) + if ok: + self._sync_button_led(profile) return ok, None if ok else "Failed to write profile" + def _sync_button_led(self, profile): + """Colour the performance-mode button LED after the active profile. + + That is what the LED does from the factory. Once the ENE backend takes + the controller over, the firmware stops driving it, so the daemon has + to keep it in step or the button just goes dark. + + Failures are logged and swallowed on purpose: a lighting detail must + never make a thermal profile change report failure. + """ + if not getattr(self, "ene_ready", False): + return + if not self.settings.get("button_follows_profile", True): + return + try: + archer_ene.set_button_for_profile( + profile, overrides=self.settings.get("button_colours")) + except Exception as exc: + logger.warning(f"Could not update the button LED: {exc}") + # --- Fan Control --- def get_fan_speed(self): path = self._sense_path("fan_speed") @@ -617,14 +657,89 @@ def get_battery_info(self): return info # --- Keyboard Lighting --- + # Both setters prefer the ENE backend and keep the sysfs/WMI write as a + # fallback. Note the sysfs path is not merely less capable: on PHN16S-71 it + # reports success and reads back the exact colours it was given while the + # LEDs never change, which is why it cannot be trusted as verification. + def set_per_zone_mode(self, zone1, zone2, zone3, zone4, brightness): + if getattr(self, "ene_ready", False): + try: + return archer_ene.set_per_zone(zone1, zone2, zone3, zone4, + brightness) + except Exception as exc: + logger.error(f"ENE per-zone write failed: {exc}") + return False path = self._driver_path("four_zoned_kb/per_zone_mode") if not path: return False val = f"{zone1},{zone2},{zone3},{zone4},{brightness}" return write_sysfs(path, val) + def poll_profile_led(self): + """Keep the button LED in step with the profile, whoever changed it. + + set_thermal_profile() only covers changes made through Archer. The + Plasma widget, powerprofilesctl, the driver's own restore and the + hardware mode button all write platform_profile directly, and the LED + would silently drift out of step with the machine. Reading one small + sysfs file on a timer is cheap and catches every writer. + + Returns True so the GLib timeout keeps re-arming. + """ + if getattr(self, "ene_ready", False): + profile = read_sysfs(PLATFORM_PROFILE) + if profile and profile != getattr(self, "_led_profile", None): + self._led_profile = profile + self._sync_button_led(profile) + return True + + def reapply_lighting(self): + """Re-send the saved lighting state. Called after resume. + + The ENE does not keep its state across a suspend and the driver does + not restore it, so without this the keyboard comes back under whatever + the EC decides. + + Deliberately narrow: lighting only. A resume is not the moment to start + rewriting the thermal profile or the fans, so those are left alone even + though the same settings file holds them. + """ + if not getattr(self, "ene_ready", False): + return False + try: + if self.settings.get("last_keyboard_mode") == "effect": + e = self.settings.get("four_zone_mode") or {} + if e: + self.set_four_zone_mode( + e.get("mode", 0), e.get("speed", 5), + e.get("brightness", 100), e.get("direction", 2), + e.get("red", 0), e.get("green", 0), e.get("blue", 255), + ) + else: + pz = self.settings.get("per_zone_mode") or {} + if pz: + self.set_per_zone_mode( + pz["zone1"], pz["zone2"], pz["zone3"], pz["zone4"], + pz["brightness"], + ) + profile = read_sysfs(PLATFORM_PROFILE) + if profile: + self._sync_button_led(profile) + logger.info("Lighting reapplied after resume") + return True + except Exception as exc: + logger.error(f"Reapplying lighting after resume failed: {exc}") + return False + def set_four_zone_mode(self, mode, speed, brightness, direction, r, g, b): + if getattr(self, "ene_ready", False): + try: + return archer_ene.set_effect(mode, brightness, r, g, b, + speed=speed, direction=direction) + except Exception as exc: + logger.error(f"ENE effect write failed: {exc}") + return False path = self._driver_path("four_zoned_kb/four_zone_mode") if not path: return False @@ -1159,6 +1274,39 @@ def main(): cleanup_pid() sys.exit(1) + # Reapply lighting after resume. Neither acer_suspend() nor acer_resume() + # in the driver touch RGB, and the ENE loses our state across the sleep, so + # without this the keyboard comes back to whatever the EC decides. + # Hooking logind's PrepareForSleep keeps this self-contained: no extra + # systemd unit to install, and the daemon is already running a GLib loop. + try: + def _on_prepare_for_sleep(sleeping): + if sleeping: + return + # The I2C-HID controller re-enumerates a moment after resume, and + # PrepareForSleep(False) arrives before that. Writing immediately + # would just fail, so give it a beat. Returning False makes the + # timeout fire once rather than repeat. + GLib.timeout_add_seconds( + 2, lambda: (hw.reapply_lighting(), False)[1]) + + system_bus = dbus.SystemBus() + system_bus.add_signal_receiver( + _on_prepare_for_sleep, + signal_name="PrepareForSleep", + dbus_interface="org.freedesktop.login1.Manager", + bus_name="org.freedesktop.login1", + ) + logger.info("Listening for resume to reapply lighting") + + # Catch profile changes made outside Archer, so the button LED cannot + # drift out of step with the machine. + GLib.timeout_add_seconds(3, hw.poll_profile_led) + except Exception as e: + # Not fatal: everything else still works, lighting just will not + # survive a suspend. + logger.warning(f"Could not subscribe to PrepareForSleep: {e}") + def signal_handler(sig, frame): logger.info("Shutting down...") hw.shutdown_fan_curves() diff --git a/gui/archer_ene.py b/gui/archer_ene.py new file mode 100644 index 0000000..8a7a652 --- /dev/null +++ b/gui/archer_ene.py @@ -0,0 +1,387 @@ +"""ENE K5130 keyboard backlight backend for Acer Predator PHN16S-71. + +WHY THIS EXISTS + On this model the ACPI-WMI path is only partially implemented in firmware. + Verified by experiment: + + brightness -> applied + per-zone RGB -> stored and read back faithfully, NEVER applied + effect mode -> ignored + + Writing per_zone_mode through linuwu_sense therefore looks like it works + (the sysfs read-back returns exactly what you wrote) while the keyboard + keeps showing the factory rainbow. The colours never reach the LEDs. + + The LEDs are driven by an ENE K5130 sitting on I2C-1 at 0x50, exposed as a + standard HID-over-I2C device (0018:0CF2:5130) with four vendor feature + reports. Talking to it directly does work. This module implements that + protocol; see docs/ENE_PROTOCOL.md for the full reverse-engineering notes. + +PROTOCOL SUMMARY + 0xA1 4 bytes, read-only: "03 65 21 83" = device count + device ids + 0xA2 1 byte: select device <- MANDATORY before 0xA4 + 0xA4 10 bytes: dev, mode, brightness, speed, direction, R, G, B, + zonemask (16-bit LE) + + Device ids: 0x21 keyboard (4 zones), 0x65 performance-mode button LED, + 0x83 lid logo. All three take colour; each has its own mode + numbering (see MODE_* and LOGO_/BUTTON_ constants). + + Mode 2 is static colour on all three devices. The rest of the mode map + is per device and does not carry over: on the keyboard mode 1 turns it + off, on the button LED mode 1 does nothing and mode 6 turns it off. + Check each device rather than assuming. + + The zone field is a bitmask of the low four bits, so zones combine: + 0x3 paints the left half in a single write. The high byte is ignored. + + Direction is encoded the OTHER WAY ROUND from Archer and the + Linuwu-Sense docs: on the wire 1 sweeps left-to-right and 2 sweeps + right-to-left. _to_wire_direction() does the translation. + +SAFETY + - The target is resolved by HID identity, never by hidraw number: the + numbering is not stable across boots (the ENE's reset times out during + probe, so it enumerates last, and the touchpad can rebind and reclaim + hidraw0). + - The internal keyboard and the touchpad are explicitly refused. + - Every value is range-checked against what the HID report descriptor + declares before anything is written. + - The accepted mode range is per device. The keyboard takes the full + verified range; every other device id stays capped at 7, because while + sweeping the button LED modes >= 8 coincided with fans starting and + stopping, so up there the report probably reaches the performance + profile and not just the LED. +""" + +import ctypes +import fcntl +import os +import signal +import threading +import time + +# --- identity ------------------------------------------------------------- +ENE_HID_ID = "0018:00000CF2:00005130" + +# Devices that must never be opened by this module even if something goes +# wrong upstream: writing vendor feature reports to them could leave the +# machine without input. +FORBIDDEN_HID_IDS = { + "0018:00001025:0000174B": "internal keyboard (input)", + "0018:000006CB:0000CFE4": "Synaptics touchpad", +} + +# --- device ids (from report 0xA1) ---------------------------------------- +DEV_KEYBOARD = 0x21 +DEV_BUTTON = 0x65 +DEV_LOGO = 0x83 + +# --- keyboard modes, each observed against a black baseline --------------- +# Numbering is the ENE's own and does NOT match the WMI mode numbers that +# other Acer tools document. Names are matched to the effects PredatorSense +# advertises, by behaviour. Modes 8 and 10 are the least certain: 10 is read +# as Shifting because it is the one with a visible direction, matching the +# documented "shifting light effect, full control over speed, direction and +# colour", which leaves 8 as Meteor. +MODE_OFF = 1 +MODE_STATIC = 2 +MODE_BREATHING = 4 # smooth fade through pure colours +MODE_NEON = 5 # whole board shifts colour at once, no sweep +MODE_NEON_FAST = 6 # same, faster +MODE_WAVE = 7 # lateral rainbow (factory default) +MODE_METEOR = 8 # a point flares at random, then the board flashes +MODE_ZOOM = 9 # circular, outside inward, colour drifting +MODE_SHIFTING = 10 # light crosses and returns over a dark board +MODE_TWINKLING = 11 # two of the four segments lit at random + +MODE_MAX_KEYBOARD = 12 # 13..31 produced nothing visible +MODE_MAX_OTHER = 7 # see the note on modes >= 8 in the module docstring + +# --- lid logo (0x83), verified from an off baseline ----------------------- +# 1 off · 2 static, honours RGB · 3 off · 4 fixed red · 5 slow cycle +# 6 fixed yellow · 7 off +LOGO_OFF = 1 +LOGO_STATIC = 2 + +# --- performance-mode button LED (0x65) ----------------------------------- +# 1 nothing · 2 static, honours RGB · 3 nothing · 4 breathing · 5 colour cycle +# 6 off · 7 nothing +# +# Mode 2 is static colour on ALL THREE devices. An earlier reading had the +# button at mode 1, which is why every write to it silently did nothing. +BUTTON_OFF = 6 +BUTTON_STATIC = 2 + +# Colour shown on the button LED for each platform profile. The button is the +# performance-mode button, so tying it to the profile is what it is for. +# +# These are DEFAULTS CHOSEN HERE, not Acer's mapping: the only value taken +# from observed hardware is the purple on balanced-performance. Acer does not +# publish the per-mode colours and the factory firmware stops driving the LED +# once this backend takes over, so there is nothing left to read them from. +# Override per profile with button_colours in settings.json. +PROFILE_COLOURS = { + "low-power": "00b0ff", # cyan + "quiet": "00ff40", # green + "balanced": "0080ff", # blue, matching Archer's own palette + "balanced-performance": "8000ff", # purple, observed on this machine + "performance": "ff0000", # red +} + +# Effects offered in the GUI, in list order. Only verified modes are exposed. +EFFECTS = [ + ("Static", MODE_STATIC), + ("Breathing", MODE_BREATHING), + ("Neon", MODE_NEON), + ("Neon (fast)", MODE_NEON_FAST), + ("Wave", MODE_WAVE), + ("Meteor", MODE_METEOR), + ("Zoom", MODE_ZOOM), + ("Shifting", MODE_SHIFTING), + ("Twinkling", MODE_TWINKLING), +] + +ZONE_ALL = 0x0F + +# Byte 4 of report 0xA4. Note this is the opposite of the convention Archer +# and the Linuwu-Sense docs use, where 2 means left-to-right: on the wire +# 1 sweeps left-to-right and 2 sweeps right-to-left. Callers pass Archer's +# value and _to_wire_direction() flips it. +DIR_LEFT_TO_RIGHT = 1 +DIR_RIGHT_TO_LEFT = 2 + +# --- reports -------------------------------------------------------------- +_REPORT_LEN = {0xA2: 1, 0xA3: 8, 0xA4: 10} + +_IOC_WRITE, _IOC_READ = 1, 2 + + +def _hidiocsfeature(size): + return (((_IOC_WRITE | _IOC_READ) << 30) | (size << 16) | + (ord('H') << 8) | 0x06) + + +IOCTL_TIMEOUT_S = 3.0 +MIN_GAP_S = 0.05 # the chip needs a breath between feature writes + +_lock = threading.Lock() +_last_write = 0.0 + + +class EneError(Exception): + pass + + +def _throttle(): + global _last_write + gap = time.monotonic() - _last_write + if gap < MIN_GAP_S: + time.sleep(MIN_GAP_S - gap) + _last_write = time.monotonic() + + +def _alarm(signum, frame): + raise EneError("ENE ioctl timed out") + + +def _resolve(): + """Return the hidraw node of the ENE, resolved by HID identity. + + Raises EneError if it is absent or ambiguous. Never returns a node whose + identity is on the blacklist. + """ + base = "/sys/class/hidraw" + if not os.path.isdir(base): + raise EneError("no hidraw class in sysfs") + + found = [] + for node in sorted(os.listdir(base)): + uevent = os.path.join(base, node, "device", "uevent") + try: + with open(uevent) as fh: + text = fh.read() + except OSError: + continue + hid_id = "" + for line in text.splitlines(): + if line.startswith("HID_ID="): + hid_id = line.split("=", 1)[1].strip() + break + if hid_id in FORBIDDEN_HID_IDS: + continue + if hid_id == ENE_HID_ID: + found.append("/dev/" + node) + + if not found: + raise EneError(f"ENE {ENE_HID_ID} not enumerated") + if len(found) > 1: + raise EneError(f"ambiguous ENE match: {found}") + return found[0] + + +def available(): + try: + _resolve() + return True + except EneError: + return False + + +def _write_report(fd, report_id, payload): + expected = _REPORT_LEN[report_id] + if len(payload) != expected: + raise EneError(f"report 0x{report_id:02X} needs exactly " + f"{expected} payload bytes, got {len(payload)}") + total = expected + 1 + buf = ctypes.create_string_buffer(bytes([report_id]) + bytes(payload), total) + + _throttle() + # setitimer only works on the main thread; the daemon serves D-Bus from a + # GLib main loop, so guard it rather than crashing on a worker thread. + armed = threading.current_thread() is threading.main_thread() + if armed: + signal.signal(signal.SIGALRM, _alarm) + signal.setitimer(signal.ITIMER_REAL, IOCTL_TIMEOUT_S) + try: + fcntl.ioctl(fd, _hidiocsfeature(total), buf, True) + except OSError as exc: + raise EneError(f"HIDIOCSFEATURE(0x{report_id:02X}) failed: {exc}") + finally: + if armed: + signal.setitimer(signal.ITIMER_REAL, 0) + + +def _check(name, value, lo, hi): + if not isinstance(value, int) or not lo <= value <= hi: + raise EneError(f"{name} out of range [{lo}, {hi}]: {value!r}") + return value + + +def _to_wire_direction(direction): + """Archer/Linuwu use 2 for left-to-right; the controller uses 1. Flip.""" + return DIR_LEFT_TO_RIGHT if direction == 2 else DIR_RIGHT_TO_LEFT + + +def _apply(fd, device, mode, brightness, rgb, zone_mask, speed=0, direction=0): + # The keyboard tolerates the full verified range. Other devices stay capped + # at 7: on the performance-mode button LED, modes >= 8 coincided with fans + # starting and stopping, so up there the report probably reaches the + # performance profile rather than just the LED. + top = MODE_MAX_KEYBOARD if device == DEV_KEYBOARD else MODE_MAX_OTHER + _check("mode", mode, 1, top) + _check("brightness", brightness, 0, 100) + _check("speed", speed, 0, 9) + _check("direction", direction, 0, 3) + _check("zone_mask", zone_mask, 0, 0xFFFF) + r, g, b = (_check(n, v, 0, 255) for n, v in zip("rgb", rgb)) + + _write_report(fd, 0xA2, [device]) # select — mandatory + _write_report(fd, 0xA4, [device, mode, brightness, speed, direction, + r, g, b, + zone_mask & 0xFF, (zone_mask >> 8) & 0xFF]) + + +def _hex_to_rgb(value): + """Accept 'RRGGBB' or '#RRGGBB'.""" + text = str(value).lstrip("#") + if len(text) != 6: + raise EneError(f"bad colour {value!r}, expected RRGGBB") + try: + return (int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16)) + except ValueError: + raise EneError(f"bad colour {value!r}, expected RRGGBB") + + +# --- public API ----------------------------------------------------------- + +def set_per_zone(zone1, zone2, zone3, zone4, brightness): + """Paint the four keyboard zones. Colours are 'RRGGBB' strings.""" + colours = [_hex_to_rgb(z) for z in (zone1, zone2, zone3, zone4)] + _check("brightness", brightness, 0, 100) + with _lock: + fd = os.open(_resolve(), os.O_RDWR) + try: + for index, rgb in enumerate(colours): + _apply(fd, DEV_KEYBOARD, MODE_STATIC, brightness, rgb, + 1 << index) + finally: + os.close(fd) + return True + + +def set_effect(effect_index, brightness, red, green, blue, + speed=0, direction=2): + """Run one of EFFECTS on the whole keyboard. + + speed is 0-9, rising monotonically. direction follows Archer's convention + (2 = left to right) and is translated for the wire. Both are ignored by + the static modes, which is harmless. + """ + if not 0 <= effect_index < len(EFFECTS): + raise EneError(f"effect index out of range: {effect_index}") + mode = EFFECTS[effect_index][1] + with _lock: + fd = os.open(_resolve(), os.O_RDWR) + try: + _apply(fd, DEV_KEYBOARD, mode, brightness, + (red, green, blue), ZONE_ALL, + speed=speed, direction=_to_wire_direction(direction)) + finally: + os.close(fd) + return True + + +def set_off(): + with _lock: + fd = os.open(_resolve(), os.O_RDWR) + try: + _apply(fd, DEV_KEYBOARD, MODE_OFF, 0, (0, 0, 0), ZONE_ALL) + finally: + os.close(fd) + return True + + +def set_logo(colour, brightness=100): + """Light the lid logo a solid colour. Pass None to switch it off.""" + with _lock: + fd = os.open(_resolve(), os.O_RDWR) + try: + if colour is None: + _apply(fd, DEV_LOGO, LOGO_OFF, 0, (0, 0, 0), 0xFFFF) + else: + _apply(fd, DEV_LOGO, LOGO_STATIC, brightness, + _hex_to_rgb(colour), 0xFFFF) + finally: + os.close(fd) + return True + + +def set_button(colour, brightness=100): + """Light the performance-mode button LED. Pass None to switch it off.""" + with _lock: + fd = os.open(_resolve(), os.O_RDWR) + try: + if colour is None: + _apply(fd, DEV_BUTTON, BUTTON_OFF, 0, (0, 0, 0), 0xFFFF) + else: + _apply(fd, DEV_BUTTON, BUTTON_STATIC, brightness, + _hex_to_rgb(colour), 0xFFFF) + finally: + os.close(fd) + return True + + +def set_button_for_profile(profile, brightness=100, overrides=None): + """Colour the button LED after the active platform profile. + + overrides is an optional {profile: "RRGGBB"} map from settings, which + takes precedence over PROFILE_COLOURS. + + Unknown profile names are left alone rather than guessed at, so a kernel + that grows a new profile does not silently get the wrong colour. + """ + colour = (overrides or {}).get(profile) or PROFILE_COLOURS.get(profile) + if colour is None: + return False + return set_button(colour, brightness)