Compare commits

...

38 Commits

Author SHA1 Message Date
522fc7c8c4 firmware: Disable performance monitor 2024-12-16 23:27:53 +00:00
f62f2414b0 controller: Fix sudden death outcome and add last round in half 2024-12-16 00:04:36 +00:00
f501dc046e controller: Improve shutdown handling 2024-12-15 23:34:39 +00:00
244a340764 controller: Add thread for HID handler to app 2024-12-15 17:41:07 +00:00
9543851867 firmware+controller: Add round start state 2024-12-15 17:30:08 +00:00
1caf43c6de firmware+controller: Add game start and over states 2024-12-15 15:36:52 +00:00
d9c063cba7 firmware+controller: Add generic in-game state 2024-12-15 14:37:31 +00:00
8adb89ef2b firmware+controller: Add pregame state 2024-12-15 13:57:31 +00:00
ea5f1a7902 firmware+controller: Add match found state 2024-12-15 12:05:36 +00:00
18831c3e0e firmware+controller: Queue start state 2024-12-14 16:26:34 +00:00
4d1074e60c firmware: Add repeating animations 2024-12-14 13:18:17 +00:00
514198d136 controller: Fix USB get_input_report on Windows 2024-12-13 17:57:49 +00:00
277eb4ee3b firmware+controller: Initial working HID state control 2024-12-13 17:51:46 +00:00
6d0db71305 firmware: Add menu and idle states 2024-12-13 16:32:53 +00:00
4fa183ab2d firmware: Finished (?) none state 2024-12-13 14:06:00 +00:00
76d8557f36 firmware: Semi-working emoji 2024-12-13 04:01:54 +00:00
facb46c068 firmware: Add second message to none state 2024-12-13 03:03:21 +00:00
e550c6d016 firmware: Call lv_disp_flush_ready() in vsync handler 2024-12-13 02:45:36 +00:00
84fb7553c5 firmware: Initial animation 2024-12-13 02:29:27 +00:00
0607f08b83 controller: Implement economy suggestions and add test 2024-12-12 23:07:29 +00:00
bb11a1607b controller: Initial state handling 2024-12-12 19:40:16 +00:00
78aa8005bd controller: Initial polling and parsing of Valorant presence 2024-12-12 17:12:13 +00:00
46cdc9cd43 controller: Initial Windows app 2024-12-12 14:19:20 +00:00
6de0451f33 controller: Add 24x24 icon 2024-12-12 13:51:50 +00:00
937cc46d80 controller: Add icon 2024-12-12 13:34:53 +00:00
68844ef4d3 controller: Tweak structure for Windows 2024-12-12 12:57:04 +00:00
b6f242a73a firmware: Add background image 2024-12-12 01:19:02 +00:00
8bdd1c018f firmware: Add Valorant font 2024-12-11 21:23:51 +00:00
680edbfd81 Update .envrc to use --impure 2024-12-11 20:01:30 +00:00
0cdaefc992 firmware: Move serial number code to main 2024-12-11 19:02:24 +00:00
4817c80df3 Initial working USB communication 2024-12-11 18:59:17 +00:00
a35f1cccc1 Update .envrc to hopefully differentiate between shells 2024-12-11 14:50:54 +00:00
1ff784bc3d Add flake.lock 2024-12-11 14:36:04 +00:00
475f5626dd firmware: Split LCD logic into separate file 2024-12-09 14:54:11 +00:00
768ee410d8 firmware+controller: Initial USB device 2024-12-09 13:41:22 +00:00
db683dbc71 firmware: Initial working touch control 2024-12-08 14:53:51 +00:00
1d5853e91c firmware: Actually working LCD with LVGL 2024-12-04 10:43:01 +00:00
df62eb0fe6 firmware: Attempt at working LVGL on LCD 2024-12-03 22:55:46 +00:00
40 changed files with 3155 additions and 41 deletions

2
controller/.envrc Normal file
View File

@@ -0,0 +1,2 @@
watch_file default.nix
use flake ..#controller --impure

5
controller/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
__pycache__/
/build/
/dist/
Valconomy.spec
.pytest_cache/

View File

@@ -0,0 +1 @@
3.13

91
controller/app.py Normal file
View File

@@ -0,0 +1,91 @@
import configparser
import logging
import os
import signal
import sys
import infi.systray
import pkg_resources # needed for pyinstaller?
import valconomy
DATA_DIR = os.path.join(os.environ['LOCALAPPDATA'], 'Valconomy')
LOG_FILE = os.path.join(DATA_DIR, 'app.log')
CONFIG_FILE = os.path.join(DATA_DIR, 'config.ini')
log = logging.getLogger('valconomy-app')
def parse_log_level(level: str) -> int:
match level.lower():
case 'debug':
return logging.DEBUG
case 'info':
return logging.INFO
case 'warning':
return logging.WARNING
case 'error':
return logging.ERROR
case _:
return logging.INFO
def data_file(fname: str) -> str:
return os.path.abspath(os.path.join(os.path.dirname(__file__), fname))
def do_open_datadir(tray: infi.systray.SysTrayIcon):
os.startfile(DATA_DIR)
def main():
os.makedirs(DATA_DIR, exist_ok=True)
if sys.stderr is None:
# running in console-less mode, redirect to log file
sys.stderr = open(LOG_FILE, 'a')
conf = configparser.ConfigParser()
conf.read_dict({
'general': {
'log_level': 'info',
'dummy_impl': False,
},
'valorant': {'player_uuid': ''},
})
conf.read(CONFIG_FILE)
with open(CONFIG_FILE, 'w') as f:
conf.write(f)
use_dummy_impl = conf.getboolean('general', 'dummy_impl')
log_level = parse_log_level(conf['general']['log_level'])
logging.basicConfig(
format='%(asctime)s %(name)s %(levelname)s %(message)s', level=log_level,
stream=sys.stderr)
log.info('Starting up')
if not conf['valorant']['player_uuid']:
log.error(f'No player UUID set, exiting...')
sys.exit(1)
if use_dummy_impl:
val_handler = valconomy.ValconomyHandler()
else:
val_handler = valconomy.HIDValconomyHandler()
val_handler.start()
val_sm = valconomy.ValconomyStateMachine(conf['valorant']['player_uuid'], val_handler)
val_client = valconomy.ValorantLocalClient(val_sm.handle_presence)
def do_quit(*args):
log.info('Shutting down')
val_client.stop()
if not use_dummy_impl:
val_handler.stop()
with infi.systray.SysTrayIcon(
data_file('icon.ico'), 'Valconomy', on_quit=do_quit,
menu_options=(
('Open data directory', None, do_open_datadir),
)) as tray:
signal.signal(signal.SIGINT, do_quit)
signal.signal(signal.SIGTERM, do_quit)
val_client.run()
if __name__ == '__main__':
main()

31
controller/default.nix Normal file
View File

@@ -0,0 +1,31 @@
{
perSystem = { libMy, pkgs, ... }: {
devenv.shells.controller = libMy.withRootdir {
languages = {
python = {
enable = true;
version = "3.13";
libraries = with pkgs; [
zlib # required by hidapi (?)
];
uv = {
enable = true;
sync.enable = true;
};
};
};
packages = with pkgs; [
imagemagick
];
env = { };
scripts = {
gen-icon.exec = ''
magick icon.png -resize 256x256 -define icon:auto-resize="256,128,96,64,48,32,24,16" icon.ico
'';
};
};
};
}

1
controller/dist.bat Normal file
View File

@@ -0,0 +1 @@
pyinstaller.exe -y --onedir --windowed --name Valconomy --icon icon.ico --add-data icon.ico:. app.py

47
controller/hid-test.py Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python3
import logging
import valconomy
from valconomy import ValorantPlayerInfo, RiotPlayerInfo, EconomyDecision
def main():
logging.basicConfig(
format='%(asctime)s %(name)s %(levelname)s %(message)s', level=logging.INFO)
h = valconomy.HIDValconomyHandler()
try:
h.menu(None, False)
h.none()
h.queue_start(RiotPlayerInfo.dummy(
valorant=ValorantPlayerInfo()))
h.menu(None, True)
h.idle(None)
h.queue_start(RiotPlayerInfo.dummy(
valorant=ValorantPlayerInfo(queue_type='unrated', is_party_owner=True)))
h.match_found(RiotPlayerInfo.dummy(valorant=ValorantPlayerInfo(queue_type='premier-seasonmatch')))
h.pregame(RiotPlayerInfo.dummy(valorant=ValorantPlayerInfo(map='/Game/Maps/Bonsai/Bonsai')))
h.match_found(RiotPlayerInfo.dummy(valorant=ValorantPlayerInfo()))
h.pregame(RiotPlayerInfo.dummy(valorant=ValorantPlayerInfo()))
h.game_generic(RiotPlayerInfo.dummy(valorant=ValorantPlayerInfo(queue_type='ggteam')))
h.game_start(None)
h.game_over(None, False)
h.game_over(None, True)
h.round_start(
RiotPlayerInfo.dummy(valorant=ValorantPlayerInfo(score=3, enemy_score=7)),
won=True, economy=EconomyDecision.SAVE)
h.round_start(
RiotPlayerInfo.dummy(valorant=ValorantPlayerInfo(score=10, enemy_score=2)),
won=False, economy=EconomyDecision.BUY)
h.round_start(
RiotPlayerInfo.dummy(valorant=ValorantPlayerInfo(score=10, enemy_score=2)),
won=None, economy=EconomyDecision.MATCH_TEAM)
h.round_start(
RiotPlayerInfo.dummy(valorant=ValorantPlayerInfo(score=2, enemy_score=0)),
won=True, economy=EconomyDecision.BONUS)
h.service()
finally:
h.close()
if __name__ == '__main__':
main()

BIN
controller/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
controller/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
controller/icon.xcf Normal file

Binary file not shown.

16
controller/pyproject.toml Normal file
View File

@@ -0,0 +1,16 @@
[project]
name = "valconomy"
version = "0.1.0"
description = "Valconomy controller"
requires-python = ">=3.13"
dependencies = [
"hidapi>=0.14.0.post4",
"infi-systray>=0.1.12; sys_platform == 'win32'",
"requests>=2.32.3",
]
[dependency-groups]
dev = [
"pyinstaller>=6.11.1",
"pytest>=8.3.4",
]

180
controller/test_sm.py Normal file
View File

@@ -0,0 +1,180 @@
import valconomy
from valconomy import ValorantPlayerInfo, RiotPlayerInfo, EconomyDecision
class DummyValHandler:
def __init__(self):
self.seq = []
def none(self):
self.seq.append('none')
def menu(self, info: RiotPlayerInfo, was_idle: bool=False):
self.seq.append(('menu', was_idle))
def idle(self, info: RiotPlayerInfo):
self.seq.append('idle')
def queue_start(self, info: RiotPlayerInfo):
self.seq.append(('queue_start', info.valorant.is_party_owner and info.valorant.queue_type == 'unrated'))
def match_found(self, info: RiotPlayerInfo):
self.seq.append(('match_found', info.valorant.queue_type == 'premier-seasonmatch'))
def pregame(self, info: RiotPlayerInfo):
self.seq.append(('pregame', info.valorant.map == '/Game/Maps/Bonsai/Bonsai'))
def game_generic(self, info: RiotPlayerInfo):
t = info.valorant.queue_type
if info.valorant.max_party_size == 12:
t = 'custom'
elif not t:
t = 'unknown'
self.seq.append(('game_generic', t))
def game_start(self, info: RiotPlayerInfo):
self.seq.append('game_start')
def round_start(self, info: RiotPlayerInfo, won: bool, economy: EconomyDecision):
self.seq.append(('round_start', won, info.valorant.score, info.valorant.enemy_score, economy))
def game_over(self, info: RiotPlayerInfo, won: bool):
self.seq.append(('game_over', won))
class TestSm:
def setup_method(self, method):
self.mock = DummyValHandler()
self.sm = valconomy.ValconomyStateMachine('dummy', self.mock)
def do(self, **kwargs):
gone = kwargs.pop('gone', False)
info = RiotPlayerInfo(kwargs.pop('uuid', 'dummy'), 'Team Player', 'gamer', 'unknown')
info.valorant = ValorantPlayerInfo(**kwargs)
self.sm.handle_presence(info, gone)
def test_comp_normal(self):
self.do(game_state='MENUS')
self.do(game_state='MENUS', queue_type='competitive')
for _ in range(2):
self.do(game_state='MENUS', party_state='MATCHMAKING', queue_type='competitive')
for _ in range(3):
self.do(game_state='MENUS', party_state='MATCHMADE_GAME_STARTING', queue_type='competitive')
self.do(queue_type='competitive', game_state='PREGAME', map='/Game/Maps/Bonsai/Bonsai')
self.do(queue_type='competitive', game_state='INGAME', map='/Game/Maps/Bonsai/Bonsai')
for _ in range(2):
self.do(queue_type='competitive', game_state='INGAME', map='/Game/Maps/Bonsai/Bonsai', score=1, enemy_score=0)
self.do(queue_type='competitive', game_state='INGAME', map='/Game/Maps/Bonsai/Bonsai', score=2, enemy_score=0)
for i in range(14):
self.do(queue_type='competitive', game_state='INGAME', map='/Game/Maps/Bonsai/Bonsai', score=2, enemy_score=i)
self.do(queue_type='competitive', game_state='INGAME', map='/Game/Maps/Bonsai/Bonsai', score=0, enemy_score=0)
assert self.mock.seq == [
('menu', False),
('queue_start', False),
('match_found', False),
('pregame', True),
'game_start',
('round_start', None, 0, 0, EconomyDecision.BUY),
('round_start', True, 1, 0, EconomyDecision.BUY),
('round_start', True, 2, 0, EconomyDecision.BONUS),
('round_start', False, 2, 1, EconomyDecision.MATCH_TEAM),
] + [
('round_start', False, 2, 2 + i, EconomyDecision.MATCH_TEAM) for i in range(7)] + [
('round_start', False, 2, 9, EconomyDecision.BUY), # last round in half
('round_start', False, 2, 10, EconomyDecision.BUY),
('round_start', False, 2, 11, EconomyDecision.SAVE),
('round_start', False, 2, 12, EconomyDecision.BUY),
('game_over', False)
]
def test_comp_overtime(self):
for _ in range(2):
self.do(queue_type='competitive', game_state='INGAME')
self.do(queue_type='competitive', game_state='INGAME', score=1)
for i in range(13):
self.do(queue_type='competitive', game_state='INGAME', score=1, enemy_score=i)
for i in range(12):
self.do(queue_type='competitive', game_state='INGAME', score=1 + i, enemy_score=12)
self.do(queue_type='competitive', game_state='INGAME', score=13, enemy_score=12)
self.do(queue_type='competitive', game_state='INGAME', score=13, enemy_score=13)
self.do(queue_type='competitive', game_state='INGAME', score=13, enemy_score=14)
self.do(queue_type='competitive', game_state='INGAME', score=14, enemy_score=14)
self.do(queue_type='competitive', game_state='INGAME', score=15, enemy_score=14)
self.do(queue_type='competitive', game_state='INGAME', score=16, enemy_score=14)
self.do(queue_type='competitive', game_state='INGAME')
assert self.mock.seq == [
'game_start',
('round_start', None, 0, 0, EconomyDecision.BUY),
('round_start', True, 1, 0, EconomyDecision.BUY),
('round_start', False, 1, 1, EconomyDecision.SAVE),
] + [
('round_start', False, 1, 2 + i, EconomyDecision.MATCH_TEAM) for i in range(8)] + [
('round_start', False, 1, 10, EconomyDecision.BUY), # last round in half
('round_start', False, 1, 11, EconomyDecision.BUY),
('round_start', False, 1, 12, EconomyDecision.BUY),
] + [
('round_start', True, 2 + i, 12, EconomyDecision.BUY) for i in range(12)] + [
('round_start', False, 13, 13, EconomyDecision.BUY),
('round_start', False, 13, 14, EconomyDecision.BUY),
('round_start', True, 14, 14, EconomyDecision.BUY),
('round_start', True, 15, 14, EconomyDecision.BUY),
('game_over', True),
]
def test_swiftplay(self):
self.do(game_state='MENUS')
self.do(game_state='MENUS', queue_type='swiftplay')
self.do(game_state='MENUS', party_state='MATCHMAKING', queue_type='swiftplay')
self.do(game_state='MENUS', party_state='MATCHMADE_GAME_STARTING', queue_type='swiftplay')
self.do(queue_type='swiftplay', game_state='PREGAME')
self.do(queue_type='swiftplay', game_state='INGAME')
for i in range(4):
self.do(queue_type='swiftplay', game_state='INGAME', enemy_score=1 + i)
for i in range(5):
self.do(queue_type='swiftplay', game_state='INGAME', score=1 + i, enemy_score=4)
self.do(queue_type='swiftplay', game_state='INGAME')
assert self.mock.seq == [
('menu', False),
('queue_start', False),
('match_found', False),
('pregame', False),
'game_start',
('round_start', None, 0, 0, EconomyDecision.BUY),
('round_start', False, 0, 1, EconomyDecision.BUY),
('round_start', False, 0, 2, EconomyDecision.BUY),
('round_start', False, 0, 3, EconomyDecision.BUY),
('round_start', False, 0, 4, EconomyDecision.BUY),
('round_start', True, 1, 4, EconomyDecision.BUY),
('round_start', True, 2, 4, EconomyDecision.BUY),
('round_start', True, 3, 4, EconomyDecision.BUY),
('round_start', True, 4, 4, EconomyDecision.BUY),
('game_over', True),
]
def test_tdm(self):
self.do(game_state='MENUS')
self.do(game_state='MENUS', queue_type='hurm')
self.do(game_state='MENUS', party_state='MATCHMAKING', queue_type='hurm')
self.do(game_state='MENUS', party_state='MATCHMADE_GAME_STARTING', queue_type='hurm')
self.do(queue_type='hurm', game_state='PREGAME')
self.do(queue_type='hurm', game_state='INGAME')
for i in range(100):
self.do(queue_type='hurm', game_state='INGAME', enemy_score=1 + i)
self.do(queue_type='hurm', game_state='INGAME')
assert self.mock.seq == [
('menu', False),
('queue_start', False),
('match_found', False),
('game_generic', 'hurm'),
]

