Page MenuHomedesp's stash

No OneTemporary

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__} <image/video path> [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

File Metadata

Mime Type
text/x-diff
Expires
Tue, Apr 15, 2:27 AM (21 h, 47 m)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
7a/59/2565f3bd69a288b9d722302c22e5

Event Timeline