diff --git a/.gitignore b/.gitignore index 867823d..3c8b0ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ * !fusion !fusion/datatypes.py !fusion/device.py + !.gitignore !play.py !requirements.txt +!profiles.py !test.py diff --git a/fusion/datatypes.py b/fusion/datatypes.py index 48f7a74..b16934b 100644 --- a/fusion/datatypes.py +++ b/fusion/datatypes.py @@ -1,194 +1,217 @@ -from enum import IntEnum +from enum import IntEnum, _EnumDict from dataclasses import dataclass from typing import Optional, List from math import floor class FusionLightEffect(IntEnum): Static = 1 Breathing = 2 Wave = 3 Fadeonkeypress = 4 Marquee = 5 Ripple = 6 Flashonkeypress = 7 Neon = 8 Rainbowmarquee = 9 Raindrop = 10 Circlemarquee = 11 Hedge = 12 Rotate = 13 Custom1 = 51 Custom2 = 52 Custom3 = 53 Custom4 = 54 Custom5 = 55 + #accept just plain ints to bypass "technically correct" profile ids + #idk what hell i just unleashed upon myself with this but yes it works + #blame object.__new__ not being usable >:( + @classmethod + def _missing_(cls, value): + #need to reset member names quickly to bypass _check_for_existing_members + backup = FusionLightEffect._member_names_ + FusionLightEffect._member_names_ = [] + + #create a subclass with an unknown member just for this value + enum_dict = _EnumDict() + enum_dict._cls_name = cls + enum_dict['Unknown'] = value + member = type('FusionLightEffectCustom', (FusionLightEffect,), enum_dict).Unknown + + #restore the backup to avoid breaking things + FusionLightEffect._member_names_ = backup + return member + class FusionLightColor(IntEnum): Black = 0 Red = 1 Green = 2 Yellow = 3 Blue = 4 Orange = 5 Purple = 6 White = 7 Random = 8 -class IoneDirection(IntEnum): +class IoneLightDirection(IntEnum): Left2Right = 0 Right2Left = 1 Up2Down = 2 Down2Up = 3 Clockwise = 4 AntiClockwise = 5 +class FusionLightDirection(IntEnum): + Left2Right = 1 + Right2Left = 2 + Down2Up = 3 + Up2Down = 4 + Clockwise = 1 + AntiClockwise = 2 + @dataclass class FusionLightData: fusion_effect: FusionLightEffect #seems like speed actually means duration here since shorter = faster fusion_speed: int fusion_brightness: int fusion_color: FusionLightColor - fusion_direction: int + fusion_direction: FusionLightDirection @dataclass class RawInputDevice: usUsagePage: int usUsage: int dwFlags: int hwndTarget: Optional[int] = None @dataclass class RGB: red: int green: int blue: int @dataclass class PictureMatrix: pixels: List[RGB] - def to_bytes(self) -> bytes: + def to_bytes(self, adjust: RGB = None) -> bytes: if len(self.pixels) not in range(106): raise TypeError('Too many pixels!') arr = bytearray(512) for i, pixel in enumerate(self.pixels): idx = KEY_MATRIX_INDEX_VALUES[i]*4 - arr[idx:idx+4] = [0, pixel.red, pixel.green, pixel.blue] + if adjust: + arr[idx:idx+4] = [0, (pixel.red * adjust.red) // 255, (pixel.green * adjust.green) // 255, (pixel.blue * adjust.blue) // 255] + else: + arr[idx:idx+4] = [0, pixel.red, pixel.green, pixel.blue] return arr @classmethod def from_bytes(cls, data: bytes) -> 'PictureMatrix': if len(data) != 512: raise TypeError('Invalid payload size!') #the nones should be gone after the loop since KEY_MATRIX_INDEX_VALUES should have all the positions for 105 keys regardless of color pixels = [None]*105 for chunk, idx in enumerate(range(0, len(data), 4)): - if chunk in [92, 7]: - breakpoint() if chunk in KEY_MATRIX_INDEX_VALUES: #apparently there are duplicates in the mapping so we need to loop through it start = 0 try: while True: key_idx = KEY_MATRIX_INDEX_VALUES.index(chunk, start) start = key_idx + 1 pixels[key_idx] = RGB(*data[idx+1:idx+4]) except ValueError: continue return PictureMatrix(pixels) @classmethod def pixel_matrix_to_keys(cls, list: List[RGB]) -> 'PictureMatrix': """Turns a proper 19x6 matrix of RGB values into the keyboard matrix by averaging the color over larger keys""" if len(list) != 19*6: raise TypeError('Has to be a 19x6 matrix!') mat = [list[i:i+19] for i in range(0, 19*6, 19)] def avg(ints: List[int]): return sum(ints) // len(ints) def merge(pos: int, length: int, pixels: List[RGB]) -> RGB: curr_length = 1 - (pos - floor(pos)) #how much of the key is actually in the first pixel location mat_pos = floor(pos) #first pixel location length -= curr_length r, g, b = [], [], [] while (curr_length + length) > 0: #print(mat_pos, length, curr_length) #apply weight r.append(pixels[mat_pos].red * curr_length) g.append(pixels[mat_pos].green * curr_length) b.append(pixels[mat_pos].blue * curr_length) #either it takes a full pixel, or a portion of the pixel if nothing else is left curr_length = min(1, length) length -= curr_length mat_pos += 1 return RGB(round(avg(r)), round(avg(g)), round(avg(b))) key_pixels = [] - for pixels, weights in zip(mat, KEY_WEIGHT): + for pixels, weights in zip(mat, KEY_WEIGHTS): pos = 0 for weight in weights: key_pixels.append(merge(pos, weight, pixels)) pos += weight #account for the 2 vertical keys def merge_vertical(*pos: int): p1, p2 = key_pixels[pos[0]], key_pixels[pos[1]], key_pixels[pos[1]] = RGB(avg([p1.red, p2.red]), avg([p1.green, p2.green]), avg([p1.blue, p2.blue])) #first position is the one to be removed according to KEY_TITLES mapping key_pixels.pop(pos[0]) #note the order - it has to be from top of list to bottom of list to avoid repositioning merge_vertical(88, 102) #numpad enter merge_vertical(54, 71) #numpad plus return PictureMatrix(key_pixels) - - - - - KEY_MATRIX_INDEX_VALUES = [ 11, 17, 23, 29, 35, 41, 47, 53, 59, 65, 71, 77, 83, 89, 95, 101, 107, 113, 119, 10, 16, 22, 28, 34, 40, 46, 52, 58, 64, 70, 76, 82, 94, 100, 106, 112, 118, 9, 15, 21, 27, 33, 39, 45, 51, 57, 63, 69, 75, 81, 87, 99, 105, 111, 8, 14, 20, 26, 32, 38, 44, 50, 56, 62, 68, 74, 92, 98, 104, 110, 116, 7, 19, 25, 31, 37, 43, 49, 55, 61, 67, 73, 85, 91, 97, 103, 109, 6, 12, 18, 24, 42, 60, 66, 72, 84, 90, 96, 102, 108, 114, 86, 92, 7, 13 ] KEY_TITLES = [ "btnEsc", "btnF1", "btnF2", "btnF3", "btnF4", "btnF5", "btnF6", "btnF7", "btnF8", "btnF9", "btnF10", "btnF11", "btnF12", "btnPause", "btnDel", "btnHome", "btnPgUp", "btnPgDn", "btnEnd", "btnGrave", "btn1", "btn2", "btn3", "btn4", "btn5", "btn6", "btn7", "btn8", "btn9", "btn0", "btnHyphen", "btnEqual", "btnBackspace", "btnNumLk", "btnSlash2", "btnAsterisk", "btnMinus", "btnTab", "btnQ", "btnW", "btnE", "btnR", "btnT", "btnY", "btnU", "btnI", "btnO", "btnP", "btnLsquarebracket", "btnRsquarebracket", "btnBackslash", "btn_7", "btn_8", "btn_9", "btnCapsLock", "btnA", "btnS", "btnD", "btnF", "btnG", "btnH", "btnJ", "btnK", "btnL", "btnSemicolon", "btnApostrophe", "btnEnter", "btn_4", "btn_5", "btn_6", "btnPlus", "btnLshift", "btnZ", "btnX", "btnC", "btnV", "btnB", "btnN", "btnM", "btnComma", "btnFullstop", "btn_Slash", "btnRshift", "btnUp", "btn_1", "btn_2", "btn_3", "btnLctrl", "btnFn", "btnWin", "btnLalt", "btnSpace", "btnRalt", "btnApp", "btnRctrl", "btnLeft", "btnDown", "btnRight", "btn_0", "btn_Del", "btnEnter2", "btnSharpUk", "btnEnterUk", "btnLshiftUk", "btnSlashUk" ] -KEY_WEIGHT = [ +KEY_WEIGHTS = [ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1], [1.5, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1.5, 1, 1, 1, 1], [1.8, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2.2, 1, 1, 1, 1], [2.3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1.7, 1, 1, 1, 1, 1], [1.2, 1, 1, 1, 5.2, 1, 1, 1.6, 1, 1, 1, 1, 1, 1], ] \ No newline at end of file diff --git a/fusion/device.py b/fusion/device.py index 76089e9..75c2dcb 100644 --- a/fusion/device.py +++ b/fusion/device.py @@ -1,165 +1,183 @@ import hid import winreg from .datatypes import * class FusionDevice(): VID = 0x1044 PID = [0x7a39, 0x7a3a] + ADJUST_RGB = None + #unsure what these devices are for yet DEVICES = [RawInputDevice(1, 6, 768), RawInputDevice(1, 2, 256), RawInputDevice(65281, 8713, 256), RawInputDevice(65282, 1, 256), RawInputDevice(65280, 65280, 256)] def __init__(self, path: bytes) -> None: self.h = hid.device() self.h.open_path(path) self.path = path def compute_simple_checksum(self, buf): acc = 0 for i in range(8): acc += buf[i] acc %= 0xFF buf[8] = 0xFF - acc def send_simple_command_and_recv(self, opcode: int, report_num = 0): buf = bytearray(9) buf[1] = opcode self.compute_simple_checksum(buf) sent = self.h.send_feature_report(buf) if sent == -1: raise IOError(self.h.error()) buf = self.h.get_feature_report(0, 9) return buf def get_firmware_version(self) -> bytes: return self.send_simple_command_and_recv(0x80) def get_version_string(self) -> str: buf = self.get_firmware_version() minor = list(str(buf[3])) return f'{buf[2]}.{"0" if len(minor) == 1 else minor.pop(0)}.{minor.pop(0)}' - def get_light_effect(self) -> FusionLightData: + def get_simple_light_effect(self) -> FusionLightData: buf = self.send_simple_command_and_recv(0x88) - return FusionLightData(FusionLightEffect(buf[3]), buf[4], buf[5], FusionLightColor(buf[6]), buf[7]) + return FusionLightData(FusionLightEffect(buf[3]), buf[4], buf[5], FusionLightColor(buf[6]), FusionLightDirection(buf[7])) - # this is needed to set the current working profile to custom too (set once only is enough) + #this is not strictly needed when setting custom profiles - + #sending the payload via set_custom_light_effect will update the profile and set the current keyboard lighting to it + #but for the profile selection to persist across reboots etc explicitly calling this to set the custom profile is needed + #(note that just calling this will also not change the current keyboard lighting, only on reboots, + # unless we are not in any of the custom profiles then regardless of the actual profile chosen + # the last image will show up right when we do set_simple_light_effect) def set_simple_light_effect(self, effect: FusionLightData) -> None: + #technically the UI only allows setting 1-10 it looks like, but 255 still works + #but if speed is 0 the firmware would crash when switching to a profile that utilizes speed + if effect.fusion_speed not in range(1, 256): + raise TypeError('Invalid speed!') buf = bytearray(9) buf[1] = 0x8 buf[3] = int(effect.fusion_effect) buf[4] = effect.fusion_speed + #also technically this should be a range from 0-50 but up to 255 works (even though it behaves the same as 50 afaict?) buf[5] = effect.fusion_brightness buf[6] = int(effect.fusion_color) - buf[7] = effect.fusion_direction + buf[7] = int(effect.fusion_direction) self.compute_simple_checksum(buf) sent = self.h.send_feature_report(buf) if sent == -1: raise IOError(self.h.error()) #aka SetPictureMatrix2Device - #TODO seems to not be persistent, but also nice since we can just reset by hibernating + devmgmt disable/enable when it breaks - def set_custom_light_effect(self, profile: int, pixels: PictureMatrix): - if profile not in range(5): - raise TypeError('Invalid profile number!') + #requires some cooperation from set_simple_light_effect to properly "persist", see above + def set_custom_light_effect(self, profile: int, pixels: PictureMatrix, adjust: RGB = None): + #seems like profile can be anything (just that outside of 0-4 it doesnt persist?) + #edit: so apparently profiles outside of 0-4 (at least up until 255) actually persists and is accessible, just that on a reboot it will assume the data doesnt exist + #switching to another profile and then switching back would still work + #aka the profile restriction only exists in set_simple_light_effect + # if profile not in range(5): + # raise TypeError('Invalid profile number!') buf = bytearray(9) buf[1] = 0x12 buf[3] = profile - buf[4] = 8 #why? + buf[4] = 8 #why? its also always returned by get_custom_light_effect self.compute_simple_checksum(buf) sent = self.h.send_feature_report(buf) if sent == -1: raise IOError(self.h.error()) - data = pixels.to_bytes() + data = pixels.to_bytes(adjust) for split in [data[i:i+64] for i in range(0, len(data), 64)]: self.h.write(b'\0' + split) #writing all at once would just freeze the keyboard #self.h.write(b'\0' + b'\0'.join([data[i:i+64] for i in range(0, len(data), 64)])) #aka LoadPictureMatrixValue #use PictureMatrix.from_bytes to convert it back into a usable PictureMatrix def get_custom_light_effect(self, profile: int) -> bytes: - if profile not in range(5): - raise TypeError('Invalid profile number!') + # if profile not in range(5): + # raise TypeError('Invalid profile number!') buf = bytearray(9) buf[1] = 0x92 buf[3] = profile self.compute_simple_checksum(buf) sent = self.h.send_feature_report(buf) if sent == -1: raise IOError(self.h.error()) buf = self.h.get_feature_report(0, 9) - print(buf) data = b'' for i in range(8): - print(i) data += bytes(self.h.read(65)) return data - #TODO seems like a no-op? but even in FusionKeyboard.dll it's immediately succeeded by a call to set static white anyways hm - #maybe it's for clearing custom profiles in firmware memory (or the unimplemented macro stuff) + #clears all data in firmware memory (custom profiles, macros, etc) + #in FusionKeyboard.dll it's usually immediately succeeded by a call to reset light profile to static white def reset(self) -> None: buf = bytearray(9) buf[1] = 0x13 buf[2] = 0xFF self.compute_simple_checksum(buf) sent = self.h.send_feature_report(buf) if sent == -1: raise IOError(self.h.error()) #TODO untested classes below class FusionDeviceIone(FusionDevice): PID = [0x7a3c, 0x7a3d, 0x7a3e] #normal, UK, JP def get_version_string(self) -> str: buf = bytearray(264) buf[0] = 0x7 buf[1] = 0x17 report_num = self.h.send_feature_report(buf) print(report_num) if report_num == -1: raise IOError(self.h.error()) with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, "Software\\GigabyteFusion") as key: return winreg.QueryValue(key, 'Version') def get_firmware_version(self) -> bytes: #theres actually no raw response directly so just fake it with the version string return self.get_version_string().encode() #TODO changeIoneAnimationListUI, changeIoneAnimationTrigger, changeIoneColorGroupUI #TODO seems like the way to do complex profile is changeLightEffect_Details for ione class FusionDeviceIte(FusionDevice): - PID = [0x7a38, 0x7a3b, 0x7a3f] + PID = [0x7a38, 0x7a3b, 0x7a3f] #normal, UK, JP + + #Ite requires RGB color adjustment (it's actually a special case even in FusionKeyboard.dll for Ite) + #this value is guessed by eyeballing though + ADJUST_RGB = RGB(70, 255, 130) class FusionDeviceX9(FusionDevice): VID = 0x4D9 PID = [0x8008] def get_version_string(self) -> str: buf = self.get_firmware_version() return f'{buf[2]}.{"0" if len(str(buf[3])) == 1 else buf[3] // 16}.{buf[3] % 16}' def get_device() -> FusionDevice: for dev in hid.enumerate(): for type in [FusionDevice, FusionDeviceX9, FusionDeviceIone, FusionDeviceIte]: #print(type, type.VID, type.PID, dev['vendor_id'], dev['product_id']) if dev['vendor_id'] == type.VID and dev['product_id'] in type.PID: try: obj = type(dev['path']) #there should only be one that we can get the firmware version successfully from obj.get_firmware_version() return obj except IOError: continue \ No newline at end of file diff --git a/play.py b/play.py index 92e9dcf..4c0500c 100644 --- a/play.py +++ b/play.py @@ -1,69 +1,130 @@ from fusion.datatypes import * from fusion.device import get_device from cv2.typing import MatLike -import sys, cv2 +import sys, cv2, time, numpy, atexit + +#10 is actually not transient - it is persistent as a secondary profile but for our purposes we can treat it as transient +#since nobody should have the need to write to a profile number this high (switching between profiles require os interaction anyways so might as well write a new image) +TRANSIENT_ID = 10 if len(sys.argv) < 2: - print(f'Usage: {__file__} [profile id]') + print(f'Usage: {__file__}