Page Menu
Home
desp's stash
Search
Configure Global Search
Log In
Files
F229849
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
16 KB
Subscribers
None
View Options
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
Details
Attached
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
Attached To
rAERO GIGABYTE Aero Fusion controller lib
Event Timeline
Log In to Comment