270
controller/uv.lock generated Normal file
View File

@@ -0,0 +1,270 @@
version = 1
requires-python = ">=3.13"
[[package]]
name = "altgraph"
version = "0.17.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/de/a8/7145824cf0b9e3c28046520480f207df47e927df83aa9555fb47f8505922/altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406", size = 48418 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/3f/3bc3f1d83f6e4a7fcb834d3720544ca597590425be5ba9db032b2bf322a2/altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff", size = 21212 },
]
[[package]]
name = "certifi"
version = "2024.8.30"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 },
]
[[package]]
name = "charset-normalizer"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 },
{ url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 },
{ url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 },
{ url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 },
{ url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 },
{ url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 },
{ url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 },
{ url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 },
{ url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 },
{ url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 },
{ url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 },
{ url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 },
{ url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 },
{ url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 },
{ url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 },
{ url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "hidapi"
version = "0.14.0.post4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/72/21ccaaca6ffb06f544afd16191425025d831c2a6d318635e9c8854070f2d/hidapi-0.14.0.post4.tar.gz", hash = "sha256:48fce253e526d17b663fbf9989c71c7ef7653ced5f4be65f1437c313fb3dbdf6", size = 174388 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/c7/8601f03a6eeeac35655245177b50bb00e707f3392e0a79c34637f8525207/hidapi-0.14.0.post4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6f96ae777e906f0a9d6f75e873313145dfec2b774f558bfcae8ba34f09792460", size = 70358 },
{ url = "https://files.pythonhosted.org/packages/c1/5d/7376cf339fbe6fca26048e3c7e183ef4d99c046cc5d8378516a745914327/hidapi-0.14.0.post4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6439fc9686518d0336fac8c5e370093279f53c997540065fce131c97567118d8", size = 68034 },
{ url = "https://files.pythonhosted.org/packages/8c/5a/4bca20898c699810f016d2719b980fc57fe36d5012d03eca7a89ace98547/hidapi-0.14.0.post4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2acadb4f1ae569c4f73ddb461af8733e8f5efcb290c3d0ef1b0671ba793b0ae3", size = 1075570 },
{ url = "https://files.pythonhosted.org/packages/6a/c6/66e6b7c27297249bc737115dff4a1e819d3e0e73885160a3104ebec7ac13/hidapi-0.14.0.post4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:884fa003d899113e14908bd3b519c60b48fc3cec0410264dcbdad1c4a8fc2e8d", size = 1081482 },
{ url = "https://files.pythonhosted.org/packages/86/a8/21e9860eddeefd0dc41b3f7e6e81cd9ff53c2b07130f57776b56a1dddc66/hidapi-0.14.0.post4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2d466b995f8ff387d68c052d3b74ee981a4ddc4f1a99f32f2dc7022273dc11", size = 1069549 },
{ url = "https://files.pythonhosted.org/packages/e8/01/3adf46a7ea5bf31f12e09d4392e1810e662101ba6611214ea6e2c35bea7a/hidapi-0.14.0.post4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e1f6409854c0a8ed4d1fdbe88d5ee4baf6f19996d1561f76889a132cb083574d", size = 698200 },
{ url = "https://files.pythonhosted.org/packages/f0/19/db15cd21bef1b0dc8ef4309c5734b64affb7e88540efd3c090f153cdae0b/hidapi-0.14.0.post4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bca568a2b7d0d454c7921d70b1cc44f427eb6f95961b6d7b3b9b4532d0de74ef", size = 671554 },
{ url = "https://files.pythonhosted.org/packages/f5/23/f896ee8f0977710c354bd1b9ac6d5206c12842bd39d78a357c866f8ec6b6/hidapi-0.14.0.post4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f14ac2737fd6f58d88d2e6bf8ebd03aac7b486c14d3f570b7b1d0013d61b726", size = 703897 },
{ url = "https://files.pythonhosted.org/packages/9a/5e/3c93bb12b01392b538870bc710786fee86a9ced074a8b5c091a59786ee07/hidapi-0.14.0.post4-cp313-cp313-win32.whl", hash = "sha256:b6b9c4dbf7d7e2635ff129ce6ea82174865c073b75888b8b97dda5a3d9a70493", size = 62688 },
{ url = "https://files.pythonhosted.org/packages/6a/a6/0d43ac0be00db25fb0c2c6125e15a3e3536196c9a7cd806d50ebfb37b375/hidapi-0.14.0.post4-cp313-cp313-win_amd64.whl", hash = "sha256:87218eeba366c871adcc273407aacbabab781d6a964919712d5583eded5ca50f", size = 69749 },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "infi-systray"
version = "0.1.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9c/10/c323bbafafe9ef59721f3a4f242afb4ec1ff958d16ebe0f44c4bae032e3a/infi.systray-0.1.12.tar.gz", hash = "sha256:635bc10fabd3ba60a2382922fdec1e4e47efaea4b8c5ea7e437f6cdedae884d3", size = 8207 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/de/e97c6111025d5a95753bcdab2db30846e1a9eb3cc57aafec98c088b1f649/infi.systray-0.1.12-py3-none-any.whl", hash = "sha256:4041ab3693f7f00a6b2b2d7b553bf60fce8941708b83e32261ca40926952ed0d", size = 9234 },
]
[[package]]
name = "iniconfig"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
]
[[package]]
name = "macholib"
version = "1.16.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/ee/af1a3842bdd5902ce133bd246eb7ffd4375c38642aeb5dc0ae3a0329dfa2/macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30", size = 59309 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/5d/c059c180c84f7962db0aeae7c3b9303ed1d73d76f2bfbc32bc231c8be314/macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c", size = 38094 },
]
[[package]]
name = "packaging"
version = "24.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
]
[[package]]
name = "pefile"
version = "2023.2.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/c5/3b3c62223f72e2360737fd2a57c30e5b2adecd85e70276879609a7403334/pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc", size = 74854 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/26/d0ad8b448476d0a1e8d3ea5622dc77b916db84c6aa3cb1e1c0965af948fc/pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6", size = 71791 },
]
[[package]]
name = "pluggy"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
]
[[package]]
name = "pyinstaller"
version = "6.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
{ name = "macholib", marker = "sys_platform == 'darwin'" },
{ name = "packaging" },
{ name = "pefile", marker = "sys_platform == 'win32'" },
{ name = "pyinstaller-hooks-contrib" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/d4/54f5f5c73b803e6256ea97ffc6ba8a305d9a5f57f85f9b00b282512bf18a/pyinstaller-6.11.1.tar.gz", hash = "sha256:491dfb4d9d5d1d9650d9507daec1ff6829527a254d8e396badd60a0affcb72ef", size = 4249772 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/15/b0f1c0985ee32fcd2f6ad9a486ef94e4db3fef9af025a3655e76cb708009/pyinstaller-6.11.1-py3-none-macosx_10_13_universal2.whl", hash = "sha256:44e36172de326af6d4e7663b12f71dbd34e2e3e02233e181e457394423daaf03", size = 991780 },
{ url = "https://files.pythonhosted.org/packages/fd/0f/9f54cb18abe2b1d89051bc9214c0cb40d7b5f4049c151c315dacc067f4a2/pyinstaller-6.11.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:6d12c45a29add78039066a53fb05967afaa09a672426072b13816fe7676abfc4", size = 711739 },
{ url = "https://files.pythonhosted.org/packages/32/f7/79d10830780eff8339bfa793eece1df4b2459e35a712fc81983e8536cc29/pyinstaller-6.11.1-py3-none-manylinux2014_i686.whl", hash = "sha256:ddc0fddd75f07f7e423da1f0822e389a42af011f9589e0269b87e0d89aa48c1f", size = 714053 },
{ url = "https://files.pythonhosted.org/packages/25/f7/9961ef02cdbd2dbb1b1a215292656bd0ea72a83aafd8fb6373513849711e/pyinstaller-6.11.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:0d6475559c4939f0735122989611d7f739ed3bf02f666ce31022928f7a7e4fda", size = 719133 },
{ url = "https://files.pythonhosted.org/packages/6f/4d/7f854842a1ce798de762a0b0bc5d5a4fc26ad06164a98575dc3c54abed1f/pyinstaller-6.11.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:e21c7806e34f40181e7606926a14579f848bfb1dc52cbca7eea66eccccbfe977", size = 709591 },
{ url = "https://files.pythonhosted.org/packages/7f/e0/00d29fc90c3ba50620c61554e26ebb4d764569507be7cd1c8794aa696f9a/pyinstaller-6.11.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:32c742a24fe65d0702958fadf4040f76de85859c26bec0008766e5dbabc5b68f", size = 710068 },
{ url = "https://files.pythonhosted.org/packages/3e/57/d14b44a69f068d2caaee49d15e45f9fa0f37c6a2d2ad778c953c1722a1ca/pyinstaller-6.11.1-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:208c0ef6dab0837a0a273ea32d1a3619a208e3d1fe3fec3785eea71a77fd00ce", size = 714439 },
{ url = "https://files.pythonhosted.org/packages/88/01/256824bb57ca208099c86c2fb289f888ca7732580e91ced48fa14e5903b2/pyinstaller-6.11.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:ad84abf465bcda363c1d54eafa76745d77b6a8a713778348377dc98d12a452f7", size = 710457 },
{ url = "https://files.pythonhosted.org/packages/7c/f0/98c9138f5f0ff17462f1ad6d712dcfa643b9a283d6238d464d8145bc139d/pyinstaller-6.11.1-py3-none-win32.whl", hash = "sha256:2e8365276c5131c9bef98e358fbc305e4022db8bedc9df479629d6414021956a", size = 1280261 },
{ url = "https://files.pythonhosted.org/packages/7d/08/f43080614b3e8bce481d4dfd580e579497c7dcdaf87656d9d2ad912e5796/pyinstaller-6.11.1-py3-none-win_amd64.whl", hash = "sha256:7ac83c0dc0e04357dab98c487e74ad2adb30e7eb186b58157a8faf46f1fa796f", size = 1340482 },
{ url = "https://files.pythonhosted.org/packages/ed/56/953c6594cb66e249563854c9cc04ac5a055c6c99d1614298feeaeaa9b87e/pyinstaller-6.11.1-py3-none-win_arm64.whl", hash = "sha256:35e6b8077d240600bb309ed68bb0b1453fd2b7ab740b66d000db7abae6244423", size = 1267519 },
]
[[package]]
name = "pyinstaller-hooks-contrib"
version = "2024.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/73/6a/9d0057e312b85fbd17a79e1c1955d115fd9bbc78b85bab757777c8ef2307/pyinstaller_hooks_contrib-2024.10.tar.gz", hash = "sha256:8a46655e5c5b0186b5e527399118a9b342f10513eb1425c483fa4f6d02e8800c", size = 140592 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/64/445861ee7a5fd32874c0f6cfe8222aacc8feda22539332e0d8ff50dadec6/pyinstaller_hooks_contrib-2024.10-py3-none-any.whl", hash = "sha256:ad47db0e153683b4151e10d231cb91f2d93c85079e78d76d9e0f57ac6c8a5e10", size = 338417 },
]
[[package]]
name = "pytest"
version = "8.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 },
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 },
]
[[package]]
name = "requests"
version = "2.32.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
]
[[package]]
name = "setuptools"
version = "75.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/54/292f26c208734e9a7f067aea4a7e282c080750c4546559b58e2e45413ca0/setuptools-75.6.0.tar.gz", hash = "sha256:8199222558df7c86216af4f84c30e9b34a61d8ba19366cc914424cdbd28252f6", size = 1337429 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/21/47d163f615df1d30c094f6c8bbb353619274edccf0327b185cc2493c2c33/setuptools-75.6.0-py3-none-any.whl", hash = "sha256:ce74b49e8f7110f9bf04883b730f4765b774ef3ef28f722cce7c273d253aaf7d", size = 1224032 },
]
[[package]]
name = "urllib3"
version = "2.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 },
]
[[package]]
name = "valconomy"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "hidapi" },
{ name = "infi-systray", marker = "sys_platform == 'win32'" },
{ name = "requests" },
]
[package.dev-dependencies]
dev = [
{ name = "pyinstaller" },
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "hidapi", specifier = ">=0.14.0.post4" },
{ name = "infi-systray", marker = "sys_platform == 'win32'", specifier = ">=0.1.12" },
{ name = "requests", specifier = ">=2.32.3" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pyinstaller", specifier = ">=6.11.1" },
{ name = "pytest", specifier = ">=8.3.4" },
]

