import os import sys import time import logging import threading import ctypes import hid from PIL import Image, ImageDraw, ImageFont import wx from wx.adv import TaskBarIcon ctypes.windll.shcore.SetProcessDpiAwareness(2) MODEL = "Ninjutso Sora V2" VID = 0x1915 PID_WIRELESS = 0xAE8C PID_WIRED = 0xAE12 USAGE_PAGE = 0xFFA0 # Colors RED = (255, 0, 0) GREEN = (71, 255, 12) BLUE = (91, 184, 255) YELLOW = (255, 255, 0) # Settings poll_rate = 60 foreground_color = BLUE background_color = (0, 0, 0, 0) font = "consola.ttf" notification_threshold = 20 # Battery percentage threshold for notification logging.basicConfig(level=logging.INFO) def get_resource(relative_path): try: base_path = sys._MEIPASS except Exception: base_path = os.path.abspath(".") return os.path.join(base_path, relative_path) def get_device_path(device_list, usage_page): for device in device_list: if device['usage_page'] == usage_page: return device['path'] def get_device_list(): device_list = hid.enumerate(VID, PID_WIRELESS) if not device_list: device_list = hid.enumerate(VID, PID_WIRED) if not device_list: raise RuntimeError(f"The specified device ({VID:X}:{PID_WIRELESS:X} or {VID:X}:{PID_WIRED:X}) cannot be found.") return device_list def get_battery(): try: device_list = get_device_list() except RuntimeError as e: logging.error(e) return None path = get_device_path(device_list, USAGE_PAGE) logging.info(f"Device path: {path}") device = hid.device() device.open_path(path) report = [0] * 32 report[0] = 5 # Report ID report[1] = 21 report[4] = 1 logging.info(f"Sending report: {report}") device.send_feature_report(report) time.sleep(0.09) res = device.get_feature_report(5, 32) logging.info(f"Received report: {res}") device.close() battery = res[9] charging = res[10] full_charge = res[11] online = res[12] logging.info(f"Battery: {battery}") logging.info(f"Charging: {charging}") logging.info(f"Full_charge: {full_charge}") logging.info(f"Online: {online}") return battery, charging, full_charge, online def create_icon(text: str, color, font_path): def PIL2wx(image): return wx.Bitmap.FromBufferRGBA(image.width, image.height, image.tobytes()) # Use high resolution base image base_size = 350 # Large canvas for crisp text image = Image.new("RGBA", (base_size, base_size), background_color) draw = ImageDraw.Draw(image) # Font size configuration size_config = { 1: 400, # Single digit size 2: 340, # Two digits size 3: 280 # Three digits size } font_size = size_config.get(len(text), 280) try: font = ImageFont.truetype(get_resource(font_path), font_size) except IOError: font = ImageFont.load_default() logging.warning("Using fallback font") # Precise text positioning using textbbox bbox = draw.textbbox((0, 0), text, font=font, anchor="mm") x = base_size // 2 y = (base_size - (bbox[3] - bbox[1])) // 2 - bbox[1] # Draw text with shadow for better visibility draw.text((x, y), text, font=font, fill=color, anchor="mm") # Convert directly without resizing return PIL2wx(image) # Define the custom directory and file name SAVE_DIR = r"C:\Users\marcn\AppData\Roaming\Macro Deck\plugins\Miranto.SoraV2" # Change this to your preferred location SAVE_FILE = os.path.join(SAVE_DIR, "battery_log.txt") def save_battery_to_file(battery): """Save battery level to a text file.""" try: if not os.path.exists(SAVE_DIR): os.makedirs(SAVE_DIR) # Create the directory if it doesn't exist with open(SAVE_FILE, "w") as file: file.write(str(battery)) # Write only the number print(f"Battery level saved to {SAVE_FILE}") except Exception as e: print(f"Error saving battery level: {e}") class MyTaskBarIcon(TaskBarIcon): def __init__(self, frame): super().__init__() self.frame = frame self.Bind(wx.adv.EVT_TASKBAR_LEFT_DOWN, self.OnClick) def CreatePopupMenu(self): menu = wx.Menu() item_settings = wx.MenuItem(menu, wx.ID_ANY, "Settings") self.Bind(wx.EVT_MENU, self.OnTaskBarActivate, id=item_settings.GetId()) item_exit = wx.MenuItem(menu, wx.ID_ANY, "Exit") self.Bind(wx.EVT_MENU, self.OnTaskBarExit, id=item_exit.GetId()) # menu.Append(item_settings) menu.Append(item_exit) return menu def OnTaskBarActivate(self, event): if not self.frame.IsShown(): self.frame.Show() def OnTaskBarExit(self, event): self.Destroy() self.frame.Destroy() def OnClick(self, event): if self.frame.battery_str == "Zzz" or self.frame.battery_str == "-": self.frame.show_battery() class MyFrame(wx.Frame): def __init__(self, parent, title): super().__init__(parent, title=title, pos=(-1, -1), size=(290, 280)) self.SetSize((350, 250)) self.tray_icon = MyTaskBarIcon(self) self.tray_icon.SetIcon(create_icon(" ", foreground_color, font), "") self.battery_str = "" self.Bind(wx.EVT_CLOSE, self.OnClose) self.Centre() self.notification = wx.adv.NotificationMessage(title=MODEL, message="Charged 100%") self.notification.SetFlags(wx.ICON_INFORMATION) self.notification.UseTaskBarIcon(self.tray_icon) self.animation_thread = threading.Thread(target=self.charge_animation, daemon=True) self.thread = threading.Thread(target=self.thread_worker, daemon=True) self.thread.start() def OnClose(self, event): if self.IsShown(): self.Hide() def thread_worker(self): self.fullcharged = False while True: self.show_battery() if self.battery_str == "-" or self.battery_str == "Zzz": time.sleep(1) else: time.sleep(poll_rate) def show_battery(self): result = get_battery() if result is None: self.stop_animation = True self.battery_str = "-" if self.animation_thread.is_alive(): self.animation_thread.join() self.tray_icon.SetIcon(create_icon(self.battery_str, foreground_color, font), MODEL) return battery, charging, full_charge, online = result # Save battery level to file save_battery_to_file(battery) if charging: self.fullcharged = False self.stop_animation = False if not self.animation_thread.is_alive(): self.animation_thread.start() return if full_charge: if not self.fullcharged: self.fullcharged = True self.notification.Show(timeout=wx.adv.NotificationMessage.Timeout_Auto) self.stop_animation = True if self.animation_thread.is_alive(): self.animation_thread.join() self.tray_icon.SetIcon(wx.Icon(get_resource(R".\icons\battery_100_green.ico")), MODEL) return if not online or battery == 0: self.fullcharged = False self.stop_animation = True self.battery_str = "Zzz" if self.animation_thread.is_alive(): self.animation_thread.join() self.tray_icon.SetIcon(create_icon(self.battery_str, foreground_color, font), MODEL) return if battery == 100: self.fullcharged = False self.stop_animation = True self.battery_str = str(battery) if self.animation_thread.is_alive(): self.animation_thread.join() self.tray_icon.SetIcon(wx.Icon(get_resource(R".\icons\battery_100.ico")), MODEL) return if battery <= notification_threshold: self.notification.SetMessage(f"Battery Low: {battery}%") self.notification.Show(timeout=wx.adv.NotificationMessage.Timeout_Auto) self.fullcharged = False self.stop_animation = True self.battery_str = str(battery) if self.animation_thread.is_alive(): self.animation_thread.join() self.tray_icon.SetIcon(create_icon(self.battery_str, foreground_color, font), MODEL) def charge_animation(self): while not self.stop_animation: self.tray_icon.SetIcon(wx.Icon(get_resource(R".\icons\battery_0.ico")), MODEL) time.sleep(0.5) self.tray_icon.SetIcon(wx.Icon(get_resource(R".\icons\battery_50.ico")), MODEL) time.sleep(0.5) self.tray_icon.SetIcon(wx.Icon(get_resource(R".\icons\battery_100.ico")), MODEL) time.sleep(0.5) class MyApp(wx.App): def OnInit(self): frame = MyFrame(None, title='Sora Tray settings') frame.Show(False) self.SetTopWindow(frame) return True def main(): app = MyApp() app.MainLoop() if __name__ == "__main__": main()