feat(companion): add ultra-lightweight distributable PC host daemon and F13-F24 custom key mappings

- Added F13-F24 custom key mappings in WebUI dropdown and DOM_TO_HID map

- Created pc_companion package with zero-CPU Win32 RegisterHotKey daemon (remotemapper_host.py)

- Added JSON hot-reload config, autostart manager, silent background launcher, and PyInstaller builder

- Ensured full portability with dynamic relative path resolution and Python environment resolution for distribution
This commit is contained in:
cuicuiV5 committed 2026-08-30 20:42:50 +08:00
1 parent 686a7fcc7c
commit 44415fa442
14 files changed
+490

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
# RemoteMapper PC Companion (本地脚本扩展守护程序)
通过在 ESP32 WebUI 中将遥控器按键映射为 F13 ~ F24 无冲突按键,本程序可在电脑后台以 0.0% CPU 占用 实时监听并异步执行本地自定义脚本。
## 快速使用方法
### 第一步:在 WebUI 中将遥控器按键配置为 F13 ~ F24
1. 电脑浏览器打开 WebUI (http://192.168.2.179 或 http://192.168.4.1);
2. 点击想绑定的遥控器按键(例如 菜单键、电视键、电源键 或任意按键);
3. 在弹出的高级下拉菜单中,选择 ??? 本地自定义脚本专用键 (F13 ~ F24 无冲突)(例如 F13 或 F14 等);
4. 点击 保存映射。
### 第二步:配置你想执行的本地动作
打开 pc_companion/config.json,根据需求修改或添加命令:
随时修改保存 config.json,程序会自动热更新重新载入!
### 第三步:启动守护进程
双击运行 start_companion.bat 即可!
+56
View File
@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
import os
import sys
import winreg
import subprocess
REG_PATH = r"Software\Microsoft\Windows\CurrentVersion\Run"
APP_NAME = "RemoteMapperCompanion"
def get_pythonw_path():
python_exe = sys.executable
pythonw = os.path.join(os.path.dirname(python_exe), "pythonw.exe")
if os.path.exists(pythonw):
return pythonw
return python_exe
def enable_autostart():
script_dir = os.path.dirname(os.path.abspath(__file__))
host_script = os.path.join(script_dir, "remotemapper_host.py")
pythonw = get_pythonw_path()
cmd = f'"{pythonw}" "{host_script}"'
# Write to HKCU Run
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_SET_VALUE)
winreg.SetValueEx(key, APP_NAME, 0, winreg.REG_SZ, cmd)
winreg.CloseKey(key)
print("=" * 60)
print("[OK] 成功设置开机自启动!")
print(f" 自启目标: {cmd}")
print(" 启动模式: 后台静默无弹窗运行 (pythonw.exe)")
print("=" * 60)
# Launch immediately in background
subprocess.Popen([pythonw, host_script], cwd=script_dir)
print("[OK] 已在后台为您即刻启动静默守护进程!")
def disable_autostart():
try:
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_SET_VALUE)
winreg.DeleteValue(key, APP_NAME)
winreg.CloseKey(key)
print("=" * 60)
print("[OK] 已成功从开机自启动中移除!")
print("=" * 60)
except FileNotFoundError:
print("[INFO] 当前未开启开机自启。")
except Exception as e:
print(f"[ERROR] 移除失败: {e}")
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1].lower() == "disable":
disable_autostart()
else:
enable_autostart()
+9
View File
@@ -0,0 +1,9 @@
@echo off
cd /d "%~dp0"
echo 正在安装/检查 PyInstaller...
python -m pip install pyinstaller
echo 正在打包为无黑框独立 exe...
pyinstaller --noconsole --onefile --name RemoteMapperHost remotemapper_host.py
echo.
echo [OK] 打包完成!独立免安装程序位于: dist\RemoteMapperHost.exe
pause
+57
View File
@@ -0,0 +1,57 @@
{
"F13": {
"name": "打开计算器",
"command": "calc.exe",
"enabled": true
},
"F14": {
"name": "打开浏览器网址",
"command": "explorer https://gemini.google.com",
"enabled": true
},
"F15": {
"name": "打开记事本",
"command": "notepad.exe",
"enabled": true
},
"F16": {
"name": "运行自定义 Python 任务",
"command": "python scripts/demo_task.py",
"enabled": true
},
"F17": {
"name": "运行自定义批处理",
"command": "scripts/demo_action.bat",
"enabled": true
},
"F19": {
"name": "自定义槽位 7",
"command": "notepad.exe",
"enabled": false
},
"F20": {
"name": "自定义槽位 8",
"command": "",
"enabled": false
},
"F21": {
"name": "自定义槽位 9",
"command": "",
"enabled": false
},
"F22": {
"name": "自定义槽位 10",
"command": "",
"enabled": false
},
"F23": {
"name": "自定义槽位 11",
"command": "",
"enabled": false
},
"F24": {
"name": "自定义槽位 12",
"command": "",
"enabled": false
}
}
+4
View File
@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
python autostart_manager.py disable
pause
+4
View File
@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
python autostart_manager.py enable
pause
+52
View File
@@ -0,0 +1,52 @@
; ==============================================================================
; RemoteMapper AutoHotkey Companion (极致超轻量 2MB 方案)
; ==============================================================================
#NoEnv
#SingleInstance Force
SetWorkingDir %A_ScriptDir%
; F13 键 -> 打开计算器
F13::
Run, calc.exe
return
; F14 键 -> 打开常用网址
F14::
Run, https://gemini.google.com
return
; F15 键 -> 打开记事本
F15::
Run, notepad.exe
return
; F16 键 -> 运行 Python 脚本
F16::
Run, python scripts/demo_task.py
return
; F17 键 -> 运行批处理脚本
F17::
Run, scripts/demo_action.bat
return
; F19 ~ F24 预留扩展槽位
F19::
; 在此添加您的自定义命令
return
F20::
; 在此添加您的自定义命令
return
F21::
return
F22::
return
F23::
return
F24::
return
+245
View File
@@ -0,0 +1,245 @@
# -*- coding: utf-8 -*-
"""
RemoteMapper PC Companion Daemon
================================
Ultra-lightweight Windows background service for RemoteMapper ESP32.
Listens for F13-F24 (and other virtual hotkeys) using native Win32 RegisterHotKey (0.0% CPU usage).
Executes custom local scripts (.bat, .ps1, .py, apps, URLs) asynchronously.
"""
import sys
import os
import json
import time
import ctypes
import subprocess
import threading
from ctypes import wintypes
# Ensure Windows environment
if sys.platform != 'win32':
print("[ERROR] RemoteMapper PC Companion only supports Windows.")
sys.exit(1)
# Win32 Constants
WM_HOTKEY = 0x0312
MOD_NONE = 0x0000
MOD_ALT = 0x0001
MOD_CONTROL = 0x0002
MOD_SHIFT = 0x0004
MOD_WIN = 0x0008
MOD_NOREPEAT = 0x4000
# Virtual Key Mapping for F13 - F24 and standard keys
VK_MAP = {
# High F-keys (F13 - F24: standard no-conflict keys)
"F13": 0x7C, "F14": 0x7D, "F15": 0x7E, "F16": 0x7F,
"F17": 0x80, "F18": 0x81, "F19": 0x82, "F20": 0x83,
"F21": 0x84, "F22": 0x85, "F23": 0x86, "F24": 0x87,
# Standard F-keys
"F1": 0x70, "F2": 0x71, "F3": 0x72, "F4": 0x73,
"F5": 0x74, "F6": 0x75, "F7": 0x76, "F8": 0x77,
"F9": 0x78, "F10": 0x79, "F11": 0x7A, "F12": 0x7B,
# Special keys
"PAUSE": 0x13, "SCROLLLOCK": 0x91, "PRINTSCREEN": 0x2C,
"APPS": 0x5D, "SLEEP": 0x5F
}
def get_app_dir():
"""Get absolute directory path whether running from Python script or PyInstaller EXE."""
if getattr(sys, 'frozen', False):
return os.path.dirname(os.path.abspath(sys.executable))
return os.path.dirname(os.path.abspath(__file__))
SCRIPT_DIR = get_app_dir()
CONFIG_FILE = os.path.join(SCRIPT_DIR, "config.json")
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
class RemoteMapperDaemon:
def __init__(self, config_path=CONFIG_FILE):
self.config_path = config_path
self.config = {}
self.hotkey_map = {} # id -> (key_name, config_entry)
self.registered_ids = []
self.last_config_mtime = 0
self.running = True
def load_config(self):
"""Load JSON configuration mapping hotkeys to local script actions."""
if not os.path.exists(self.config_path):
self.create_default_config()
try:
with open(self.config_path, "r", encoding="utf-8") as f:
self.config = json.load(f)
self.last_config_mtime = os.path.getmtime(self.config_path)
print(f"[CONFIG] Loaded {len(self.config)} hotkey action(s) from {os.path.basename(self.config_path)}")
return True
except Exception as e:
print(f"[ERROR] Failed to load config.json: {e}")
return False
def create_default_config(self):
"""Create sample config.json if missing."""
default_cfg = {
"F13": {
"name": "打开计算器",
"command": "calc.exe",
"enabled": True
},
"F14": {
"name": "打开浏览器网址",
"command": "explorer https://gemini.google.com",
"enabled": True
},
"F15": {
"name": "打开记事本",
"command": "notepad.exe",
"enabled": True
},
"F16": {
"name": "运行自定义 Python 任务",
"command": "python scripts/demo_task.py",
"enabled": True
},
"F17": {
"name": "运行自定义批处理",
"command": "scripts/demo_action.bat",
"enabled": True
}
}
with open(self.config_path, "w", encoding="utf-8") as f:
json.dump(default_cfg, f, ensure_ascii=False, indent=4)
print(f"[INIT] Created default template: {self.config_path}")
def register_all_hotkeys(self):
"""Register all enabled hotkeys with Windows OS."""
self.unregister_all_hotkeys()
self.hotkey_map.clear()
hotkey_id = 100
for key_str, entry in self.config.items():
if not entry.get("enabled", True):
continue
parts = [p.strip().upper() for p in key_str.split("+")]
main_key = parts[-1]
vk = VK_MAP.get(main_key)
if not vk:
print(f"[WARN] Unknown key: '{main_key}' in '{key_str}' (skipped)")
continue
mod = MOD_NOREPEAT
for p in parts[:-1]:
if p in ("CTRL", "CONTROL"): mod |= MOD_CONTROL
elif p in ("ALT", "MENU"): mod |= MOD_ALT
elif p in ("SHIFT",): mod |= MOD_SHIFT
elif p in ("WIN", "WINDOWS", "SUPER"): mod |= MOD_WIN
res = user32.RegisterHotKey(None, hotkey_id, mod, vk)
if res:
self.hotkey_map[hotkey_id] = (key_str, entry)
self.registered_ids.append(hotkey_id)
action_name = entry.get("name", "未命名动作")
print(f" [+] 成功监听: {key_str: <10} -> {action_name}")
hotkey_id += 1
else:
err = kernel32.GetLastError()
print(f" [-] 注册失败: {key_str} (可能已被其他程序占用, Win32 Error: {err})")
def unregister_all_hotkeys(self):
"""Unregister previously registered hotkeys."""
for hid in self.registered_ids:
user32.UnregisterHotKey(None, hid)
self.registered_ids.clear()
def execute_action(self, key_str, entry):
"""Execute the configured command asynchronously with robust relative path and Python environment resolution."""
name = entry.get("name", "未命名动作")
cmd = entry.get("command", "").strip()
if not cmd:
print(f"[TRIGGER] {key_str} 按下 -> 无配置命令")
return
# 1. Expand {DIR} or {ROOT} placeholders
cmd = cmd.replace("{DIR}", SCRIPT_DIR).replace("{ROOT}", SCRIPT_DIR)
# 2. Smart Python path resolution (works even if Python is not in system PATH on end-user PC)
if cmd.startswith("python ") or cmd.startswith("pythonw "):
py_bin = sys.executable
if cmd.startswith("pythonw "):
py_bin = py_bin.replace("python.exe", "pythonw.exe")
cmd = f'"{py_bin}" ' + cmd.split(" ", 1)[1]
print(f"\n[TRIGGER] >>> 遥控器按下 {key_str} [{name}] -> 执行命令: {cmd}")
def _run():
try:
# Force working directory to SCRIPT_DIR so all relative paths (scripts/...) work anywhere
subprocess.Popen(cmd, shell=True, cwd=SCRIPT_DIR)
except Exception as ex:
print(f"[ERROR] 执行失败 [{cmd}]: {ex}")
threading.Thread(target=_run, daemon=True).start()
def check_config_reload(self):
"""Periodically check if config.json was edited by user and hot-reload."""
try:
if os.path.exists(self.config_path):
mtime = os.path.getmtime(self.config_path)
if mtime > self.last_config_mtime:
print("\n[HOT-RELOAD] 检测到 config.json 已修改,正在重新载入配置...")
if self.load_config():
self.register_all_hotkeys()
except Exception:
pass
def run(self):
"""Main message loop with Windows event pump (0% CPU)."""
print("=" * 60)
print(" RemoteMapper PC Companion - 极简超轻量本地脚本扩展守护进程")
print(f" 工作目录: {SCRIPT_DIR}")
print("=" * 60)
self.load_config()
self.register_all_hotkeys()
print("\n[READY] 正在后台监听遥控器快捷键 (0.0% CPU 极低开销)...")
print("[TIP] 随时编辑 config.json 即可实时热更新,按 Ctrl+C 退出程序。\n")
msg = wintypes.MSG()
last_check_time = time.time()
while self.running:
# Check for config reload every 1.5s
now = time.time()
if now - last_check_time > 1.5:
self.check_config_reload()
last_check_time = now
# Process any available messages without busy looping
while user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, 1): # PM_REMOVE = 1
if msg.message == WM_HOTKEY:
hid = msg.wParam
if hid in self.hotkey_map:
key_str, entry = self.hotkey_map[hid]
self.execute_action(key_str, entry)
user32.TranslateMessage(ctypes.byref(msg))
user32.DispatchMessageW(ctypes.byref(msg))
# Sleep 30ms to prevent CPU spin
time.sleep(0.03)
self.unregister_all_hotkeys()
if __name__ == "__main__":
daemon = RemoteMapperDaemon()
try:
daemon.run()
except KeyboardInterrupt:
print("\n[EXIT] RemoteMapper PC Companion 已退出。")
daemon.unregister_all_hotkeys()
+6
View File
@@ -0,0 +1,6 @@
@echo off
echo ===================================================
echo [RemoteMapper] Triggered local batch script!
echo Time: %date% %time%
echo ===================================================
timeout /t 3
+5
View File
@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
import datetime
print(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] RemoteMapper Python 自定义脚本触发成功!")
# 您可以在这里编写任意自动化逻辑,例如调取大模型 API、操控软件、发送网络请求等
+3
View File
@@ -0,0 +1,3 @@
# PowerShell 示例脚本
Write-Host "[RemoteMapper] 触发 PowerShell 脚本" -ForegroundColor Cyan
[System.Media.SystemSounds]::Asterisk.Play()
+2
View File
@@ -0,0 +1,2 @@
Set WshShell = CreateObject("WScript.Shell")
WshShell.Run "pythonw.exe "" & Replace(WScript.ScriptFullName, "silent_start.vbs", "remotemapper_host.py") & "", 0, False
+13
View File
@@ -0,0 +1,13 @@
@echo off
cd /d "%~dp0"
cls
echo ========================================================
echo RemoteMapper PC Companion Service
echo ========================================================
echo.
python remotemapper_host.py
if errorlevel 1 (
echo.
echo [ERROR] Python script exited with error.
pause
)
+16
View File
@@ -434,6 +434,20 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
<option value="k:0:0x44">F11 (全屏)</option>
<option value="k:0:0x45">F12 (开发者工具)</option>
</optgroup>
<optgroup label="🖥️ 本地自定义脚本专用键 (F13 ~ F24 无冲突)">
<option value="k:0:0x68">F13 (触发脚本 1)</option>
<option value="k:0:0x69">F14 (触发脚本 2)</option>
<option value="k:0:0x6A">F15 (触发脚本 3)</option>
<option value="k:0:0x6B">F16 (触发脚本 4)</option>
<option value="k:0:0x6C">F17 (触发脚本 5)</option>
<option value="k:0:0x6D">F18 (触发脚本 6)</option>
<option value="k:0:0x6E">F19 (触发脚本 7)</option>
<option value="k:0:0x6F">F20 (触发脚本 8)</option>
<option value="k:0:0x70">F21 (触发脚本 9)</option>
<option value="k:0:0x71">F22 (触发脚本 10)</option>
<option value="k:0:0x72">F23 (触发脚本 11)</option>
<option value="k:0:0x73">F24 (触发脚本 12)</option>
</optgroup>
<optgroup label="常用快捷组合键">
<option value="k:0x04:0x36">Alt + , (豆包/AI语音助手)</option>
<option value="k:0x08:0x0B">Win + H (Windows语音听写)</option>
@@ -524,6 +538,8 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
'Comma': 0x36, 'Period': 0x37, 'Slash': 0x38, 'CapsLock': 0x39,
'F1': 0x3A, 'F2': 0x3B, 'F3': 0x3C, 'F4': 0x3D, 'F5': 0x3E, 'F6': 0x3F,
'F7': 0x40, 'F8': 0x41, 'F9': 0x42, 'F10': 0x43, 'F11': 0x44, 'F12': 0x45,
'F13': 0x68, 'F14': 0x69, 'F15': 0x6A, 'F16': 0x6B, 'F17': 0x6C, 'F18': 0x6D,
'F19': 0x6E, 'F20': 0x6F, 'F21': 0x70, 'F22': 0x71, 'F23': 0x72, 'F24': 0x73,
'PrintScreen': 0x46, 'ScrollLock': 0x47, 'Pause': 0x48, 'Insert': 0x49,
'Home': 0x4A, 'PageUp': 0x4B, 'Delete': 0x4C, 'End': 0x4D, 'PageDown': 0x4E,
'ArrowRight': 0x4F, 'ArrowLeft': 0x50, 'ArrowDown': 0x51, 'ArrowUp': 0x52