526
controller/valconomy.py Normal file
View File

@@ -0,0 +1,526 @@
import base64
from dataclasses import dataclass
from enum import Enum
import json
import logging
from pprint import pprint
import os
import queue
import struct
import threading
import time
from typing import Callable
import hid
import requests
import urllib3
# Local HTTPS connection, we need to not verify the cert
urllib3.disable_warnings()
# Disable so we can debug without a log for every request
logging.getLogger('urllib3.connectionpool').disabled = True
log = logging.getLogger('valconomy')
@dataclass
class ValorantPlayerInfo:
is_idle: bool = False
is_party_owner: bool = True
max_party_size: int = 5
party_size: int = 1
party_state: str = None
queue_type: str = None
game_state: str = None
map: str = None
team: str = None
score: int = 0
enemy_score: int = 0
@dataclass
class RiotPlayerInfo:
uuid: str
name: str
tag: str
state: str
valorant: ValorantPlayerInfo = None
@classmethod
def dummy(cls, **kwargs):
return cls('00000000-0000-0000-0000-000000000000', 'Player', 'gamer', 'dnd', **kwargs)
def full_name(self) -> str:
return f'{self.name}#{self.tag}'
class ValorantLocalClient:
poll_interval = 0.5
def __init__(self, callback: Callable[[RiotPlayerInfo, bool], None]):
self.callback = callback
self.lockfile_path = os.path.join(os.environ['LOCALAPPDATA'], r'Riot Games\Riot Client\Config\lockfile')
self.port, self.password = None, None
self.running = True
self.players = {}
def _load_credentials(self):
if not os.path.exists(self.lockfile_path):
return False
try:
with open(self.lockfile_path) as f:
for line in f:
if line.startswith('Riot Client'):
client, pid, self.port, self.password, proto = line.rstrip().split(':')
assert proto == 'https'
return True
except FileNotFoundError:
return False
return False
def _do_poll(self):
try:
resp = requests.get(
f'https://127.0.0.1:{self.port}/chat/v4/presences',
auth=('riot', self.password), verify=False)
resp.raise_for_status()
data = resp.json()
except (requests.HTTPError, requests.ConnectionError) as ex:
# Most likely one of these means the Riot Client shut down
log.error(f'Failed to make request to Riot Client: {ex}')
return False
next_players = {}
for p in data['presences']:
if p['product'] != 'valorant':
continue
v = json.loads(base64.b64decode(p.pop('private'))) # Valorant-specfic
# pprint(p)
# pprint(v)
val_info = ValorantPlayerInfo(
is_idle=v['isIdle'], is_party_owner=v['isPartyOwner'],
max_party_size=v['maxPartySize'], party_size=v['partySize'],
party_state=v['partyState'],
queue_type=v['queueId'] or None, game_state=v['sessionLoopState'],
map=v['matchMap'] or None,
score=v['partyOwnerMatchScoreAllyTeam'], enemy_score=v['partyOwnerMatchScoreEnemyTeam'])
match v['partyOwnerMatchCurrentTeam']:
case 'Red':
val_info.team = 'attackers'
case 'Blue':
val_info.team = 'defenders'
info = RiotPlayerInfo(p['puuid'], p['game_name'], p['game_tag'], p['state'], valorant=val_info)
last = self.players.get(info.uuid)
if info != last:
self.callback(info, False)
next_players[info.uuid] = info
for uuid in self.players.keys() - next_players.keys():
self.callback(self.players[uuid], True)
self.players = next_players
return True
def run(self):
while self.running:
if self.password is None:
if not self._load_credentials():
time.sleep(self.poll_interval)
continue
log.info('Detected Riot Client credentials, starting polling')
if not self._do_poll():
for p in self.players.values():
self.callback(p, True)
self.players = {}
self.port, self.password = None, None
time.sleep(self.poll_interval)
def stop(self):
self.running = False
class EconomyDecision(Enum):
BUY = 0
SAVE = 1
BONUS = 2
MATCH_TEAM = 3
class ValconomyHandler:
# Valorant isn't open
def none(self):
log.info('Val Time soon?')
# Welcome the user (back) to the game!
def menu(self, info: RiotPlayerInfo, was_idle: bool=False):
if was_idle:
log.info(f"Welcome back, {info.name}!")
else:
log.info(f"It's Val Time, {info.name}!")
# The user is idle in the menu...
def idle(self, info: RiotPlayerInfo):
log.info(f'Come back soon, {info.name}...')
# Just entered queue
def queue_start(self, info: RiotPlayerInfo):
if info.valorant.is_party_owner and info.valorant.queue_type == 'unrated':
log.info('Uhhhh should that be comp, MoonStar?')
else:
log.info(f'Hope you find a game quickly, {info.name}!')
def match_found(self, info: RiotPlayerInfo):
if info.valorant.queue_type == 'premier-seasonmatch':
log.info('Do the Cosmonauts proud!')
else:
log.info("I hope it's not Split...")
# Loaded into the agent select
def pregame(self, info: RiotPlayerInfo):
if info.valorant.map == '/Game/Maps/Bonsai/Bonsai':
log.info('Ewwwww, Split....')
else:
log.info('Pick a good agent!')
# Game where we're not providing economy help
def game_generic(self, info: RiotPlayerInfo):
match info.valorant.queue_type:
case 'hurm':
log.info('Have a good TDM!')
case 'deathmatch':
log.info('Have a good deathmatch!')
case 'ggteam':
log.info('Have a good Escalation!')
case 'spikerush':
log.info('Have a good Spike Rush!')
case _:
if info.valorant.max_party_size == 12:
log.info('Have a good custom!')
else:
log.info('Have a good game!')
# Loaded into the game
def game_start(self, info: RiotPlayerInfo):
log.info('OK best of luck!')
# Round started
def round_start(self, info: RiotPlayerInfo, won: bool, economy: EconomyDecision):
msg = f'Start round {info.valorant.score}-{info.valorant.enemy_score}'
if won is not None:
msg += f' ({"won" if won else "lost"})'
log.info(msg)
match economy:
case EconomyDecision.BUY:
log.info('You should buy!')
case EconomyDecision.SAVE:
log.info('Time to save...')
case EconomyDecision.BONUS:
log.info('Keep your gun from last round!')
case EconomyDecision.MATCH_TEAM:
log.info('Follow the team economy!')
def game_over(self, info: RiotPlayerInfo, won: bool):
if won:
log.info('Well played!')
else:
log.info('Hard luck...')
class HIDValconomyHandler(ValconomyHandler):
vid = 0x6969
pid = 0x0004
def __init__(self):
self.dev = None
self.running = True
self.queue = queue.Queue(128)
self._thread = None
def close(self):
if self.dev is not None:
self.dev.close()
def _dev_ready(self):
try:
if self.dev is None:
dev = hid.device()
dev.open(vendor_id=self.vid, product_id=self.pid)
self.dev = dev
log.info(f'USB device opened')
# 2 bytes: report ID and returned value
# We get back the same report ID and value
# Set to report size + 1 to satisfy W*ndoze
data = self.dev.get_input_report(0, 65)
assert len(data) == 2
return data[1] == 1
except OSError as ex:
if self.dev is not None:
log.warning(f'USB device lost')
self.dev = None
return False
def _do(self, cmd, *vals, fmt=None):
if fmt is None:
fmt = ''
fmt = '<BB' + fmt
# Prepend report ID 0
data = struct.pack(fmt, *(0, cmd) + vals)
self.dev.write(data)
def _enq(self, cmd, *vals, fmt=None):
self.queue.put((cmd, vals, fmt))
def service(self):
while not self.queue.empty():
cmd, vals, fmt = self.queue.get()
while not self._dev_ready():
if not self.running:
return
time.sleep(0.1)
try:
self._do(cmd, *vals, fmt=fmt)
except OSError as ex:
log.warning(f'USB device lost, state dequeuing stalled')
def run(self):
while self.running:
while self.queue.empty():
if not self.running:
return
time.sleep(0.5)
self.service()
def start(self):
assert self._thread is None
self._thread = threading.Thread(name='HIDValconomyHandler', target=self.run)
self._thread.start()
def stop(self):
if self._thread is None or not self.running:
return
self.running = False
self._thread.join()
def none(self):
self._enq(0)
def menu(self, info: RiotPlayerInfo, was_idle: bool=False):
self._enq(1, 1 if was_idle else 0, fmt='B')
def idle(self, info: RiotPlayerInfo):
self._enq(2)
def queue_start(self, info: RiotPlayerInfo):
ms_not_comp = info.valorant.is_party_owner and info.valorant.queue_type == 'unrated'
self._enq(3, 1 if ms_not_comp else 0, fmt='B')
def match_found(self, info: RiotPlayerInfo):
self._enq(4, 1 if info.valorant.queue_type == 'premier-seasonmatch' else 0, fmt='B')
def pregame(self, info: RiotPlayerInfo):
self._enq(5, 1 if info.valorant.map == '/Game/Maps/Bonsai/Bonsai' else 0, fmt='B')
def game_generic(self, info: RiotPlayerInfo):
match info.valorant.queue_type:
case 'hurm': # tdm
gm = 0
case 'deathmatch':
gm = 1
case 'ggteam': # escalation
gm = 2
case 'spikerush':
gm = 3
case _:
if info.valorant.max_party_size == 12: # custom
gm = 4
else:
gm = 5
self._enq(6, gm, fmt='B')
def game_start(self, info: RiotPlayerInfo):
self._enq(7)
def round_start(self, info: RiotPlayerInfo, won: bool, economy: EconomyDecision):
match won:
case False:
won_val = 0
case True:
won_val = 1
case None:
won_val = 2
self._enq(
8,
info.valorant.score, info.valorant.enemy_score,
won_val,
economy.value,
fmt='BBBB')
def game_over(self, info: RiotPlayerInfo, won: bool):
self._enq(9, 1 if won else 0, fmt='B')
class GameState(Enum):
NONE = 0
MENU = 1
IDLE = 2
IN_QUEUE = 3
GAME_FOUND = 4
PRE_GAME = 5
GAME_START = 6
IN_GAME = 7
GAME_OVER = 8
IN_GAME_GENERIC = 9
class ValconomyStateMachine:
def __init__(self, player_uuid: str, handler: ValconomyHandler):
self.uuid = player_uuid
self.handler = handler
self.last = None
self.score = -1
self.score_enemy = -1
self.round_history = []
def handle_state(self, s: GameState, p: RiotPlayerInfo):
log.info(f'{self.last} -> {s}')
if s == GameState.IN_GAME:
if p.valorant.score == self.score and p.valorant.enemy_score == self.score_enemy:
# Same score line
return
won_last = None
if self.score != -1:
won_last = True if p.valorant.score > self.score else False
self.round_history.append(won_last)
over = False
if p.valorant.queue_type == 'swiftplay':
if p.valorant.score == 5 or p.valorant.enemy_score == 5:
over = True
eco = EconomyDecision.BUY
else:
if p.valorant.score > 12 or p.valorant.enemy_score > 12:
if p.valorant.queue_type == 'unrated' or abs(p.valorant.score - p.valorant.enemy_score) >= 2:
over = True
eco = EconomyDecision.MATCH_TEAM
rounds_played = p.valorant.score + p.valorant.enemy_score
if p.valorant.enemy_score == 12:
# Enemy about to win!
eco = EconomyDecision.BUY
elif rounds_played in (0, 12):
# First round (of half)
eco = EconomyDecision.BUY
elif rounds_played in (1, 13):
# Second round (of half)
eco = EconomyDecision.BUY if won_last else EconomyDecision.SAVE
elif rounds_played in (2, 14):
# Third round (of half)
match self.round_history[-2:]:
case [True, True]:
eco = EconomyDecision.BONUS
case [True, False]:
eco = EconomyDecision.SAVE
case [False, _]:
eco = EconomyDecision.BUY
elif rounds_played >= 24:
# Sudden death or overtime (buy either way)
eco = EconomyDecision.BUY
elif rounds_played == 11:
# Last round of half
eco = EconomyDecision.BUY
if not over:
self.handler.round_start(p, won_last, eco)
self.score = p.valorant.score
self.score_enemy = p.valorant.enemy_score
self.last = GameState.IN_GAME
return
if s == self.last:
return
if s != GameState.GAME_OVER:
self.score = self.score_enemy = -1
self.round_history = []
match s:
case GameState.IN_GAME_GENERIC:
self.handler.game_generic(p)
case GameState.GAME_START:
self.handler.game_start(p)
case GameState.PRE_GAME:
self.handler.pregame(p)
case GameState.GAME_FOUND:
self.handler.match_found(p)
case GameState.IN_QUEUE:
self.handler.queue_start(p)
case GameState.IDLE:
self.handler.idle(p)
case GameState.MENU:
self.handler.menu(p, self.last == GameState.IDLE)
case GameState.NONE:
self.handler.none()
case GameState.GAME_OVER:
self.handler.game_over(p, self.score > self.score_enemy)
self.last = s
def handle_presence(self, p: RiotPlayerInfo, gone: bool):
log.debug(f'{repr(p)}, gone: {gone}')
if p.uuid != self.uuid:
return
if gone:
self.handle_state(GameState.NONE, p)
return
if p.valorant.party_state == 'MATCHMADE_GAME_STARTING':
self.handle_state(GameState.GAME_FOUND, p)
return
if p.valorant.party_state == 'MATCHMAKING':
self.handle_state(GameState.IN_QUEUE, p)
return
if p.valorant.queue_type in ('unrated', 'competitive', 'premier-seasonmatch', 'swiftplay'):
if p.valorant.game_state == 'PREGAME':
self.handle_state(GameState.PRE_GAME, p)
return
if p.valorant.game_state == 'INGAME':
if self.score == -1 and p.valorant.score == p.valorant.enemy_score == 0:
self.handle_state(GameState.GAME_START, p)
if (self.score > 0 or self.score_enemy > 0) and p.valorant.score == p.valorant.enemy_score == 0:
self.handle_state(GameState.GAME_OVER, p)
else:
self.handle_state(GameState.IN_GAME, p)
return
if p.valorant.game_state in ('PREGAME', 'INGAME'):
self.handle_state(GameState.IN_GAME_GENERIC, p)
return
if p.valorant.game_state == 'MENUS':
if p.valorant.is_idle:
self.handle_state(GameState.IDLE, p)
return
self.handle_state(GameState.MENU, p)

