diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..867823d --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +* + +!fusion +!fusion/datatypes.py +!fusion/device.py +!.gitignore +!play.py +!requirements.txt +!test.py diff --git a/fusion/datatypes.py b/fusion/datatypes.py new file mode 100644 index 0000000..48f7a74 --- /dev/null +++ b/fusion/datatypes.py @@ -0,0 +1,194 @@ +from enum import IntEnum +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 + +class FusionLightColor(IntEnum): + Black = 0 + Red = 1 + Green = 2 + Yellow = 3 + Blue = 4 + Orange = 5 + Purple = 6 + White = 7 + Random = 8 + +class IoneDirection(IntEnum): + Left2Right = 0 + Right2Left = 1 + Up2Down = 2 + Down2Up = 3 + Clockwise = 4 + AntiClockwise = 5 + + +@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 + +@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: + 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] + 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): + 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 = [ + [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 new file mode 100644 index 0000000..76089e9 --- /dev/null +++ b/fusion/device.py @@ -0,0 +1,165 @@ +import hid +import winreg + +from .datatypes import * + +class FusionDevice(): + VID = 0x1044 + PID = [0x7a39, 0x7a3a] + + #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: + buf = self.send_simple_command_and_recv(0x88) + return FusionLightData(FusionLightEffect(buf[3]), buf[4], buf[5], FusionLightColor(buf[6]), buf[7]) + + # this is needed to set the current working profile to custom too (set once only is enough) + def set_simple_light_effect(self, effect: FusionLightData) -> None: + buf = bytearray(9) + buf[1] = 0x8 + buf[3] = int(effect.fusion_effect) + buf[4] = effect.fusion_speed + buf[5] = effect.fusion_brightness + buf[6] = int(effect.fusion_color) + buf[7] = 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!') + buf = bytearray(9) + buf[1] = 0x12 + buf[3] = profile + buf[4] = 8 #why? + self.compute_simple_checksum(buf) + sent = self.h.send_feature_report(buf) + if sent == -1: + raise IOError(self.h.error()) + + data = pixels.to_bytes() + 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!') + 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) + 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] + +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 new file mode 100644 index 0000000..92e9dcf --- /dev/null +++ b/play.py @@ -0,0 +1,69 @@ +from fusion.datatypes import * +from fusion.device import get_device +from cv2.typing import MatLike +import sys, cv2 + +if len(sys.argv) < 2: + print(f'Usage: {__file__} [profile id]') + exit(-1) + +if len(sys.argv) >= 3: + try: + profile = int(sys.argv[3]) + if profile not in range(5): + raise TypeError() + except: + print('Invalid profile id given, defaulting to 1...') + profile = 1 +else: + profile = 1 + + +dev = get_device() + +#change to profile for the image stream first +dev.set_simple_light_effect(FusionLightData(int(FusionLightEffect.Custom1) + profile - 1, 0, 255, FusionLightColor.Random, 0)) + + +def set_image(raw: MatLike) -> None: + pre = cv2.resize(raw, (19, 6), interpolation=cv2.INTER_AREA) + im = cv2.cvtColor(pre, cv2.COLOR_BGR2RGB) + + #display what's supposed to be encoded in the keyboard at this frame + cv2.imshow('preview', cv2.resize(pre, (190*5, 60*5))) + cv2.waitKey(1) + + pixels = [] + + h, w, _ = im.shape + + for y in range(h): + for x in range(w): + pixels.append(RGB(*im[y, x])) + + dev.set_custom_light_effect(profile, PictureMatrix.pixel_matrix_to_keys(pixels)) + + + +#apparently also works with static images +vid = cv2.VideoCapture(sys.argv[1]) + +#skip some frames so it's not too slow +interval = vid.get(cv2.CAP_PROP_FPS) // 4 + +count = 0 +while vid.isOpened(): + ret, frame = vid.read() + if ret: + set_image(frame) + count += interval + else: + vid.release() + break + +#keep last frame until we manually quit +cv2.waitKey(0) + + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ece54bd --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +hidapi \ No newline at end of file diff --git a/test.py b/test.py new file mode 100644 index 0000000..652f5a2 --- /dev/null +++ b/test.py @@ -0,0 +1,23 @@ +from fusion.device import get_device +from fusion.datatypes import * + +dev = get_device() +print(dev.get_version_string()) + + +#set simple effect +# print(dev.set_custom_light_effect(1, mat)) +# print(dev.set_simple_light_effect(FusionLightData(FusionLightEffect.Custom1, 1, 255//2, FusionLightColor.Random, 4))) + +#get and set to verify if get is correctly implemented +# load = PictureMatrix.from_bytes(dev.get_custom_light_effect(1)) +# print(load) +# dev.set_custom_light_effect(1, load) + +#manually set color +# mat = PictureMatrix([RGB(255, 0, 0) for i in range(105)]) +# dev.set_custom_light_effect(1, mat) + +#test reset +print(dev.reset()) +print(dev.set_simple_light_effect(FusionLightData(FusionLightEffect.Static, 1, 255, FusionLightColor.White, 4))) \ No newline at end of file