commit 9d77f54bfeae5b5e9c484fe32e12a8fedbd8fd6d Author: Reaper Date: Fri Jun 19 15:51:23 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a17732e --- /dev/null +++ b/.gitignore @@ -0,0 +1,364 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp directory +.temp + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# pnpm +.pnpm-store + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +*.lcov +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule* +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..454e806 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# dependencies + +### make python VENV + +`python -m venv venv` + +### activate VENV + +`source venv/bin/activate` + +### install dependencies + +`pip install evdev websockets` + +### run the script (inside VENV) + +`python input.py` + +### Important note for wayland. + +make sure to change your input access rules. + +`sudo usermod -aG input $USER` + +then reboot for this to take effect. + +### Node backend dependencies + +`npm install` + diff --git a/generate_keymap.py b/generate_keymap.py new file mode 100644 index 0000000..f2eef4b --- /dev/null +++ b/generate_keymap.py @@ -0,0 +1,36 @@ +from evdev import InputDevice, ecodes +import json + +DEVICE_PATH = "/dev/input/event8" + +device = InputDevice(DEVICE_PATH) + +print("Press keys... Ctrl+C to stop") +print("Generating keymap from live input...\n") + +keymap = {} + +try: + for event in device.read_loop(): + + if event.type != ecodes.EV_KEY: + continue + + if event.value != 1: + continue + + key_name = ecodes.KEY.get(event.code, f"UNKNOWN_{event.code}") + + clean = key_name.replace("KEY_", "").title() + + print(f"{event.code} -> {clean}") + + keymap[event.code] = clean + +except KeyboardInterrupt: + pass + +with open("keymap.json", "w") as f: + json.dump(keymap, f, indent=4) + +print("\nSaved to keymap.json") \ No newline at end of file diff --git a/input.py b/input.py new file mode 100644 index 0000000..eda4577 --- /dev/null +++ b/input.py @@ -0,0 +1,97 @@ +from evdev import InputDevice, ecodes +import asyncio +import websockets +import json +import threading + +# ---------------------------- +# CONFIG +# ---------------------------- +DEVICE_PATH = "/dev/input/event8" +WS_HOST = "0.0.0.0" +WS_PORT = 8765 + +device = InputDevice(DEVICE_PATH) + +clients = set() + +# ---------------------------- +# KEY MAPPING +# ---------------------------- +import json + +with open("keymap.json", "r") as f: + KEY_MAP = json.load(f) +# ---------------------------- +# WEBSOCKET HANDLER +# ---------------------------- +async def handler(websocket): + print("Client connected") + clients.add(websocket) + + try: + await websocket.wait_closed() + finally: + clients.remove(websocket) + print("Client disconnected") + +# ---------------------------- +# BROADCAST FUNCTION +# ---------------------------- +async def broadcast(msg): + if clients: + await asyncio.gather( + *[c.send(msg) for c in clients], + return_exceptions=True + ) + +# ---------------------------- +# INPUT LOOP +# ---------------------------- +def input_loop(loop): + print("Input loop started") + + for event in device.read_loop(): + + if event.type != ecodes.EV_KEY: + continue + + if event.value not in (0, 1): + continue + + key_name = KEY_MAP.get(str(event.code), "").upper() + + payload = json.dumps({ + "type": "keydown" if event.value == 1 else "keyup", + "key": key_name + }) + + print("send:", payload) + + asyncio.run_coroutine_threadsafe( + broadcast(payload), + loop + ) + +# ---------------------------- +# MAIN +# ---------------------------- +async def main(): + print(f"Starting WS server on ws://{WS_HOST}:{WS_PORT}") + + loop = asyncio.get_running_loop() + + server = await websockets.serve(handler, WS_HOST, WS_PORT) + + print("WS server ready") + + threading.Thread( + target=input_loop, + args=(loop,), + daemon=True + ).start() + + await server.wait_closed() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/keymap.json b/keymap.json new file mode 100644 index 0000000..5c15898 --- /dev/null +++ b/keymap.json @@ -0,0 +1,105 @@ +{ + "1": "Esc", + "59": "F1", + "60": "F2", + "61": "F3", + "62": "F4", + "63": "F5", + "64": "F6", + "65": "F7", + "66": "F8", + "67": "F9", + "68": "F10", + "87": "F11", + "88": "F12", + "99": "Sysrq", + "70": "Scrolllock", + "119": "Pause", + "41": "Grave", + "2": "1", + "3": "2", + "4": "3", + "5": "4", + "6": "5", + "7": "6", + "8": "7", + "9": "8", + "10": "9", + "11": "0", + "12": "Minus", + "13": "Equal", + "14": "Backspace", + "110": "Insert", + "102": "Home", + "104": "Pageup", + "69": "Numlock", + "98": "Kpslash", + "55": "Kpasterisk", + "74": "Kpminus", + "15": "Tab", + "16": "Q", + "17": "W", + "18": "E", + "19": "R", + "20": "T", + "21": "Y", + "22": "U", + "23": "I", + "24": "O", + "25": "P", + "26": "Leftbrace", + "27": "Rightbrace", + "43": "Backslash", + "111": "Delete", + "107": "End", + "109": "Pagedown", + "71": "Kp7", + "72": "Kp8", + "73": "Kp9", + "78": "Kpplus", + "58": "Capslock", + "30": "A", + "31": "S", + "32": "D", + "33": "F", + "34": "G", + "35": "H", + "36": "J", + "37": "K", + "38": "L", + "39": "Semicolon", + "40": "Apostrophe", + "28": "Enter", + "75": "Kp4", + "76": "Kp5", + "77": "Kp6", + "42": "Leftshift", + "44": "Z", + "45": "X", + "46": "C", + "47": "V", + "48": "B", + "49": "N", + "50": "M", + "51": "Comma", + "52": "Dot", + "53": "Slash", + "54": "Rightshift", + "103": "Up", + "79": "Kp1", + "80": "Kp2", + "81": "Kp3", + "96": "Kpenter", + "29": "Leftctrl", + "125": "Leftmeta", + "56": "Leftalt", + "57": "Space", + "100": "Rightalt", + "126": "Rightmeta", + "97": "Rightctrl", + "105": "Left", + "108": "Down", + "106": "Right", + "82": "Kp0", + "83": "Kpdot" +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..619b843 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "obs-keyboard-overlay", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "express": "^5.2.1", + "ws": "^8.21.0" + } +} diff --git a/public/Tux.svg b/public/Tux.svg new file mode 100644 index 0000000..6b558e7 --- /dev/null +++ b/public/Tux.svg @@ -0,0 +1,438 @@ + + + Tux + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..bb1ec03 --- /dev/null +++ b/public/index.html @@ -0,0 +1,299 @@ + + + + + + + + + +
+
+
ESC
+
+
F1
+
F2
+
F3
+
F4
+
+
F5
+
F6
+
F7
+
F8
+
+
F9
+
F10
+
F11
+
F12
+
+
+ +
+
~
+
`
+
+ +
+
!
+
1
+
+ +
+
@
+
2
+
+ +
+
#
+
3
+
+ +
+
$
+
4
+
+ +
+
%
+
5
+
+ +
+
^
+
6
+
+ +
+
&
+
7
+
+ +
+
*
+
8
+
+ +
+
(
+
9
+
+ +
+
)
+
0
+
+ +
+
_
+
-
+
+ +
+
+
+
=
+
+ +
+
BACKSPACE
+
+
+
+ +
+
TAB
+
+ +
+
Q
+
+ +
+
W
+
+ +
+
E
+
+ +
+
R
+
+ +
+
T
+
+ +
+
Y
+
+ +
+
U
+
+ +
+
I
+
+ +
+
O
+
+ +
+
P
+
+ +
+
{
+
[
+
+ +
+
}
+
]
+
+ +
+
|
+
\
+
+
+
+ +
+
CAPSLOCK
+
+ +
+
A
+
+ +
+
S
+
+ +
+
D
+
+ +
+
F
+
+ +
+
G
+
+ +
+
H
+
+ +
+
J
+
+ +
+
K
+
+ +
+
L
+
+ +
+
:
+
;
+
+ +
+
"
+
'
+
+ +
+
ENTER
+
+
+
+ +
+
SHIFT
+
+ +
+
Z
+
+ +
+
X
+
+ +
+
C
+
+ +
+
V
+
+ +
+
B
+
+ +
+
N
+
+ +
+
M
+
+ +
+
<
+
,
+
+ +
+
>
+
.
+
+ +
+
?
+
/
+
+ +
+
SHIFT
+
+
+
+
+
CTRL
+
+
+ +
+
+
ALT
+
+
+ +
+
ALT
+
+ +
+
FN
+
+
+
CTRL
+
+
+
+ + + + \ No newline at end of file diff --git a/public/keymap.json b/public/keymap.json new file mode 100644 index 0000000..0fa3d24 --- /dev/null +++ b/public/keymap.json @@ -0,0 +1,30 @@ +{ + "1": "Esc", + "15": "Tab", + "16": "Q", + "17": "W", + "18": "E", + "19": "R", + "20": "T", + "21": "Y", + "30": "A", + "31": "S", + "32": "D", + "33": "F", + "34": "G", + "35": "H", + "44": "Z", + "46": "C", + "47": "V", + "50": "M", + "2": "1", + "3": "2", + "4": "3", + "5": "4", + "6": "5", + "29": "Leftctrl", + "42": "Leftshift", + "56": "Leftalt", + "57": "Space", + "45": "X" +} \ No newline at end of file diff --git a/public/overlay.js b/public/overlay.js new file mode 100644 index 0000000..7dc2b2e --- /dev/null +++ b/public/overlay.js @@ -0,0 +1,28 @@ +const ws = new WebSocket("ws://" + location.hostname + ":3000"); + +ws.addEventListener("message", (event) => { + const { type, key } = JSON.parse(event.data); + if (!key) return; + + const el = document.getElementById(key); + if (!el) return; + + if (type === "keydown") { + el.classList.add("active"); + } else if (type === "keyup") { + el.classList.remove("active"); + } +}); + +ws.addEventListener("open", () => { + fetch("/keymap.json") + .then(r => r.json()) + .then(keymap => { + const mapped = new Set(Object.values(keymap).map(k => k.toUpperCase())); + document.querySelectorAll(".key").forEach(el => { + if (!mapped.has(el.id)) { + el.style.visibility = "hidden"; + } + }); + }); +}); \ No newline at end of file diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..1c5fc85 --- /dev/null +++ b/public/style.css @@ -0,0 +1,66 @@ +body { + margin: 0; + background: transparent; + font-family: sans-serif; +} + +.keyboard { + display: flex; + flex-direction: column; + gap: 6px; + padding: 20px; +} + +.row { + display: flex; + gap: 4px; +} + +.key { + height: 45px; + background: #222; + border: 2px solid #555; + border-radius: 6px; + position: relative; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + flex-shrink: 0; +} + +.active { + background: #00ff88; + box-shadow: 0 0 12px #00ff88; +} + +.w1 { width: 45px; } +.w1_25 { width: 57px; } +.w1_5 { width: 69px; } +.w1_75 { width: 81px; } +.w2 { width: 93px; } +.w2_25 { width: 105px; } +.w2_75 { width: 129px; } +.w6_25 { width: 289px; } + +.base { + position: absolute; + bottom: 4px; + right: 6px; + font-size: 11px; + color: white; +} + +.shift { + position: absolute; + top: 4px; + left: 6px; + font-size: 10px; + color: #bbb; +} + +.tux-icon { + width: 18px; + height: 18px; + position: absolute; +} \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..b78600a --- /dev/null +++ b/server.js @@ -0,0 +1,75 @@ +const express = require("express"); +const WebSocket = require("ws"); + +const app = express(); +app.use(express.static("public")); + +const PORT = 3000; + +// ---------------------------- +// HTTP SERVER (OBS overlay page) +// ---------------------------- +const server = app.listen(PORT, () => { + console.log(`OBS overlay running on http://localhost:${PORT}`); +}); + +// ---------------------------- +// OBS / BROWSER CLIENTS +// ---------------------------- +const wss = new WebSocket.Server({ server }); + +// ---------------------------- +// BROADCAST FUNCTION +// ---------------------------- +function broadcast(data) { + wss.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(data); + } + }); +} + +// ---------------------------- +// CONNECT TO PYTHON INPUT SERVER +// ---------------------------- +function connectToPython() { + console.log("Connecting to Python input server..."); + + const inputSocket = new WebSocket("ws://127.0.0.1:8765"); + + inputSocket.on("open", () => { + console.log("CONNECTED TO PYTHON"); + }); + + inputSocket.on("message", (msg) => { + const data = msg.toString(); + + // DEBUG + console.log("FROM PYTHON:", data); + + // forward to OBS overlay + broadcast(data); + }); + + inputSocket.on("error", (err) => { + console.log("PYTHON WS ERROR:", err.message); + }); + + inputSocket.on("close", () => { + console.log("PYTHON DISCONNECTED. Reconnecting in 2s..."); + setTimeout(connectToPython, 2000); + }); +} + +connectToPython(); + +// ---------------------------- +// OBS CLIENT CONNECTION LOGGING +// ---------------------------- +wss.on("connection", (ws) => { + console.log("OBS browser connected"); + + ws.on("close", () => { + console.log("OBS browser disconnected"); + }); +}); \ No newline at end of file