View File

@@ -1,2 +1,2 @@
watch_file default.nix
use flake ..#firmware --override-input rootdir "file+file://"<(printf %s "$PWD")
use flake ..#firmware --impure

7
firmware/.gitignore vendored
View File

@@ -1,3 +1,10 @@
/build/
/managed_components/
sdkconfig
sdkconfig.old
*.swp
/main/font/*.c
/main/img/*.c
/assets/moon.png
/assets/star.png
/assets/sleep.png

Binary file not shown.

BIN
firmware/assets/bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,9 +1,15 @@
{
perSystem = { libMy, pkgs, ... }: {
perSystem = { libMy, pkgs, ... }:
let
genImgsPy = pkgs.python3.withPackages (ps: with ps; [ pillow ]);
in
{
devenv.shells.firmware = libMy.withRootdir {
packages = with pkgs; [
esp-idf-esp32s3
picocom
lv_font_conv
imagemagick
];
env = {
@@ -11,13 +17,22 @@
};
scripts = {
# build.exec = ''
# cmake -S . -B build -D CMAKE_BUILD_TYPE=Debug -D PICO_STDIO_SEMIHOSTING=1
# cmake --build build --parallel
# '';
init.exec = ''
idf.py set-target esp32s3
'';
gen-fonts.exec = ''
for s in 40 180; do
DEBUG='*' lv_font_conv --font assets/Tungsten-Bold.ttf --bpp 4 --size $s -r 0x20-0x7F --no-compress \
--format lvgl --lv-include lvgl.h --lv-font-name lv_font_tungsten_"$s" -o main/font/tungsten_"$s".c
done
'';
gen-imgs.exec = ''
for e in moon star sleep; do
magick assets/''${e}_orig.png -resize 120x120 assets/$e.png
done
${genImgsPy}/bin/python gen-imgs.py
'';
};
};
};

View File

@@ -0,0 +1,73 @@
dependencies:
espressif/esp_lcd_touch:
component_hash: 779b4ba2464a3ae85681e4b860caa5fdc35801458c23f3039ee761bae7f442a4
dependencies:
- name: idf
registry_url: https://components.espressif.com
require: private
version: '>=4.4.2'
source:
registry_url: https://components.espressif.com
type: service
version: 1.1.2
espressif/esp_lcd_touch_gt911:
component_hash: 145801c24c7ecfa2c43eea0dd5e87fd5bde5653eb9a48d619c7bad60b20a1acf
dependencies:
- name: espressif/esp_lcd_touch
registry_url: https://components.espressif.com
require: public
version: ^1.1.0
- name: idf
require: private
version: '>=4.4.2'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.1.1~2
espressif/esp_tinyusb:
component_hash: 4878831be091116ec8d0e5eaedcae54a5e9866ebf15a44ef101886fd42c0b91f
dependencies:
- name: idf
require: private
version: '>=5.0'
- name: espressif/tinyusb
registry_url: https://components.espressif.com
require: public
version: '>=0.14.2'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.5.0
espressif/tinyusb:
component_hash: 8a9e23b8cdc733b51fed357979139f5ae63a2fed3ce4e7c41d505f685f7d741a
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com
type: service
targets:
- esp32s2
- esp32s3
- esp32p4
version: 0.17.0~1
idf:
source:
type: idf
version: 5.3.1
lvgl/lvgl:
component_hash: 096c69af22eaf8a2b721e3913da91918c5e6bf1a762a113ec01f401aa61337a0
dependencies: []
source:
registry_url: https://components.espressif.com/
type: service
version: 9.2.2
direct_dependencies:
- espressif/esp_lcd_touch_gt911
- espressif/esp_tinyusb
- idf
- lvgl/lvgl
manifest_hash: a301075b9c0715323f037caea9def414e47e90934bf7fd0c3b2c9d8ffa30bc49
target: esp32s3
version: 2.0.0

54
firmware/gen-imgs.py Executable file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
import itertools
import os
import struct
from PIL import Image
def rgb2h(r, g, b):
return (r >> 3) << 11 | (g >> 2) << 5 | (b >> 3)
def bytes_line(bs):
return ' ' + ' '.join(map(lambda i: f'{i:#x},', bs)) + '\n'
imgs = ['bg.png', 'moon.png', 'star.png', 'sleep.png']
for fname in imgs:
alpha = []
with Image.open(os.path.join('assets/', fname)) as img:
w = img.width
h = img.height
if img.has_transparency_data:
has_alpha = True
data = [rgb2h(r, g, b) for r, g, b, _ in img.getdata()]
alpha = [a for _, _, _, a in img.getdata()]
else:
data = [rgb2h(r, g, b) for r, g, b in img.getdata()]
basename = os.path.splitext(fname)[0]
with open(os.path.join('main/img/', basename + '.c'), 'w') as f:
f.write(
'#include <inttypes.h>\n\n'
'#include "lvgl.h"\n\n'
'static const uint8_t _data[] = {\n')
for group in itertools.batched(data, 10):
bs = bytearray()
for c in group:
bs += struct.pack('<H', c)
f.write(bytes_line(bs))
for group in itertools.batched(alpha, 20):
f.write(bytes_line(group))
f.write('};\n\n')
f.write(
f'const lv_image_dsc_t ui_img_{basename} = {{\n'
' .header.magic = LV_IMAGE_HEADER_MAGIC,\n'
f' .header.cf = LV_COLOR_FORMAT_RGB565{"A8" if alpha else ""},\n'
f' .header.w = {w},\n'
f' .header.h = {h},\n'
' .data_size = sizeof(_data),\n'
' .data = _data,\n'
'};\n')

View File

@@ -1,2 +1,6 @@
idf_component_register(SRCS "valconomy.c"
INCLUDE_DIRS ".")
idf_component_register(
SRCS
"font/tungsten_40.c" "font/tungsten_180.c"
"img/bg.c" "img/moon.c" "img/star.c" "img/sleep.c"
"valconomy.c" "ui.c" "lcd.c" "usb.c"
INCLUDE_DIRS ".")

15
firmware/main/common.h Normal file
View File

@@ -0,0 +1,15 @@
#pragma once
#include <inttypes.h>
#include "driver/i2c_master.h"
#define SCL_SPEED 100000
#define LCD_HRES 800
#define LCD_VRES 480
extern char val_dev_serial[13];
extern i2c_master_bus_handle_t i2c_bus_handle;
extern i2c_master_dev_handle_t exio_cfg_handle, exio_o_handle;

View File

View File

@@ -0,0 +1,19 @@
## IDF Component Manager Manifest File
dependencies:
espressif/esp_tinyusb: "^1.5.0"
lvgl/lvgl: "^9.2.2"
esp_lcd_touch_gt911: "^1.1.1"
## Required IDF version
idf:
version: ">=4.1.0"
# # Put list of dependencies here
# # For components maintained by Espressif:
# component: "~1.0.0"
# # For 3rd party components:
# username/component: ">=1.0.0,<2.0.0"
# username2/component2:
# version: "~1.0.0"
# # For transient dependencies `public` flag can be set.
# # `public` flag doesn't have an effect dependencies of the `main` component.
# # All dependencies of `main` are public by default.
# public: true

View File

280
firmware/main/lcd.c Normal file
View File

@@ -0,0 +1,280 @@
#include <inttypes.h>
#include "sdkconfig.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_err.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_lcd_panel_ops.h"
#include "esp_lcd_panel_rgb.h"
#include "driver/i2c_master.h"
#include "esp_lcd_touch_gt911.h"
#include "lvgl.h"
#include "common.h"
#include "lcd.h"
#define LCD_PIXEL_SIZE 2
// in ms
#define LVGL_TICK_PERIOD 2
#define LVGL_TASK_MAX_DELAY 500
#define LVGL_TASK_MIN_DELAY 1
#define LVGL_TASK_PRIORITY 2
#define LVGL_TASK_STACK_SIZE (8 * 1024)
#define WAIT_VSYNC 0
static const char *TAG = "valconomy-lcd";
// LVGL library is not thread-safe, we will call LVGL APIs from different tasks, so use a mutex to protect it
static SemaphoreHandle_t lvgl_mtx = NULL;
#if WAIT_VSYNC
static SemaphoreHandle_t sem_vsync_end = NULL;
static SemaphoreHandle_t sem_gui_ready = NULL;
#endif
static bool val_on_vsync(esp_lcd_panel_handle_t panel, const esp_lcd_rgb_panel_event_data_t *event_data, void *user_ctx) {
BaseType_t high_task_awoken = pdFALSE;
#if WAIT_VSYNC
if (xSemaphoreTakeFromISR(sem_gui_ready, &high_task_awoken) == pdTRUE) {
xSemaphoreGiveFromISR(sem_vsync_end, &high_task_awoken);
}
#endif
lv_disp_flush_ready(user_ctx);
return high_task_awoken == pdTRUE;
}
static void val_lvgl_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map) {
esp_lcd_panel_handle_t panel_handle = lv_display_get_user_data(disp);
#if WAIT_VSYNC
xSemaphoreGive(sem_gui_ready);
xSemaphoreTake(sem_vsync_end, portMAX_DELAY);
#endif
// pass the draw buffer to the driver
esp_lcd_panel_draw_bitmap(panel_handle, area->x1, area->y1, area->x2 + 1, area->y2 + 1, px_map);
}
static void val_increase_lvgl_tick(void *arg) {
/* Tell LVGL how many milliseconds has elapsed */
lv_tick_inc(LVGL_TICK_PERIOD);
}
static void val_lvgl_port_task(void *arg) {
ESP_LOGI(TAG, "Starting LVGL task");
uint32_t task_delay_ms = LVGL_TASK_MAX_DELAY;
while (1) {
// Lock the mutex due to the LVGL APIs are not thread-safe
if (val_lvgl_lock(-1)) {
task_delay_ms = lv_timer_handler();
// Release the mutex
val_lvgl_unlock();
}
if (task_delay_ms > LVGL_TASK_MAX_DELAY) {
task_delay_ms = LVGL_TASK_MAX_DELAY;
} else if (task_delay_ms < LVGL_TASK_MIN_DELAY) {
task_delay_ms = LVGL_TASK_MIN_DELAY;
}
vTaskDelay(pdMS_TO_TICKS(task_delay_ms));
}
}
static void val_lvgl_touch_read(lv_indev_t *indev, lv_indev_data_t *data) {
uint16_t touchpad_x[1] = {0};
uint16_t touchpad_y[1] = {0};
uint8_t touchpad_cnt = 0;
// Read touch controller data
esp_lcd_touch_handle_t touch_panel = lv_indev_get_user_data(indev);
esp_lcd_touch_read_data(touch_panel);
// Get coordinates
bool touchpad_pressed = esp_lcd_touch_get_coordinates(touch_panel, touchpad_x, touchpad_y, NULL, &touchpad_cnt, 1);
if (touchpad_pressed && touchpad_cnt > 0) {
data->point.x = touchpad_x[0];
data->point.y = touchpad_y[0];
data->state = LV_INDEV_STATE_PR;
} else {
data->state = LV_INDEV_STATE_REL;
}
}
bool val_lvgl_lock(int timeout_ms) {
// Convert timeout in milliseconds to FreeRTOS ticks
// If `timeout_ms` is set to -1, the program will block until the condition is met
const TickType_t timeout_ticks = (timeout_ms == -1) ? portMAX_DELAY : pdMS_TO_TICKS(timeout_ms);
return xSemaphoreTakeRecursive(lvgl_mtx, timeout_ticks) == pdTRUE;
}
void val_lvgl_unlock(void) {
xSemaphoreGiveRecursive(lvgl_mtx);
}
esp_lcd_touch_handle_t val_setup_touch(void) {
ESP_LOGI(TAG, "Install touch panel driver");
esp_lcd_panel_io_i2c_config_t io_config = ESP_LCD_TOUCH_IO_I2C_GT911_CONFIG();
io_config.scl_speed_hz = SCL_SPEED;
esp_lcd_panel_io_handle_t io_handle = NULL;
ESP_ERROR_CHECK(esp_lcd_new_panel_io_i2c(i2c_bus_handle, &io_config, &io_handle));
esp_lcd_touch_io_gt911_config_t gt911_config = {
.dev_addr = io_config.dev_addr,
};
esp_lcd_touch_config_t tp_cfg = {
.x_max = LCD_HRES,
.y_max = LCD_VRES,
.rst_gpio_num = -1,
.int_gpio_num = 4,
.levels = {
.reset = 0,
.interrupt = 0,
},
.flags = {
.swap_xy = 0,
.mirror_x = 0,
.mirror_y = 0,
},
.driver_data = &gt911_config,
};
esp_lcd_touch_handle_t tp;
ESP_ERROR_CHECK(esp_lcd_touch_new_i2c_gt911(io_handle, &tp_cfg, &tp));
return tp;
}
esp_lcd_panel_handle_t val_setup_lcd(void) {
uint8_t i2c_b;
i2c_b = 0x01; // Output enable
ESP_ERROR_CHECK(i2c_master_transmit(exio_cfg_handle, &i2c_b, 1, -1));
i2c_b = 0x00; // Reset everything
ESP_ERROR_CHECK(i2c_master_transmit(exio_o_handle, &i2c_b, 1, -1));
vTaskDelay(pdMS_TO_TICKS(10));
i2c_b = 0x0c; // Pull LCD + touch out of reset
ESP_ERROR_CHECK(i2c_master_transmit(exio_o_handle, &i2c_b, 1, -1));
vTaskDelay(pdMS_TO_TICKS(100));
ESP_LOGI(TAG, "Install RGB LCD panel driver");
esp_lcd_panel_handle_t panel_handle = NULL;
esp_lcd_rgb_panel_config_t panel_config = {
.data_width = 16, // RGB565
.psram_trans_align = 64,
.num_fbs = 2,
.clk_src = LCD_CLK_SRC_DEFAULT,
.disp_gpio_num = -1, // Only accessible via GPIO expander
.pclk_gpio_num = 7,
.vsync_gpio_num = 3,
.hsync_gpio_num = 46,
.de_gpio_num = 5,
.data_gpio_nums = {
14, // B3
38, // B4
18, // B5
17, // B6
10, // B7
39, // G2
0, // G3
45, // G4
48, // G5
47, // G6
21, // G7
1, // R3
2, // R4
42, // R5
41, // R6
40, // R7
},
.timings = {
.pclk_hz = 18 * 1000 * 1000, // 18000000 / (hs+hbp+hfp+hres) / (vs+vbp+hfp+vres) = ~42Hz
.h_res = LCD_HRES,
.v_res = LCD_VRES,
.hsync_back_porch = 8,
.hsync_front_porch = 8,
.hsync_pulse_width = 4,
.vsync_back_porch = 16,
.vsync_front_porch = 16,
.vsync_pulse_width = 4,
.flags = {
.pclk_active_neg = true,
},
},
.flags.fb_in_psram = true, // allocate frame buffer in PSRAM
};
ESP_ERROR_CHECK(esp_lcd_new_rgb_panel(&panel_config, &panel_handle));
ESP_LOGI(TAG, "Initialize RGB LCD panel");
ESP_ERROR_CHECK(esp_lcd_panel_reset(panel_handle));
ESP_ERROR_CHECK(esp_lcd_panel_init(panel_handle));
ESP_LOGI(TAG, "Turn on LCD backlight");
i2c_b = 0x0e; // LCD + touch out of reset, backlight on
ESP_ERROR_CHECK(i2c_master_transmit(exio_o_handle, &i2c_b, 1, -1));
return panel_handle;
}
lv_display_t *val_setup_lvgl(esp_lcd_panel_handle_t lcd_panel, esp_lcd_touch_handle_t touch_panel) {
ESP_LOGI(TAG, "Initialize LVGL library");
lv_init();
// create a lvgl display
lv_display_t *display = lv_display_create(LCD_HRES, LCD_VRES);
// associate the rgb panel handle to the display
lv_display_set_user_data(display, lcd_panel);
// set color depth
lv_display_set_color_format(display, LV_COLOR_FORMAT_RGB565);
// create draw buffers
void *buf1 = NULL;
void *buf2 = NULL;
ESP_LOGI(TAG, "Use frame buffers as LVGL draw buffers");
ESP_ERROR_CHECK(esp_lcd_rgb_panel_get_frame_buffer(lcd_panel, 2, &buf1, &buf2));
// set LVGL draw buffers and direct mode
lv_display_set_buffers(display, buf1, buf2, LCD_HRES * LCD_VRES * LCD_PIXEL_SIZE, LV_DISPLAY_RENDER_MODE_DIRECT);
// set the callback which can copy the rendered image to an area of the display
lv_display_set_flush_cb(display, val_lvgl_flush_cb);
#if WAIT_VSYNC
sem_vsync_end = xSemaphoreCreateBinary();
assert(sem_vsync_end);
sem_gui_ready = xSemaphoreCreateBinary();
assert(sem_gui_ready);
#endif
ESP_LOGI(TAG, "Register event callbacks");
esp_lcd_rgb_panel_event_callbacks_t cbs = {
.on_vsync = val_on_vsync,
};
ESP_ERROR_CHECK(esp_lcd_rgb_panel_register_event_callbacks(lcd_panel, &cbs, display));
lv_indev_t *indev = lv_indev_create();
lv_indev_set_user_data(indev, touch_panel);
lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
lv_indev_set_read_cb(indev, val_lvgl_touch_read);
ESP_LOGI(TAG, "Install LVGL tick timer");
// Tick interface for LVGL (using esp_timer to generate 2ms periodic event)
const esp_timer_create_args_t lvgl_tick_timer_args = {
.callback = &val_increase_lvgl_tick,
.name = "lvgl_tick"
};
esp_timer_handle_t lvgl_tick_timer = NULL;
ESP_ERROR_CHECK(esp_timer_create(&lvgl_tick_timer_args, &lvgl_tick_timer));
ESP_ERROR_CHECK(esp_timer_start_periodic(lvgl_tick_timer, LVGL_TICK_PERIOD * 1000));
lvgl_mtx = xSemaphoreCreateRecursiveMutex();
assert(lvgl_mtx);
ESP_LOGI(TAG, "Create LVGL task");
xTaskCreate(val_lvgl_port_task, "LVGL", LVGL_TASK_STACK_SIZE, NULL, LVGL_TASK_PRIORITY, NULL);
return display;
}

