Files
Sora_tray/sora_tray.py
T
2026-09-08 22:42:28 +02:00

279 lines
9.3 KiB
Python

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()