16
firmware/main/lcd.h Normal file
View File

@@ -0,0 +1,16 @@
#pragma once
#include <inttypes.h>
#include "esp_lcd_panel_ops.h"
#include "esp_lcd_panel_rgb.h"
#include "esp_lcd_touch_gt911.h"
#include "lvgl.h"
bool val_lvgl_lock(int timeout_ms);
void val_lvgl_unlock(void);
esp_lcd_touch_handle_t val_setup_touch(void);
esp_lcd_panel_handle_t val_setup_lcd(void);
lv_display_t *val_setup_lvgl(esp_lcd_panel_handle_t lcd_panel, esp_lcd_touch_handle_t touch_panel);

755
firmware/main/ui.c Normal file
View File

@@ -0,0 +1,755 @@
#include "lvgl.h"
#include "ui.h"
const char *val_ext_gamemodes[] = {
"TDM",
"DEATHMATCH",
"ESCALATION",
"SPIKE RUSH",
"CUSTOM",
"GAME",
};
static const char* M_VAL_TIME = "VAL TIME?";
static const char *val_eco_titles[] = {
"BUY",
"SAVE",
"BONUS",
"MATCH TEAM",
};
static const char *val_eco_subtitles[] = {
"SPEND ALL YOUR MONEY!",
"MAKE SURE YOU CAN BUY NEXT ROUND!",
"KEEP YOUR GUN FROM LAST ROUND!",
"FOLLOW THE TEAM ECONOMY",
};
static const lv_font_t *font_normal = &lv_font_montserrat_24;
static lv_color_t color_primary, color_secondary;
static lv_color_t color_text_hero, color_text_subtitle;
static lv_color_t color_text_won, color_text_lost;
static lv_style_t s_hero, s_subtitle;
static lv_obj_t *o_container = NULL;
static lv_anim_timeline_t *at_active = NULL;
static lv_anim_timeline_t *at_active_rep = NULL;
static lv_obj_t *o_active = NULL;
static bool state_ready = true;
static const void* imgfont_get_path(
const lv_font_t *font, uint32_t unicode, uint32_t unicode_next, int32_t *offset_y, void *user_data) {
LV_UNUSED(font);
LV_UNUSED(unicode_next);
LV_UNUSED(offset_y);
LV_UNUSED(user_data);
switch (unicode) {
case 0x1F319:
return &ui_img_moon;
case 0x2B50:
return &ui_img_star;
case 0x1F634:
return &ui_img_sleep;
default:
return NULL;
}
}
static void b_cfg_cb(lv_event_t *e) {
lv_obj_t *box = lv_msgbox_create(NULL);
lv_msgbox_add_title(box, "Settings");
lv_msgbox_add_text(box, "Sorry, there aren't any right now.");
lv_msgbox_add_close_button(box);
}
static void setup_next_state() {
assert(state_ready);
state_ready = false;
if (at_active) {
lv_anim_timeline_delete(at_active);
at_active = NULL;
}
if (at_active_rep) {
lv_anim_timeline_delete(at_active_rep);
at_active_rep = NULL;
}
if (o_active) {
lv_obj_delete(o_active);
o_active = NULL;
}
o_active = lv_obj_create(o_container);
lv_obj_center(o_active);
lv_obj_set_style_bg_opa(o_active, LV_OPA_0, 0);
lv_obj_set_style_border_width(o_active, 0, 0);
lv_obj_set_size(o_active, lv_pct(100), lv_pct(100));
lv_obj_set_scrollbar_mode(o_active, LV_SCROLLBAR_MODE_OFF);
at_active = lv_anim_timeline_create();
at_active_rep = lv_anim_timeline_create();
lv_anim_timeline_set_repeat_count(at_active_rep, LV_ANIM_REPEAT_INFINITE);
}
bool val_ui_state_ready() {
return state_ready;
}
static void anim_state_ready_cb(lv_anim_t *anim) {
lv_anim_timeline_start(at_active_rep);
state_ready = true;
}
static void anim_x_cb(void *var, int32_t v) {
lv_obj_set_x(var, v);
}
static void anim_y_cb(void *var, int32_t v) {
lv_obj_set_y(var, v);
}
static void anim_opa_cb(void *var, int32_t v) {
lv_obj_set_style_opa(var, v, 0);
}
static void anim_val_time_text(lv_anim_t *anim) {
lv_label_set_text_static(anim->var, M_VAL_TIME);
}
void val_ui_none() {
setup_next_state();
// Widgets
lv_obj_t *l_main = lv_label_create(o_active);
lv_obj_add_style(l_main, &s_hero, 0);
lv_obj_center(l_main);
lv_label_set_text_static(l_main, M_VAL_TIME);
lv_obj_t *l_subtitle = lv_label_create(o_active);
lv_obj_add_style(l_subtitle, &s_subtitle, 0);
lv_obj_center(l_subtitle);
lv_label_set_text_static(l_subtitle, "SOME VAL IS ALWAYS ON THE MENU...");
lv_obj_update_layout(o_active);
lv_obj_set_pos(
l_subtitle,
-(lv_obj_get_width(l_main) - lv_obj_get_width(l_subtitle)) / 2,
(lv_obj_get_height(l_main) + lv_obj_get_height(l_subtitle)) / 2 + 5);
lv_label_set_text_static(l_main, "HELLO \U0001F319\u2B50!");
lv_obj_update_layout(o_active);
// Animations
lv_anim_t a_hello_in;
lv_anim_init(&a_hello_in);
lv_anim_set_var(&a_hello_in, l_main);
lv_anim_set_values(
&a_hello_in,
(lv_obj_get_height(o_container) - lv_obj_get_height(l_main)) / 2, 0);
lv_anim_set_exec_cb(&a_hello_in, anim_y_cb);
lv_anim_set_path_cb(&a_hello_in, lv_anim_path_ease_in);
lv_anim_set_duration(&a_hello_in, 500);
lv_anim_t a_hello_out;
lv_anim_init(&a_hello_out);
lv_anim_set_var(&a_hello_out, l_main);
lv_anim_set_values(&a_hello_out, 255, 0);
lv_anim_set_exec_cb(&a_hello_out, anim_opa_cb);
lv_anim_set_path_cb(&a_hello_out, lv_anim_path_linear);
lv_anim_set_duration(&a_hello_out, 500);
lv_anim_t a_val;
lv_anim_init(&a_val);
lv_anim_set_early_apply(&a_val, false);
lv_anim_set_var(&a_val, l_main);
lv_anim_set_values(&a_val, 0, 255);
lv_anim_set_start_cb(&a_val, anim_val_time_text);
lv_anim_set_exec_cb(&a_val, anim_opa_cb);
lv_anim_set_path_cb(&a_val, lv_anim_path_linear);
lv_anim_set_duration(&a_val, 500);
lv_anim_t a_sub;
lv_anim_init(&a_sub);
lv_anim_set_var(&a_sub, l_subtitle);
lv_anim_set_values(&a_sub, 0, 255);
lv_anim_set_exec_cb(&a_sub, anim_opa_cb);
lv_anim_set_path_cb(&a_sub, lv_anim_path_linear);
lv_anim_set_completed_cb(&a_sub, anim_state_ready_cb);
lv_anim_set_duration(&a_sub, 750);
lv_anim_timeline_add(at_active, 0, &a_hello_in);
lv_anim_timeline_add(at_active, 3000, &a_hello_out);
lv_anim_timeline_add(at_active, 3250, &a_val);
lv_anim_timeline_add(at_active, 3750, &a_sub);
lv_anim_timeline_start(at_active);
}
void val_ui_menu(bool was_idle) {
setup_next_state();
// Widgets
lv_obj_t *l_main = lv_label_create(o_active);
lv_obj_add_style(l_main, &s_hero, 0);
lv_obj_center(l_main);
lv_label_set_text_static(l_main, "LET'S GO");
lv_obj_t *l_subtitle = lv_label_create(o_active);
lv_obj_add_style(l_subtitle, &s_subtitle, 0);
lv_obj_center(l_subtitle);
lv_label_set_text_static(l_subtitle, was_idle ? "WELCOME BACK!" : "GET QUEUEING!");
lv_obj_update_layout(o_active);
// Animations
lv_anim_t a_title;
lv_anim_init(&a_title);
lv_anim_set_var(&a_title, l_main);
lv_anim_set_values(
&a_title,
-(lv_obj_get_height(o_container) + lv_obj_get_height(l_main)) / 2, 0);
lv_anim_set_exec_cb(&a_title, anim_y_cb);
lv_anim_set_path_cb(&a_title, lv_anim_path_ease_in);
lv_anim_set_duration(&a_title, 500);
lv_anim_t a_sub;
lv_anim_init(&a_sub);
lv_anim_set_var(&a_sub, l_subtitle);
lv_anim_set_values(
&a_sub,
(lv_obj_get_height(o_container) + lv_obj_get_height(l_subtitle)) / 2,
(lv_obj_get_height(l_main) + lv_obj_get_height(l_subtitle)) / 2 + 5);
lv_anim_set_exec_cb(&a_sub, anim_y_cb);
lv_anim_set_path_cb(&a_sub, lv_anim_path_ease_in);
lv_anim_set_completed_cb(&a_sub, anim_state_ready_cb);
lv_anim_set_duration(&a_sub, 500);
lv_anim_timeline_add(at_active, 0, &a_title);
lv_anim_timeline_add(at_active, 0, &a_sub);
// Repeating loop swing back and forth
lv_anim_t a_title_swing;
lv_anim_init(&a_title_swing);
lv_anim_set_early_apply(&a_title_swing, false);
lv_anim_set_var(&a_title_swing, l_main);
lv_anim_set_exec_cb(&a_title_swing, anim_x_cb);
// Left
lv_anim_set_path_cb(&a_title_swing, lv_anim_path_ease_out);
lv_anim_set_values(&a_title_swing, 0, -10);
lv_anim_set_duration(&a_title_swing, 500);
lv_anim_timeline_add(at_active_rep, 0, &a_title_swing);
// Left to right
lv_anim_set_path_cb(&a_title_swing, lv_anim_path_ease_in_out);
lv_anim_set_values(&a_title_swing, -10, 10);
lv_anim_set_duration(&a_title_swing, 1000);
lv_anim_timeline_add(at_active_rep, 500, &a_title_swing);
// Right to centre
lv_anim_set_path_cb(&a_title_swing, lv_anim_path_ease_in);
lv_anim_set_values(&a_title_swing, 10, 0);
lv_anim_set_duration(&a_title_swing, 500);
lv_anim_timeline_add(at_active_rep, 1500, &a_title_swing);
lv_anim_timeline_start(at_active);
}
void val_ui_idle() {
setup_next_state();
// Widgets
lv_obj_t *l_main = lv_label_create(o_active);
lv_obj_add_style(l_main, &s_hero, 0);
lv_obj_center(l_main);
lv_label_set_text_static(l_main, "\U0001F634");
lv_obj_t *l_subtitle = lv_label_create(o_active);
lv_obj_add_style(l_subtitle, &s_subtitle, 0);
lv_obj_center(l_subtitle);
lv_label_set_text_static(l_subtitle, "COME BACK SOON...");
lv_obj_update_layout(o_active);
lv_obj_set_y(
l_subtitle,
(lv_obj_get_height(l_main) + lv_obj_get_height(l_subtitle)) / 2 + 5);
// Animations
lv_anim_t a_fade_in;
lv_anim_init(&a_fade_in);
lv_anim_set_values(&a_fade_in, 0, 255);
lv_anim_set_exec_cb(&a_fade_in, anim_opa_cb);
lv_anim_set_path_cb(&a_fade_in, lv_anim_path_ease_in);
lv_anim_set_duration(&a_fade_in, 1000);
lv_anim_set_var(&a_fade_in, l_main);
lv_anim_timeline_add(at_active, 0, &a_fade_in);
lv_anim_set_var(&a_fade_in, l_subtitle);
lv_anim_set_completed_cb(&a_fade_in, anim_state_ready_cb);
lv_anim_timeline_add(at_active, 0, &a_fade_in);
lv_anim_timeline_start(at_active);
}
static void anim_text_color_mix_hero_sub(void *var, int32_t v) {
lv_obj_set_style_text_color(
var, lv_color_mix(color_text_hero, color_text_subtitle, v), 0);
}
void val_ui_queue_start(bool ms_not_comp) {
setup_next_state();
// Widgets
lv_obj_t *l_main = lv_label_create(o_active);
lv_obj_add_style(l_main, &s_hero, 0);
lv_obj_center(l_main);
lv_label_set_text_static(l_main, "SEARCHING...");
lv_obj_t *l_subtitle = lv_label_create(o_active);
lv_obj_add_style(l_subtitle, &s_subtitle, 0);
lv_obj_center(l_subtitle);
lv_label_set_text_static(l_subtitle, ms_not_comp ? "UHHH SHOULD THAT BE COMP?" : "HOPE YOU FIND A GAME QUICKLY!");
lv_obj_t *spinner = lv_spinner_create(o_active);
lv_obj_set_size(spinner, 100, 100);
lv_obj_center(spinner);
lv_obj_set_style_arc_color(spinner, color_text_hero, LV_PART_INDICATOR);
lv_spinner_set_anim_params(spinner, 1500, 200);
lv_obj_update_layout(o_active);
const int32_t offset = -60;
lv_obj_set_y(l_main, offset);
lv_obj_set_y(
l_subtitle,
offset + (lv_obj_get_height(l_main) + lv_obj_get_height(l_subtitle)) / 2 + 5);
lv_obj_set_y(
spinner,
lv_obj_get_height(o_container) / 4);
// Animations
lv_anim_t a_title;
lv_anim_init(&a_title);
lv_anim_set_var(&a_title, l_main);
lv_anim_set_values(
&a_title,
-(lv_obj_get_width(o_container) + lv_obj_get_width(l_main)) / 2, 0);
lv_anim_set_exec_cb(&a_title, anim_x_cb);
lv_anim_set_path_cb(&a_title, lv_anim_path_ease_out);
lv_anim_set_duration(&a_title, 750);
lv_anim_t a_sub;
lv_anim_init(&a_sub);
lv_anim_set_var(&a_sub, l_subtitle);
lv_anim_set_values(
&a_sub,
(lv_obj_get_width(o_container) + lv_obj_get_width(l_subtitle)) / 2, 0);
lv_anim_set_exec_cb(&a_sub, anim_x_cb);
lv_anim_set_path_cb(&a_sub, lv_anim_path_ease_out);
lv_anim_set_completed_cb(&a_sub, anim_state_ready_cb);
lv_anim_set_duration(&a_sub, 750);
lv_anim_timeline_add(at_active, 0, &a_title);
lv_anim_timeline_add(at_active, 0, &a_sub);
if (ms_not_comp) {
lv_anim_t a_warn;
lv_anim_init(&a_warn);
lv_anim_set_early_apply(&a_warn, false);
lv_anim_set_var(&a_warn, l_subtitle);
lv_anim_set_path_cb(&a_warn, lv_anim_path_linear);
lv_anim_set_duration(&a_warn, 1000);
lv_anim_set_exec_cb(&a_warn, anim_text_color_mix_hero_sub);
lv_anim_set_values(&a_warn, 0, 255);
lv_anim_timeline_add(at_active_rep, 0, &a_warn);
lv_anim_set_values(&a_warn, 255, 0);
lv_anim_timeline_add(at_active_rep, 1000, &a_warn);
}
lv_anim_timeline_start(at_active);
}
void val_ui_match_found(bool is_premier) {
setup_next_state();
// Widgets
lv_obj_t *l_main = lv_label_create(o_active);
lv_obj_add_style(l_main, &s_hero, 0);
lv_obj_center(l_main);
lv_label_set_text_static(l_main, "MATCH FOUND");
lv_obj_t *l_subtitle = lv_label_create(o_active);
lv_obj_add_style(l_subtitle, &s_subtitle, 0);
lv_obj_center(l_subtitle);
lv_label_set_text_static(l_subtitle, is_premier ? "DO THE COSMONAUTS PROUD!" : "I HOPE IT'S NOT SPLIT...");
lv_obj_update_layout(o_active);
// Animations
lv_anim_t a_title;
lv_anim_init(&a_title);
lv_anim_set_var(&a_title, l_main);
lv_anim_set_values(
&a_title,
(lv_obj_get_height(o_container) + lv_obj_get_height(l_main)) / 2, 0);
lv_anim_set_exec_cb(&a_title, anim_y_cb);
lv_anim_set_path_cb(&a_title, lv_anim_path_ease_in);
lv_anim_set_duration(&a_title, 500);
lv_anim_t a_sub;
lv_anim_init(&a_sub);
lv_anim_set_var(&a_sub, l_subtitle);
lv_anim_set_values(
&a_sub,
-(lv_obj_get_height(o_container) + lv_obj_get_height(l_main)) / 2,
(lv_obj_get_height(l_main) + lv_obj_get_height(l_subtitle)) / 2 + 5);
lv_anim_set_exec_cb(&a_sub, anim_y_cb);
lv_anim_set_path_cb(&a_sub, lv_anim_path_ease_in);
lv_anim_set_completed_cb(&a_sub, anim_state_ready_cb);
lv_anim_set_duration(&a_sub, 500);
lv_anim_timeline_add(at_active, 0, &a_title);
lv_anim_timeline_add(at_active, 0, &a_sub);
lv_anim_timeline_start(at_active);
}
void val_ui_pregame(bool is_split) {
setup_next_state();
// Widgets
lv_obj_t *l_main = lv_label_create(o_active);
lv_obj_add_style(l_main, &s_hero, 0);
lv_obj_center(l_main);
lv_label_set_text_static(l_main, "CHOOSE AGENT");
lv_obj_t *l_subtitle = lv_label_create(o_active);
lv_obj_add_style(l_subtitle, &s_subtitle, 0);
lv_obj_center(l_subtitle);
lv_label_set_text_static(l_subtitle, is_split ? "EWWW, SPLIT..." : "PICK SOMETHING GOOD!");
lv_obj_update_layout(o_active);
lv_obj_set_y(
l_subtitle,
(lv_obj_get_height(l_main) + lv_obj_get_height(l_subtitle)) / 2 + 5);
// Animations
lv_anim_t a_fade_in;
lv_anim_init(&a_fade_in);
lv_anim_set_values(&a_fade_in, 0, 255);
lv_anim_set_exec_cb(&a_fade_in, anim_opa_cb);
lv_anim_set_path_cb(&a_fade_in, lv_anim_path_ease_in);
lv_anim_set_duration(&a_fade_in, 500);
lv_anim_set_var(&a_fade_in, l_main);
lv_anim_timeline_add(at_active, 0, &a_fade_in);
lv_anim_set_var(&a_fade_in, l_subtitle);
lv_anim_set_completed_cb(&a_fade_in, anim_state_ready_cb);
lv_anim_timeline_add(at_active, 0, &a_fade_in);
lv_anim_t a_warn;
lv_anim_init(&a_warn);
lv_anim_set_early_apply(&a_warn, false);
lv_anim_set_var(&a_warn, l_main);
lv_anim_set_path_cb(&a_warn, lv_anim_path_linear);
lv_anim_set_duration(&a_warn, 2000);
lv_anim_set_exec_cb(&a_warn, anim_text_color_mix_hero_sub);
lv_anim_set_values(&a_warn, 255, 0);
lv_anim_timeline_add(at_active_rep, 0, &a_warn);
lv_anim_set_values(&a_warn, 0, 255);
lv_anim_timeline_add(at_active_rep, 2000, &a_warn);
lv_anim_timeline_start(at_active);
}
void val_ui_game_generic(const char *gamemode) {
setup_next_state();
// Widgets
lv_obj_t *l_main = lv_label_create(o_active);
lv_obj_add_style(l_main, &s_hero, 0);
lv_obj_center(l_main);
lv_label_set_text_static(l_main, "HAVE FUN!");
lv_obj_t *l_subtitle = lv_label_create(o_active);
lv_obj_add_style(l_subtitle, &s_subtitle, 0);
lv_obj_center(l_subtitle);
lv_label_set_text_fmt(l_subtitle, "ENJOY YOUR %s!", gamemode);
lv_obj_update_layout(o_active);
lv_obj_set_y(
l_subtitle,
(lv_obj_get_height(l_main) + lv_obj_get_height(l_subtitle)) / 2 + 5);
// Animations
lv_anim_t a_title;
lv_anim_init(&a_title);
lv_anim_set_var(&a_title, l_main);
lv_anim_set_values(
&a_title,
-(lv_obj_get_width(o_container) + lv_obj_get_width(l_main)) / 2, 0);
lv_anim_set_exec_cb(&a_title, anim_x_cb);
lv_anim_set_path_cb(&a_title, lv_anim_path_bounce);
lv_anim_set_duration(&a_title, 750);
lv_anim_t a_sub;
lv_anim_init(&a_sub);
lv_anim_set_var(&a_sub, l_subtitle);
lv_anim_set_values(
&a_sub,
-(lv_obj_get_width(o_container) + lv_obj_get_width(l_subtitle)) / 2,
-(lv_obj_get_width(l_main) - lv_obj_get_width(l_subtitle)) / 2);
lv_anim_set_exec_cb(&a_sub, anim_x_cb);
lv_anim_set_path_cb(&a_sub, lv_anim_path_bounce);
lv_anim_set_completed_cb(&a_sub, anim_state_ready_cb);
lv_anim_set_duration(&a_sub, 750);
lv_anim_timeline_add(at_active, 0, &a_title);
lv_anim_timeline_add(at_active, 0, &a_sub);
lv_anim_timeline_start(at_active);
}
void val_ui_game_start() {
setup_next_state();
// Widgets
lv_obj_t *l_main = lv_label_create(o_active);
lv_obj_add_style(l_main, &s_hero, 0);
lv_obj_center(l_main);
lv_label_set_text_static(l_main, "GOOD LUCK!");
state_ready = true;
}
static void anim_text_color_mix_hero_won(void *var, int32_t v) {
lv_obj_set_style_text_color(
var, lv_color_mix(color_text_hero, color_text_won, v), 0);
}
static void anim_text_color_mix_hero_lost(void *var, int32_t v) {
lv_obj_set_style_text_color(
var, lv_color_mix(color_text_hero, color_text_lost, v), 0);
}
void val_ui_round_start(uint8_t score, uint8_t score_enemy, val_won_t won, val_eco_decision_t eco) {
assert(won <= ROUND_NONE && eco <= ECO_MATCH_TEAM);
setup_next_state();
// Widgets
lv_obj_t *l_score = lv_label_create(o_active);
lv_obj_add_style(l_score, &s_hero, 0);
lv_obj_center(l_score);
lv_label_set_text_fmt(l_score, "%hhu-%hhu", score, score_enemy);
lv_obj_t *l_eco = lv_label_create(o_active);
lv_obj_add_style(l_eco, &s_hero, 0);
lv_obj_center(l_eco);
lv_label_set_text_static(l_eco, val_eco_titles[eco]);
lv_obj_t *l_advice = lv_label_create(o_active);
lv_obj_add_style(l_advice, &s_subtitle, 0);
lv_obj_center(l_advice);
lv_label_set_text_static(l_advice, val_eco_subtitles[eco]);
lv_obj_update_layout(o_active);
lv_obj_set_y(
l_score,
-(lv_obj_get_height(l_score) / 2) - 20);
lv_obj_set_y(
l_eco,
lv_obj_get_height(l_eco) / 2);
// Animations
// Score and eco fade in
lv_anim_t a_fade_in;
lv_anim_init(&a_fade_in);
lv_anim_set_values(&a_fade_in, 0, 255);
lv_anim_set_exec_cb(&a_fade_in, anim_opa_cb);
lv_anim_set_path_cb(&a_fade_in, lv_anim_path_ease_in);
lv_anim_set_duration(&a_fade_in, 750);
lv_anim_set_var(&a_fade_in, l_score);
lv_anim_timeline_add(at_active, 0, &a_fade_in);
lv_anim_set_var(&a_fade_in, l_eco);
lv_anim_timeline_add(at_active, 0, &a_fade_in);
// Advice slide up
lv_anim_t a_advice;
lv_anim_init(&a_advice);
lv_anim_set_var(&a_advice, l_advice);
lv_anim_set_values(
&a_advice,
(lv_obj_get_height(o_container) + lv_obj_get_height(l_advice)) / 2,
lv_obj_get_height(l_eco) + (lv_obj_get_height(l_advice) / 2) + 5);
lv_anim_set_exec_cb(&a_advice, anim_y_cb);
lv_anim_set_path_cb(&a_advice, lv_anim_path_ease_out);
lv_anim_set_duration(&a_advice, 750);
if (won != ROUND_NONE) {
// Score colour
lv_anim_t a_score;
lv_anim_init(&a_score);
lv_anim_set_var(&a_score, l_score);
lv_anim_set_path_cb(&a_score, lv_anim_path_ease_in);
lv_anim_set_exec_cb(
&a_score,
won == ROUND_WON ? anim_text_color_mix_hero_won : anim_text_color_mix_hero_lost);
lv_anim_set_duration(&a_score, 2000);
lv_anim_set_values(&a_score, 0, 255);
lv_anim_set_completed_cb(&a_score, anim_state_ready_cb);
lv_anim_timeline_add(at_active, 0, &a_score);
} else {
lv_anim_set_completed_cb(&a_advice, anim_state_ready_cb);
}
lv_anim_timeline_add(at_active, 0, &a_advice);
// Eco flash
lv_anim_t a_eco;
lv_anim_init(&a_eco);
lv_anim_set_early_apply(&a_eco, false);
lv_anim_set_var(&a_eco, l_eco);
lv_anim_set_path_cb(&a_eco, lv_anim_path_linear);
lv_anim_set_duration(&a_eco, 1000);
lv_anim_set_exec_cb(&a_eco, anim_text_color_mix_hero_sub);
lv_anim_set_values(&a_eco, 255, 0);
lv_anim_timeline_add(at_active_rep, 0, &a_eco);
lv_anim_set_values(&a_eco, 0, 255);
lv_anim_timeline_add(at_active_rep, 1000, &a_eco);
lv_anim_timeline_start(at_active);
}
void val_ui_game_over(bool won) {
setup_next_state();
// Widgets
lv_obj_t *l_main = lv_label_create(o_active);
lv_obj_add_style(l_main, &s_hero, 0);
lv_obj_center(l_main);
lv_label_set_text_static(l_main, won ? "VICTORY" : "DEFEAT");
lv_obj_set_style_text_color(l_main, won ? color_text_won : color_text_lost, 0);
lv_obj_t *l_subtitle = lv_label_create(o_active);
lv_obj_add_style(l_subtitle, &s_subtitle, 0);
lv_obj_center(l_subtitle);
lv_label_set_text_static(l_subtitle, won ? "WELL PLAYED!" : "HARD LUCK...");
lv_obj_update_layout(o_active);
lv_obj_set_y(
l_subtitle,
(lv_obj_get_height(l_main) + lv_obj_get_height(l_subtitle)) / 2 + 5);
// Animations
if (won) {
lv_anim_t a_fly;
lv_anim_init(&a_fly);
lv_anim_set_path_cb(&a_fly, lv_anim_path_ease_out);
lv_anim_set_duration(&a_fly, 750);
// Main text from top-left
// Y
lv_anim_set_var(&a_fly, l_main);
lv_anim_set_exec_cb(&a_fly, anim_y_cb);
lv_anim_set_values(
&a_fly,
-(lv_obj_get_height(o_container) + lv_obj_get_height(l_main)) / 2, 0);
lv_anim_timeline_add(at_active, 0, &a_fly);
// X
lv_anim_set_exec_cb(&a_fly, anim_x_cb);
lv_anim_set_values(
&a_fly,
-(lv_obj_get_width(o_container) + lv_obj_get_width(l_main)) / 2, 0);
lv_anim_timeline_add(at_active, 0, &a_fly);
// Subtitle from bottom-right
// Y
lv_anim_set_var(&a_fly, l_subtitle);
lv_anim_set_exec_cb(&a_fly, anim_y_cb);
lv_anim_set_values(
&a_fly,
(lv_obj_get_height(o_container) + lv_obj_get_height(l_subtitle)) / 2,
(lv_obj_get_height(l_main) + lv_obj_get_height(l_subtitle)) / 2 + 5);
lv_anim_timeline_add(at_active, 0, &a_fly);
// X
lv_anim_set_exec_cb(&a_fly, anim_x_cb);
lv_anim_set_values(
&a_fly,
(lv_obj_get_width(o_container) + lv_obj_get_width(l_subtitle)) / 2, 0);
lv_anim_set_completed_cb(&a_fly, anim_state_ready_cb);
lv_anim_timeline_add(at_active, 0, &a_fly);
} else {
lv_anim_t a_fade_in;
lv_anim_init(&a_fade_in);
lv_anim_set_values(&a_fade_in, 0, 255);
lv_anim_set_exec_cb(&a_fade_in, anim_opa_cb);
lv_anim_set_path_cb(&a_fade_in, lv_anim_path_ease_in);
lv_anim_set_duration(&a_fade_in, 1000);
lv_anim_set_var(&a_fade_in, l_main);
lv_anim_timeline_add(at_active, 0, &a_fade_in);
lv_anim_set_var(&a_fade_in, l_subtitle);
lv_anim_set_completed_cb(&a_fade_in, anim_state_ready_cb);
lv_anim_timeline_add(at_active, 0, &a_fade_in);
}
lv_anim_timeline_start(at_active);
}
void val_lvgl_ui(lv_display_t *disp) {
color_primary = lv_color_hex(0xff4655);
color_secondary = lv_color_hex(0xf7518f);
color_text_hero = lv_palette_lighten(LV_PALETTE_GREY, 2);
color_text_subtitle = lv_palette_darken(LV_PALETTE_GREY, 1);
color_text_won = lv_palette_lighten(LV_PALETTE_GREEN, 1);
color_text_lost = lv_palette_darken(LV_PALETTE_RED, 4);
// init default theme
lv_theme_default_init(
disp, color_primary, color_secondary,
true, // dark theme
font_normal);
lv_sysmon_hide_performance(disp);
lv_font_t *f_hero_emoji = lv_imgfont_create(120, imgfont_get_path, NULL);
assert(f_hero_emoji);
f_hero_emoji->fallback = &lv_font_tungsten_180;
lv_style_init(&s_hero);
lv_style_set_text_font(&s_hero, f_hero_emoji);
lv_style_set_text_color(&s_hero, color_text_hero);
lv_style_init(&s_subtitle);
lv_style_set_text_font(&s_subtitle, &lv_font_tungsten_40);
lv_style_set_text_color(&s_subtitle, color_text_subtitle);
// Background
lv_obj_t *i_bg = lv_image_create(lv_screen_active());
lv_image_set_src(i_bg, &ui_img_bg);
// Content container
o_container = lv_obj_create(lv_screen_active());
lv_obj_set_style_bg_opa(o_container, LV_OPA_0, 0);
lv_obj_set_style_border_width(o_container, 0, 0);
lv_obj_set_style_pad_all(o_container, 0, 0);
lv_obj_set_size(o_container, lv_pct(100), lv_pct(100));
lv_obj_set_scrollbar_mode(o_container, LV_SCROLLBAR_MODE_OFF);
// Settings button
lv_obj_t *b_cfg = lv_button_create(lv_screen_active());
lv_obj_align(b_cfg, LV_ALIGN_BOTTOM_RIGHT, -10, -10);
lv_obj_add_event_cb(b_cfg, b_cfg_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *l_cfg = lv_label_create(b_cfg);
lv_label_set_text(l_cfg, LV_SYMBOL_SETTINGS);
lv_obj_center(l_cfg);
val_ui_none();
}

41
firmware/main/ui.h Normal file
View File

@@ -0,0 +1,41 @@
#pragma once
#include "lvgl.h"
extern const char *val_ext_gamemodes[];
#define VAL_EXT_GAMEMODES_SIZE 6
typedef enum val_eco_decision {
ECO_BUY,
ECO_SAVE,
ECO_BONUS,
ECO_MATCH_TEAM,
} val_eco_decision_t;
typedef enum val_won {
ROUND_LOST,
ROUND_WON,
ROUND_NONE,
} val_won_t;
LV_FONT_DECLARE(lv_font_tungsten_40)
LV_FONT_DECLARE(lv_font_tungsten_180)
LV_IMAGE_DECLARE(ui_img_bg);
LV_IMAGE_DECLARE(ui_img_moon);
LV_IMAGE_DECLARE(ui_img_star);
LV_IMAGE_DECLARE(ui_img_sleep);
bool val_ui_state_ready();
void val_ui_none();
void val_ui_menu(bool was_idle);
void val_ui_idle();
void val_ui_queue_start(bool ms_not_comp);
void val_ui_match_found(bool is_premier);
void val_ui_pregame(bool is_split);
void val_ui_game_generic(const char *gamemode);
void val_ui_game_start();
void val_ui_round_start(uint8_t score, uint8_t score_enemy, val_won_t won, val_eco_decision_t eco);
void val_ui_game_over(bool won);
void val_lvgl_ui(lv_display_t *disp);

196
firmware/main/usb.c Normal file
View File

@@ -0,0 +1,196 @@
#include <inttypes.h>
#include "esp_log.h"
#include "tinyusb.h"
#include "class/hid/hid_device.h"
#include "common.h"
#include "usb.h"
#include "lcd.h"
#include "ui.h"
#define TUSB_DESC_TOTAL_LEN (TUD_CONFIG_DESC_LEN + TUD_HID_INOUT_DESC_LEN)
static const char *TAG = "valconomy-usb";
// See https://github.com/hathach/tinyusb/blob/cb22301f91f0465a5578be35d9be284657ddd31d/src/common/tusb_types.h#L331
const tusb_desc_device_t val_usb_dev_descriptor = {
.bLength = sizeof(tusb_desc_device_t),
.bDescriptorType = TUSB_DESC_DEVICE,
// BCD-coded USB version (?)
.bcdUSB = 0x0200,
.bDeviceClass = 0x00,
.bDeviceSubClass = 0x00,
.bDeviceProtocol = 0x00,
.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
.idVendor = 0x6969,
.idProduct = 0x0004,
// BCD-coded device version (0.1)
.bcdDevice = 0x0010,
// Indices of strings
.iManufacturer = 0x01,
.iProduct = 0x02,
.iSerialNumber = 0x03,
.bNumConfigurations = 0x01,
};
const char* val_usb_string_descriptor[5] = {
// 0: supported language is English (0x0409)
(char[]){0x09, 0x04},
// 1: Manufacturer
"/dev/player0",
// 2: Product
"Valorant Economy Helper",
// 3: Serials, should use chip ID
val_dev_serial,
// 4: HID
"Valconomy HID interface",
};
const uint8_t val_hid_report_descriptor[] = {
TUD_HID_REPORT_DESC_GENERIC_INOUT(USB_EP_BUFSIZE),
};
const uint8_t val_hid_conf_descriptor[] = {
// Configuration number, interface count, string index, total length, attribute, power in mA
TUD_CONFIG_DESCRIPTOR(1, 1, 0, TUSB_DESC_TOTAL_LEN, TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100),
// HID requires an IN endpoint even if we don't care about it
// Interface number, string index, protocol, report descriptor len, EP Out & In address, size & polling interval (ms)
// 0x80 in endpoint address indicates IN
TUD_HID_INOUT_DESCRIPTOR(0, 4, HID_ITF_PROTOCOL_NONE, sizeof(val_hid_report_descriptor), EPNUM_HID, 0x80 | EPNUM_HID, USB_EP_BUFSIZE, 100)
};
// TinyUSB callbacks
// Invoked when received GET HID REPORT DESCRIPTOR request
// Application return pointer to descriptor, whose contents must exist long enough for transfer to complete
uint8_t const *tud_hid_descriptor_report_cb(uint8_t instance) {
// We use only one interface and one HID report descriptor, so we can ignore parameter 'instance'
return val_hid_report_descriptor;
}
// Invoked when received GET_REPORT control request
// Application must fill buffer report's content and return its length.
// Return zero will cause the stack to STALL request
uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_t report_type, uint8_t* buf, uint16_t reqlen) {
(void) instance;
if (report_id != 0 || reqlen < 1) {
return 0;
}
// ESP_LOGI(TAG, "Got %hu bytes report %hhu", reqlen, report_id);
// for (uint16_t i = 0; i < reqlen; i++) {
// ESP_LOGI(TAG, "b: %02hhx", buf[i]);
// }
buf[0] = val_ui_state_ready();
return 1;
}
// Invoked when received SET_REPORT control request or
// received data on OUT endpoint ( Report ID = 0, Type = 0 )
void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_t report_type, uint8_t const* buf, uint16_t bufsize) {
(void) instance;
assert(report_id == 0 && report_type == HID_REPORT_TYPE_OUTPUT && bufsize >= 1);
if (!val_lvgl_lock(-1)) {
ESP_LOGE(TAG, "Failed to grab LVGL lock");
return;
}
if (buf[0] > ST_GAME_OVER) {
ESP_LOGW(TAG, "Unknown state %hhu", buf[0]);
goto ret;
}
if (!val_ui_state_ready()) {
goto ret;
}
switch (buf[0]) {
case ST_NONE:
val_ui_none();
break;
case ST_MENU:
if (bufsize < 2) {
ESP_LOGE(TAG, "Invalid ST_MENU command");
goto ret;
}
val_ui_menu((bool)buf[1]);
break;
case ST_IDLE:
val_ui_idle();
break;
case ST_QUEUE_START:
if (bufsize < 2) {
ESP_LOGE(TAG, "Invalid ST_QUEUE_START command");
goto ret;
}
val_ui_queue_start((bool)buf[1]);
break;
case ST_MATCH_FOUND:
if (bufsize < 2) {
ESP_LOGE(TAG, "Invalid ST_MATCH_FOUND command");
goto ret;
}
val_ui_match_found((bool)buf[1]);
break;
case ST_PREGAME:
if (bufsize < 2) {
ESP_LOGE(TAG, "Invalid ST_PREGAME command");
goto ret;
}
val_ui_pregame((bool)buf[1]);
break;
case ST_GAME_GENERIC:
if (bufsize < 2 || buf[1] > VAL_EXT_GAMEMODES_SIZE) {
ESP_LOGE(TAG, "Invalid ST_PREGAME command");
goto ret;
}
val_ui_game_generic(val_ext_gamemodes[buf[1]]);
break;
case ST_GAME_START:
val_ui_game_start();
break;
case ST_ROUND_START:
if (bufsize < 5 || buf[3] > ROUND_NONE || buf[4] > ECO_MATCH_TEAM) {
ESP_LOGE(TAG, "Invalid ST_ROUND_START command");
goto ret;
}
val_ui_round_start(buf[1], buf[2], buf[3], buf[4]);
break;
case ST_GAME_OVER:
if (bufsize < 2) {
ESP_LOGE(TAG, "Invalid ST_GAME_OVER command");
goto ret;
}
val_ui_game_over((bool)buf[1]);
break;
}
ret:
val_lvgl_unlock();
}
void val_usb_init(void) {
ESP_LOGI(TAG, "Initializing USB");
const tinyusb_config_t cfg = {
.device_descriptor = &val_usb_dev_descriptor,
.string_descriptor = val_usb_string_descriptor,
.string_descriptor_count = sizeof(val_usb_string_descriptor) / sizeof(char *),
.external_phy = false,
#if (TUD_OPT_HIGH_SPEED)
// HID configuration descriptor for full-speed and high-speed are the same
.fs_configuration_descriptor = val_hid_conf_descriptor,
.hs_configuration_descriptor = val_hid_conf_descriptor,
.qualifier_descriptor = NULL,
#else
.configuration_descriptor = val_hid_conf_descriptor,
#endif // TUD_OPT_HIGH_SPEED
};
ESP_ERROR_CHECK(tinyusb_driver_install(&cfg));
}

21
firmware/main/usb.h Normal file
View File

@@ -0,0 +1,21 @@
#pragma once
#include "tinyusb_types.h"
#define EPNUM_HID 0x01
#define USB_EP_BUFSIZE 64
typedef enum val_state {
ST_NONE = 0,
ST_MENU,
ST_IDLE,
ST_QUEUE_START,
ST_MATCH_FOUND,
ST_PREGAME,
ST_GAME_GENERIC,
ST_GAME_START,
ST_ROUND_START,
ST_GAME_OVER,
} val_state_t;
void val_usb_init(void);

View File

@@ -1,46 +1,76 @@
#include <stdio.h>
#include <inttypes.h>
#include "sdkconfig.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_chip_info.h"
#include "esp_flash.h"
#include "esp_system.h"
#include "esp_err.h"
#include "esp_log.h"
#include "esp_mac.h"
#include "esp_lcd_panel_ops.h"
#include "driver/i2c_master.h"
#include "lvgl.h"
#include "common.h"
#include "usb.h"
#include "lcd.h"
#include "ui.h"
static const char *TAG = "valconomy";
char val_dev_serial[13];
i2c_master_bus_handle_t i2c_bus_handle;
i2c_master_dev_handle_t exio_cfg_handle, exio_o_handle;
static void val_i2c_master_init(void) {
ESP_LOGI(TAG, "Init I2C");
i2c_master_bus_config_t i2c_mst_config = {
.clk_source = I2C_CLK_SRC_DEFAULT,
.i2c_port = I2C_NUM_0,
.scl_io_num = 9,
.sda_io_num = 8,
.glitch_ignore_cnt = 7,
.flags.enable_internal_pullup = true,
};
ESP_ERROR_CHECK(i2c_new_master_bus(&i2c_mst_config, &i2c_bus_handle));
i2c_device_config_t exio_dev_cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = 0x24, // CH422G config register
.scl_speed_hz = SCL_SPEED,
};
ESP_ERROR_CHECK(i2c_master_bus_add_device(i2c_bus_handle, &exio_dev_cfg, &exio_cfg_handle));
exio_dev_cfg.device_address = 0x38;
ESP_ERROR_CHECK(i2c_master_bus_add_device(i2c_bus_handle, &exio_dev_cfg, &exio_o_handle));
}
void app_main(void) {
printf("Hello world!\n");
// Use MAC address for serial number
uint8_t mac[6] = { 0 };
ESP_ERROR_CHECK(esp_read_mac(mac, ESP_MAC_EFUSE_FACTORY));
snprintf(
val_dev_serial, sizeof(val_dev_serial),
"%02hhx%02hhx%02hhx%02hhx%02hhx%02hhx", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
/* Print chip information */
esp_chip_info_t chip_info;
uint32_t flash_size;
esp_chip_info(&chip_info);
printf("This is %s chip with %d CPU core(s), %s%s%s%s, ",
CONFIG_IDF_TARGET,
chip_info.cores,
(chip_info.features & CHIP_FEATURE_WIFI_BGN) ? "WiFi/" : "",
(chip_info.features & CHIP_FEATURE_BT) ? "BT" : "",
(chip_info.features & CHIP_FEATURE_BLE) ? "BLE" : "",
(chip_info.features & CHIP_FEATURE_IEEE802154) ? ", 802.15.4 (Zigbee/Thread)" : "");
val_i2c_master_init();
unsigned major_rev = chip_info.revision / 100;
unsigned minor_rev = chip_info.revision % 100;
printf("silicon revision v%d.%d, ", major_rev, minor_rev);
if(esp_flash_get_size(NULL, &flash_size) != ESP_OK) {
printf("Get flash size failed");
return;
}
val_usb_init();
printf("%" PRIu32 "MB %s flash\n", flash_size / (uint32_t)(1024 * 1024),
(chip_info.features & CHIP_FEATURE_EMB_FLASH) ? "embedded" : "external");
esp_lcd_panel_handle_t lcd_panel = val_setup_lcd();
assert(lcd_panel);
esp_lcd_touch_handle_t touch_panel = val_setup_touch();
assert(touch_panel);
printf("Minimum free heap size: %" PRIu32 " bytes\n", esp_get_minimum_free_heap_size());
lv_display_t *display = val_setup_lvgl(lcd_panel, touch_panel);
assert(display);
for (int i = 10; i >= 0; i--) {
printf("Restarting in %d seconds...\n", i);
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
printf("Restarting now.\n");
fflush(stdout);
esp_restart();
ESP_LOGI(TAG, "Display LVGL UI");
// Lock the mutex due to the LVGL APIs are not thread-safe
assert(val_lvgl_lock(-1));
val_lvgl_ui(display);
val_lvgl_unlock();
}

5
firmware/partitions.csv Normal file
View File

@@ -0,0 +1,5 @@
# ESP-IDF Partition Table
# Name, Type, SubType, Offset, Size, Flags
nvs,data,nvs,0x9000,24K,
phy_init,data,phy,0xf000,4K,
factory,app,factory,0x10000,4M,
1 # ESP-IDF Partition Table
2 # Name, Type, SubType, Offset, Size, Flags
3 nvs,data,nvs,0x9000,24K,
4 phy_init,data,phy,0xf000,4K,
5 factory,app,factory,0x10000,4M,

View File

@@ -1,4 +1,30 @@
# This file was generated using idf.py save-defconfig. It can be edited manually.
# Espressif IoT Development Framework (ESP-IDF) 5.3.1 Project Minimal Configuration
#
CONFIG_IDF_TARGET="esp32s3"
CONFIG_ESPTOOLPY_FLASH_MODE_AUTO_DETECT=n
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
CONFIG_ESPTOOLPY_FLASHFREQ_120M=y
CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_COMPILER_OPTIMIZATION_PERF=y
CONFIG_USJ_ENABLE_USB_SERIAL_JTAG=n
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_OCT=y
CONFIG_SPIRAM_FETCH_INSTRUCTIONS=y
CONFIG_SPIRAM_RODATA=y
CONFIG_SPIRAM_SPEED_120M=y
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y
CONFIG_ESP32S3_DATA_CACHE_LINE_64B=y
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
CONFIG_ESP_WIFI_DPP_SUPPORT=y
CONFIG_FREERTOS_HZ=1000
CONFIG_TINYUSB_DEBUG_LEVEL=0
CONFIG_TINYUSB_HID_COUNT=1
CONFIG_LV_USE_LOG=y
CONFIG_LV_LOG_PRINTF=y
CONFIG_LV_FONT_MONTSERRAT_24=y
CONFIG_LV_USE_SYSMON=y
CONFIG_LV_USE_PERF_MONITOR=y
CONFIG_LV_USE_IMGFONT=y
CONFIG_IDF_EXPERIMENTAL_FEATURES=y

384
flake.lock generated Normal file
View File

@@ -0,0 +1,384 @@
{
"nodes": {
"cachix": {
"inputs": {
"devenv": [
"devenv"
],
"flake-compat": [
"devenv"
],
"git-hooks": [
"devenv"
],
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1728672398,
"narHash": "sha256-KxuGSoVUFnQLB2ZcYODW7AVPAh9JqRlD5BrfsC/Q4qs=",
"owner": "cachix",
"repo": "cachix",
"rev": "aac51f698309fd0f381149214b7eee213c66ef0a",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"devenv": {
"inputs": {
"cachix": "cachix",
"flake-compat": "flake-compat",
"git-hooks": "git-hooks",
"nix": "nix",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1733788855,
"narHash": "sha256-sGn4o9KFoGRSWDQlBKpv8dkNQ2/ODS9APopZD1/FP2Y=",
"owner": "cachix",
"repo": "devenv",
"rev": "d59fee8696cd48f69cf79f65992269df9891ba86",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"esp-dev": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1733098767,
"narHash": "sha256-XLxNWOclBjSrzbbLQoOUNWyuF306/R0n4mMGxT3a698=",
"owner": "mirrexagon",
"repo": "nixpkgs-esp-dev",
"rev": "31ee58005f43e93a6264e3667c9bf5c31b368733",
"type": "github"
},
"original": {
"owner": "mirrexagon",
"repo": "nixpkgs-esp-dev",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-compat_2": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"devenv",
"nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1712014858,
"narHash": "sha256-sB4SWl2lX95bExY2gMFG5HIzvva5AVMJd4Igm+GpZNw=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "9126214d0a59633752a136528f5f3b9aa8565b7d",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-parts_2": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1733312601,
"narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1726560853,
"narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": [
"devenv"
],
"gitignore": "gitignore",
"nixpkgs": [
"devenv",
"nixpkgs"
],
"nixpkgs-stable": [
"devenv"
]
},
"locked": {
"lastModified": 1730302582,
"narHash": "sha256-W1MIJpADXQCgosJZT8qBYLRuZls2KSiKdpnTVdKBuvU=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "af8a16fe5c264f5e9e18bcee2859b40a656876cf",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"devenv",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"libgit2": {
"flake": false,
"locked": {
"lastModified": 1697646580,
"narHash": "sha256-oX4Z3S9WtJlwvj0uH9HlYcWv+x1hqp8mhXl7HsLu2f0=",
"owner": "libgit2",
"repo": "libgit2",
"rev": "45fd9ed7ae1a9b74b957ef4f337bc3c8b3df01b5",
"type": "github"
},
"original": {
"owner": "libgit2",
"repo": "libgit2",
"type": "github"
}
},
"nix": {
"inputs": {
"flake-compat": [
"devenv"
],
"flake-parts": "flake-parts",
"libgit2": "libgit2",
"nixpkgs": "nixpkgs_2",
"nixpkgs-23-11": [
"devenv"
],
"nixpkgs-regression": [
"devenv"
],
"pre-commit-hooks": [
"devenv"
]
},
"locked": {
"lastModified": 1727438425,
"narHash": "sha256-X8ES7I1cfNhR9oKp06F6ir4Np70WGZU5sfCOuNBEwMg=",
"owner": "domenkozar",
"repo": "nix",
"rev": "f6c5ae4c1b2e411e6b1e6a8181cc84363d6a7546",
"type": "github"
},
"original": {
"owner": "domenkozar",
"ref": "devenv-2.24",
"repo": "nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1730531603,
"narHash": "sha256-Dqg6si5CqIzm87sp57j5nTaeBbWhHFaVyG7V6L8k3lY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7ffd9ae656aec493492b44d0ddfb28e79a1ea25d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1733096140,
"narHash": "sha256-1qRH7uAUsyQI7R1Uwl4T+XvdNv778H0Nb5njNrqvylY=",
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/5487e69da40cbd611ab2cadee0b4637225f7cfae.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/5487e69da40cbd611ab2cadee0b4637225f7cfae.tar.gz"
}
},
"nixpkgs-python": {
"inputs": {
"flake-compat": "flake-compat_2",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1733319315,
"narHash": "sha256-cFQBdRmtIZFVjr2P6NkaCOp7dddF93BC0CXBwFZFaN0=",
"owner": "cachix",
"repo": "nixpkgs-python",
"rev": "01263eeb28c09f143d59cd6b0b7c4cc8478efd48",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "nixpkgs-python",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1717432640,
"narHash": "sha256-+f9c4/ZX5MWDOuB1rKoWj+lBNm0z0rs4CK47HBLxy1o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "88269ab3044128b7c2f4c7d68448b2fb50456870",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "release-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1733749988,
"narHash": "sha256-+5qdtgXceqhK5ZR1YbP1fAUsweBIrhL38726oIEAtDs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "bc27f0fde01ce4e1bfec1ab122d72b7380278e68",
"type": "github"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"esp-dev": "esp-dev",
"flake-parts": "flake-parts_2",
"nixpkgs": "nixpkgs_3",
"nixpkgs-python": "nixpkgs-python",
"rootdir": "rootdir"
}
},
"rootdir": {
"flake": false,
"locked": {
"narHash": "sha256-d6xi4mKdjkX2JFicDIv5niSzpyI0m/Hnm8GGAIU04kY=",
"type": "file",
"url": "file:///dev/null"
},
"original": {
"type": "file",
"url": "file:///dev/null"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -4,6 +4,8 @@
devenv.url = "github:cachix/devenv";
devenv.inputs.nixpkgs.follows = "nixpkgs";
nixpkgs-python.url = "github:cachix/nixpkgs-python";
nixpkgs-python.inputs.nixpkgs.follows = "nixpkgs";
esp-dev.url = "github:mirrexagon/nixpkgs-esp-dev";
esp-dev.inputs.nixpkgs.follows = "nixpkgs";
@@ -19,6 +21,7 @@
devenv.flakeModule
./firmware
./controller
];
systems = [ "x86_64-linux" ];