├── .dev └── packager.sh ├── .gitignore ├── Hardware-Test.py ├── Install.sh ├── LICENCE ├── README.md ├── Uninstall.sh ├── Update.sh ├── V-Link.py ├── backend ├── __init__.py ├── adc.py ├── app.py ├── buttonHandler.py ├── can.py ├── config │ ├── _readme.txt │ ├── adc.json │ ├── app.json │ ├── can.json │ ├── lin.json │ ├── mmi.json │ └── rti.json ├── dev │ ├── lin_test.txt │ ├── setup.sh │ └── vcan.py ├── ign.py ├── lin.py ├── rti.py ├── server.py ├── settings.py └── shared │ └── shared_state.py ├── frontend ├── .babelrc ├── .eslintrc.cjs ├── config-overrides.js ├── index.html ├── package-lock.json ├── package.json ├── public │ ├── assets │ │ ├── audio.worklet.js │ │ ├── fonts │ │ │ ├── Inter18pt-LightItalic.woff │ │ │ ├── Inter18pt-LightItalic.woff2 │ │ │ ├── Inter18pt-SemiBold.woff │ │ │ ├── Inter18pt-SemiBold.woff2 │ │ │ ├── LeagueSpartan-Bold.woff │ │ │ ├── LeagueSpartan-Bold.woff2 │ │ │ ├── LeagueSpartan-Regular.woff │ │ │ └── LeagueSpartan-Regular.woff2 │ │ └── svg │ │ │ ├── background │ │ │ ├── glow.svg │ │ │ ├── horizon.svg │ │ │ └── road.svg │ │ │ ├── banner.svg │ │ │ ├── buttons │ │ │ ├── carplay.svg │ │ │ ├── dashboard.svg │ │ │ ├── general.svg │ │ │ ├── interface.svg │ │ │ ├── keymap.svg │ │ │ ├── link.svg │ │ │ ├── settings.svg │ │ │ └── system.svg │ │ │ ├── gauges │ │ │ └── race.svg │ │ │ ├── icons │ │ │ ├── data │ │ │ │ ├── col.svg │ │ │ │ ├── err.svg │ │ │ │ ├── iat.svg │ │ │ │ ├── ld1.svg │ │ │ │ ├── ld2.svg │ │ │ │ ├── map.svg │ │ │ │ ├── oilp.svg │ │ │ │ ├── oilt.svg │ │ │ │ └── spd.svg │ │ │ └── interface │ │ │ │ ├── phone.svg │ │ │ │ └── wifi.svg │ │ │ ├── logo.svg │ │ │ ├── logos │ │ │ ├── moose.svg │ │ │ ├── typo.svg │ │ │ └── vlink.svg │ │ │ └── react.svg │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ ├── robots.txt │ └── vite.svg ├── src │ ├── App.css │ ├── App.tsx │ ├── app │ │ ├── Content.tsx │ │ ├── Splash.tsx │ │ ├── components │ │ │ ├── DataBox.tsx │ │ │ ├── DataChart.tsx │ │ │ ├── DataList.tsx │ │ │ ├── LinearGauge.tsx │ │ │ ├── Modal.tsx │ │ │ ├── Pagination.tsx │ │ │ └── RadialGauge.tsx │ │ ├── helper │ │ │ ├── EventEmitter.tsx │ │ │ └── HexToRGBA.tsx │ │ ├── pages │ │ │ ├── carplay │ │ │ │ └── Carplay.tsx │ │ │ ├── dashboard │ │ │ │ ├── Dashboard.tsx │ │ │ │ ├── charts │ │ │ │ │ └── Charts.tsx │ │ │ │ ├── classic │ │ │ │ │ └── Classic.tsx │ │ │ │ └── race │ │ │ │ │ └── Race.tsx │ │ │ └── settings │ │ │ │ └── Settings.tsx │ │ └── sidebars │ │ │ ├── NavBar.tsx │ │ │ ├── SideBar.tsx │ │ │ └── TopBar.tsx │ ├── cardata │ │ ├── Cardata.tsx │ │ ├── helper │ │ │ ├── Display.tsx │ │ │ ├── Ignition.tsx │ │ │ └── Recorder.tsx │ │ └── worker │ │ │ ├── ADC.worker.ts │ │ │ ├── CAN.worker.ts │ │ │ └── types.ts │ ├── carplay │ │ ├── Carplay.tsx │ │ ├── Config.tsx │ │ ├── useCarplayAudio.ts │ │ ├── useCarplayTouch.ts │ │ └── worker │ │ │ ├── CarPlay.worker.ts │ │ │ ├── render │ │ │ ├── Render.worker.ts │ │ │ ├── RenderEvents.ts │ │ │ ├── WebGL2Renderer.ts │ │ │ ├── WebGLRenderer.ts │ │ │ ├── WebGPURenderer.ts │ │ │ └── lib │ │ │ │ ├── h264-utils.ts │ │ │ │ └── utils.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ ├── index.css │ ├── logo.svg │ ├── main.tsx │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── setupProxy.js │ ├── setupTests.ts │ ├── socket │ │ └── Socket.tsx │ ├── store │ │ ├── Store.ts │ │ └── Types.tsx │ ├── theme │ │ ├── Animations.js │ │ ├── Theme.js │ │ ├── fonts.module.css │ │ └── styles │ │ │ ├── Container.js │ │ │ ├── Effects.js │ │ │ ├── Icons.js │ │ │ ├── Inputs.js │ │ │ └── Typography.js │ ├── themes.scss │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── types │ └── ringbuf.js │ │ └── index.d.ts └── vite.config.ts ├── requirements.txt └── resources ├── bosch └── bosch_0261230482.jpg ├── dtoverlays ├── mcp2515-can0.dtbo ├── mcp2515-can1.dtbo ├── mcp2515-can2.dtbo └── v-link.dtbo ├── lcd └── RTI_LCD_Mount_v1.4..stl ├── media ├── banner.jpg ├── display_mount.jpg ├── screensaver.jpg └── usb.jpg ├── psu ├── gerber │ ├── PCB_PSU_LIN_2023-01-12-B_Cu.gbr │ ├── PCB_PSU_LIN_2023-01-12-B_Mask.gbr │ ├── PCB_PSU_LIN_2023-01-12-B_Paste.gbr │ ├── PCB_PSU_LIN_2023-01-12-B_Silkscreen.gbr │ ├── PCB_PSU_LIN_2023-01-12-Edge_Cuts.gbr │ ├── PCB_PSU_LIN_2023-01-12-F_Cu.gbr │ ├── PCB_PSU_LIN_2023-01-12-F_Mask.gbr │ ├── PCB_PSU_LIN_2023-01-12-F_Paste.gbr │ ├── PCB_PSU_LIN_2023-01-12-F_Silkscreen.gbr │ ├── PCB_PSU_LIN_2023-01-12-drl_map.gbr │ ├── PCB_PSU_LIN_2023-01-12-job.gbrjob │ └── PCB_PSU_LIN_2023-01-12.drl ├── psu.kicad_pcb └── psu_schematic.JPG ├── schematics ├── cem.png ├── components.png ├── icm.png ├── installation.png ├── layout.png └── rti.png └── tools └── plotter.py /.dev/packager.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd /home/$USER/Development/v-link 3 | 4 | echo "VLINK-Packager" 5 | 6 | echo "Deleting old dist files..." 7 | rm -rf dist/ frontend/dist/ 8 | echo "Done." 9 | 10 | echo "Creating ./dist/ directory..." 11 | mkdir dist/ 12 | mkdir dist/frontend/ 13 | mkdir dist/backend/ 14 | echo "Done." 15 | 16 | echo "Packaging frontend..." 17 | cd frontend/ 18 | npm run build 19 | cd .. 20 | echo "Done." 21 | 22 | echo "Copying files..." 23 | cp -r frontend/dist/ dist/frontend/dist 24 | cp -r backend/ dist/ 25 | 26 | cp V-Link.py dist/V-Link.py 27 | cp requirements.txt dist/requirements.txt 28 | cp Install.sh dist/Install.sh 29 | cp Uninstall.sh dist/Uninstall.sh 30 | cp Update.sh dist/Update.sh 31 | echo "Done." 32 | 33 | echo "Creating Zip..." 34 | cd dist/ 35 | zip -r V-Link.zip V-Link.py requirements.txt frontend/ backend/ 36 | echo "Done." 37 | 38 | echo "Cleaning up..." 39 | rm -rf V-Link.py requirements.txt frontend/ backend/ 40 | 41 | cd .. 42 | 43 | echo "All Done." -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #directories 2 | #.dev/ 3 | 4 | node_modules/ 5 | venv/ 6 | dist/ 7 | frontend/dist/ 8 | __pycache__/ 9 | 10 | 11 | 12 | #files 13 | *.tgz 14 | *.log 15 | *.apk 16 | .DS* 17 | *.py[cod] 18 | ._* 19 | create-package.sh 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to the Boosted Moose V-Link project! 2 | 3 | ![TITLE IMAGE](resources/media/banner.jpg?raw=true "Banner") 4 | 5 | ### Let's face it. MMIs from the 2000s suck. 6 | 7 | This project was started because no suitable aftermarket solution could be found. I wanted to implement live vehicle data as well as AndroidAuto/Apple CarPlay in an **OEM like fashion** to enhance the driving experience of retro cars and give the user the ability to tinker around. 8 | 9 | The heart of this project is the open source **V-Link app**. It's running natively on Raspberry Pi OS which enables full support of an OS without the restrictions of 3rd party images. **The custom V-Link HAT** builds the bridge between the Raspberry Pi and the car and works plug and play with the app. To use this application you need a Raspberry Pi, the V-Link Hat (optional) and an HDMI-screen, preferably with touch support. 10 | 11 | 12 | *This project is in ongoing development. Feel free to fork it, create a new branch and open a pull request with your cool ideas. Do you have any tips for improvement, need help or just want to be part of our awesome little community?* 13 | 14 | * [Swedespeed Forum](https://www.swedespeed.com/threads/volvo-rtvi-raspberry-media-can-interface.658254/) 15 | * [V-Link Discord Server](https://discord.gg/V4RQG6p8vM) 16 | 17 | 18 | # Installation 19 | 20 | ### > System Requirements: 21 | ``` 22 | Raspberry Pi 3/4/5 23 | Raspberry Pi OS 12 (Bookworm) 24 | ``` 25 | 26 | For the best user experience a RPi 4 or 5 is recommended. 27 | 28 | --- 29 | 30 | ### > Run the App: 31 | 32 | When using the Installer everything is being set up automatically. More information can be found in the Wiki. 33 | 34 | ``` 35 | #Download and Install 36 | wget "https://github.com/BoostedMoose/v-link/releases/download/v3.0.1/Install.sh" 37 | sudo chmod +x Install.sh 38 | sudo ./Install.sh 39 | 40 | #Test Hardware (Requires V-Link HAT) 41 | python /home/$USER/v-link/HWT.py 42 | 43 | #Execute 44 | python /home/$USER/v-link/V-Link.py 45 | 46 | #Advanced Options: 47 | python /home/$USER/v-link/V-Link.py -h 48 | ``` 49 | 50 | ## Wiki 51 | 52 | Detailed instructions on all the functions and features can be found in the [Wiki](https://github.com/BoostedMoose/v-link/wiki) of this repository. In there you will find schematics, instructions to set up the HAT or your custom circuit and more. Definitely check it out! 53 | 54 | ## Disclaimer 55 | 56 | The use of this soft- and hardware is at your own risk. The author and distributor of this project is not responsible for any damage, personal injury, or any other loss resulting from the use or misuse of the setup described in this repository. By using this setup, you agree to accept full responsibility for any consequences that arise from its use. It’s DIY after all! 57 | 58 | 59 | #### The project is inspired by the following repositories: 60 | 61 | * [volvo-can-gauge](https://github.com/Alfaa123/Volvo-CAN-Gauge) 62 | * [react-carplay](https://github.com/rhysmorgan134/react-carplay) 63 | * [volvo-crankshaft](https://github.com/laurynas/volvo_crankshaft) 64 | * [volve](https://github.com/LuukEsselbrugge/Volve) 65 | * [volvo-vida](https://github.com/Tigo2000/Volvo-VIDA) 66 | 67 | #### Want to join development, got any tips for improvement or need help? 68 | 69 | * [Swedespeed Forum](https://www.swedespeed.com/threads/volvo-rtvi-raspberry-media-can-interface.658254/) 70 | * [V-Link Discord Server](https://discord.gg/V4RQG6p8vM) 71 | 72 | 73 | 74 | ## Want to support us? 75 | 76 | In the Wiki you can find a guide to set everything up. 77 | If you want to help, you can leave a tip through the buttons below. 78 | 79 | Your support is highly appreciated :) 80 | 81 | | [![Buy Me A Coffee](https://cdn.buymeacoffee.com/buttons/default-orange.png)](https://www.buymeacoffee.com/lrymnd) | [![Buy Me A Coffee](https://cdn.buymeacoffee.com/buttons/default-orange.png)](https://www.buymeacoffee.com/tigo) | 82 | |---|---| 83 | |
(Louis)
|
(Tigo)
| 84 | -------------------------------------------------------------------------------- /Uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # V-Link Uninstaller - https://www.github.com/lrymnd/v-link 4 | 5 | # Function to prompt the user for confirmation 6 | confirm_action() { 7 | while true; do 8 | read -p "Do you want to $1? (y/n): " choice 9 | case "$choice" in 10 | y|Y ) return 0;; 11 | n|N ) return 1;; 12 | * ) echo "Invalid input. Please enter 'y' or 'n'.";; 13 | esac 14 | done 15 | } 16 | 17 | user_exit() { 18 | echo "Aborted by user. Exiting." 19 | exit 1 20 | } 21 | 22 | # Function to remove a file or directory if it exists 23 | remove_if_exists() { 24 | if [ -e "$1" ]; then 25 | echo "Removing: $1" 26 | sudo rm -rf "$1" 27 | else 28 | echo "$1 not found, skipping." 29 | fi 30 | } 31 | 32 | echo "Uninstalling V-Link" 33 | sudo -v # This will prompt the user for their password upfront 34 | 35 | # Step 1: Remove application files 36 | if confirm_action "remove all application files"; then 37 | remove_if_exists "/home/$USER/v-link" 38 | fi 39 | 40 | # Step 2: Remove v-link.dtbo, mcp2515-can1, and mcp2515-can2 from /boot/firmware/overlays 41 | if confirm_action "remove entries from /boot/firmware/overlays"; then 42 | remove_if_exists "/boot/firmware/overlays/v-link.dtbo" 43 | remove_if_exists "/boot/firmware/overlays/mcp2515-can1.dtbo" 44 | remove_if_exists "/boot/firmware/overlays/mcp2515-can2.dtbo" 45 | fi 46 | 47 | # Step 3: Remove v-link.desktop from /etc/xdg/autostart 48 | if confirm_action "remove entries from /etc/xdg/autostart"; then 49 | remove_if_exists "/etc/xdg/autostart/v-link.desktop" 50 | fi 51 | 52 | # Step 4: Remove v-link.service from /etc/systemd/system 53 | if confirm_action "remove entries from /etc/systemd/system"; then 54 | remove_if_exists "/etc/systemd/system/v-link.service" 55 | fi 56 | 57 | # Step 5: Remove v-link.service from /etc/systemd/system 58 | if confirm_action "remove rules from /etc/udev/rules.d/"; then 59 | remove_if_exists "/etc/udev/rules.d/42-v-link.rules" 60 | fi 61 | 62 | # Step 6: Remove v-link rule from /etc/sudoers.d/ 63 | if confirm_action "remove rules from /etc/sudoers.d/"; then 64 | remove_if_exists "/etc/sudoers.d/v-link" 65 | fi 66 | 67 | # Step 7: Remove settings from ~/.config 68 | if confirm_action "remove all user settings from ~/.config"; then 69 | remove_if_exists "/home/$USER/.config/v-link" 70 | fi 71 | 72 | # Step 8: Remove logo and cursor on boot 73 | if confirm_action "revert boot changes (remove no-logo and cursor hiding)"; then 74 | sudo sed -i '1{s/ logo.nologo vt.global_cursor_default=0//}' /boot/firmware/cmdline.txt 75 | fi 76 | 77 | # Step 9: Remove entries from /boot/firmware/config.txt starting with [V-Link and ending with disable_splash=1 78 | if confirm_action "remove entries from /boot/firmware/config.txt"; then 79 | sudo mv /etc/xdg/autostart/pwrkey.desktop.backup /etc/xdg/autostart/pwrkey.desktop 80 | sudo sed -i '/\[V-LINK/,/disable_splash=1/d' /boot/firmware/config.txt 81 | fi 82 | 83 | echo "Uninstall complete." 84 | -------------------------------------------------------------------------------- /Update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # HELPER FUNCTIONS 4 | confirm_action() { 5 | while true; do 6 | read -p "Do you want to $1? (y/n): " choice 7 | case "$choice" in 8 | y|Y ) return 0;; 9 | n|N ) return 1;; 10 | * ) echo "Invalid input. Please enter 'y' or 'n'.";; 11 | esac 12 | done 13 | } 14 | 15 | wait_for_key_press() { 16 | read -n 1 -s -r -p "Press any key to restart..." 17 | echo # To move to a new line after the key press 18 | } 19 | 20 | # SETUP 21 | echo "Updating V-Link" 22 | 23 | # Step 1: Remove the existing V-Link configuration and files 24 | echo "Removing old files..." 25 | rm -rf ~/.config/v-link 26 | rm -rf ~/v-link/frontend/ 27 | rm -rf ~/v-link/backend 28 | rm -f ~/v-link/V-Link.zip 29 | rm -f ~/v-link/V-Link.py 30 | 31 | # Step 2: Download the latest V-Link.zip from the latest release on GitHub 32 | 33 | # Fetch the latest release info from GitHub (no external package needed) 34 | echo "Fetching the latest release URL from GitHub..." 35 | LATEST_RELEASE_URL=$(curl -s https://api.github.com/repos/BoostedMoose/v-link/releases/latest | grep "browser_download_url" | grep "V-Link.zip" | cut -d '"' -f 4) 36 | 37 | if [ -z "$LATEST_RELEASE_URL" ]; then 38 | echo "Error: Could not find the latest V-Link.zip release." 39 | exit 1 40 | fi 41 | 42 | # Download the latest release 43 | echo "Downloading latest V-Link.zip from GitHub..." 44 | curl -L "$LATEST_RELEASE_URL" -o ~/v-link/V-Link.zip 45 | 46 | # Step 3: Unzip the downloaded file 47 | echo "Unzipping the latest V-Link.zip..." 48 | unzip -o ~/v-link/V-Link.zip -d ~/v-link/ 49 | 50 | # Wait for user to press any key before restarting 51 | wait_for_key_press 52 | 53 | # Restart the system 54 | echo "System will restart now..." 55 | sudo reboot 56 | -------------------------------------------------------------------------------- /backend/__init__.py: -------------------------------------------------------------------------------- 1 | # backend/ __init__.py 2 | # Import modules 3 | 4 | from .server import ServerThread 5 | from .app import APPThread 6 | 7 | from .can import CANThread 8 | from .lin import LINThread 9 | from .adc import ADCThread 10 | from .rti import RTIThread 11 | from .ign import IGNThread 12 | 13 | 14 | from .dev.vcan import VCANThread 15 | from .shared.shared_state import shared_state -------------------------------------------------------------------------------- /backend/adc.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | import json 4 | import os 5 | import numpy as np 6 | import socketio 7 | from .shared.shared_state import shared_state 8 | 9 | import board 10 | import busio 11 | import adafruit_ads1x15.ads1115 as ADS 12 | from adafruit_ads1x15.analog_in import AnalogIn 13 | 14 | i2c = None 15 | ads = None 16 | 17 | try: 18 | i2c = busio.I2C(board.SCL, board.SDA) 19 | ads = ADS.ADS1115(i2c) 20 | ads.gain = 1 21 | except Exception as e: 22 | print("i2c error: ", e) 23 | PULL_UP = 2000 24 | STEP = 0.5 25 | 26 | 27 | class ADCThread(threading.Thread): 28 | def __init__(self): 29 | super().__init__() 30 | self.client = socketio.Client() 31 | self._stop_event = threading.Event() 32 | 33 | self.channels = [] 34 | 35 | self.sensor_data = None 36 | self.pressure_data = None 37 | self.temperature_data = None 38 | 39 | def run(self): 40 | if (ads): 41 | self.read_settings() 42 | self.connect_to_socketio() 43 | self.start_adc() 44 | 45 | def stop_thread(self): 46 | print("Stopping ADC thread.") 47 | time.sleep(.5) 48 | self._stop_event.set() 49 | 50 | 51 | def start_adc(self): 52 | while not self._stop_event.is_set(): 53 | self.read_sensor() 54 | time.sleep(.1) 55 | #self.disconnect_from_socketio() 56 | 57 | 58 | def read_settings(self): 59 | self.sensor_data = self.read_sensor_data_from_json() 60 | 61 | for i, (sensor_name, sensor_details) in enumerate(self.sensor_data["sensors"].items()): 62 | channel = sensor_details["channel"] 63 | analog_in_instance = AnalogIn(ads, getattr(ADS, channel)) 64 | self.channels.append(analog_in_instance) 65 | 66 | 67 | def read_sensor(self): 68 | for i, (key, value) in enumerate(self.sensor_data["sensors"].items()): 69 | voltage = self.channels[i].voltage 70 | resistance = None 71 | 72 | if value["ntc"]: 73 | resistance = PULL_UP * voltage / (5 - voltage) 74 | 75 | characteristics = value["characteristic"] 76 | interpolated_value = self.interpolate_value(voltage, resistance, characteristics) 77 | 78 | data = (f"{value['app_id']}:{interpolated_value}") 79 | self.emit_data_to_frontend(data) 80 | 81 | def interpolate_value(self, voltage, resistance, characteristics): 82 | interpolated_value = None 83 | 84 | # Calculate Value based on NTC characteristics 85 | if resistance is not None: 86 | closest_resistances = sorted(characteristics.keys(), key=lambda x: abs(float(x) - resistance))[:2] 87 | value1, value2 = characteristics[closest_resistances[0]], characteristics[closest_resistances[1]] 88 | interpolated_value = value1 + (value2 - value1) * (resistance - float(closest_resistances[0])) / (float(closest_resistances[1]) - float(closest_resistances[0])) 89 | interpolated_value = round(interpolated_value / STEP) * STEP 90 | 91 | # Calculate Value based on Voltage characteristics 92 | else: 93 | voltage_values = [float(key) for key in characteristics.keys()] 94 | pressure_values = list(characteristics.values()) 95 | 96 | # Find the two closest pairs 97 | value1 = min(range(len(voltage_values)), key=lambda i: abs(voltage_values[i] - voltage)) 98 | value2 = max(range(len(voltage_values)), key=lambda i: abs(voltage_values[i] - voltage)) 99 | 100 | # Linear interpolation 101 | interpolated_value = pressure_values[value1] + (pressure_values[value2] - pressure_values[value1]) * ( 102 | voltage - voltage_values[value1] 103 | ) / (voltage_values[value2] - voltage_values[value1]) 104 | 105 | return interpolated_value 106 | 107 | def read_sensor_data_from_json(self, filename="adc.json"): 108 | config_folder = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config') 109 | file_path = os.path.join(config_folder, filename) 110 | with open(file_path, "r") as file: 111 | data = json.load(file) 112 | return data 113 | 114 | def connect_to_socketio(self): 115 | max_retries = 5 116 | current_retry = 0 117 | while not self.client.connected and current_retry < max_retries: 118 | try: 119 | self.client.connect('http://localhost:4001', namespaces=['/adc']) 120 | if(shared_state.verbose): 121 | if self.client.connected: 122 | print("ADC connected to Socket.IO") 123 | else: 124 | print("ADC failed to connect to Socket.IO.") 125 | except Exception as e: 126 | print(f"ADCThread: Socket.IO connection failed. Retry {current_retry}/{max_retries}. Error: {e}") 127 | time.sleep(2) 128 | current_retry += 1 129 | 130 | def emit_data_to_frontend(self, data): 131 | if self.client and self.client.connected: 132 | self.client.emit('data', data, namespace='/adc') -------------------------------------------------------------------------------- /backend/app.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | import sys 4 | import os 5 | import subprocess 6 | import signal 7 | from .shared.shared_state import shared_state 8 | 9 | class APPThread(threading.Thread): 10 | def __init__(self): 11 | super().__init__() 12 | self.url = f"http://localhost:{4001 if shared_state.vite else 5173}" 13 | self.browser = None 14 | self._stop_event = threading.Event() 15 | 16 | def run(self): 17 | self.start_browser() 18 | 19 | while not self._stop_event.is_set(): 20 | if(shared_state.toggle_app.is_set()): 21 | self.stop_thread() 22 | time.sleep(.1) 23 | 24 | def stop_thread(self): 25 | print("Stopping APP thread.") 26 | self._stop_event.set() 27 | self.close_browser() 28 | shared_state.toggle_app.clear() 29 | 30 | 31 | def start_browser(self): 32 | if shared_state.verbose: log_level_flag = "--log-level=3 >/dev/null 2>&1" 33 | else: log_level_flag = "--log-level=3 >/dev/null 2>&1" 34 | 35 | if shared_state.isKiosk: 36 | flags = "--window-size=800,480 --kiosk --enable-experimental-web-platform-features --enable-features=SharedArrayBuffer --autoplay-policy=no-user-gesture-required --disable-extensions --remote-debugging-port=9222" 37 | command = f"chromium-browser --app={self.url} {flags} {log_level_flag}" 38 | else: 39 | flags = "--window-size=800,480 --disable-resize --enable-experimental-web-platform-features --enable-features=SharedArrayBuffer,OverlayScrollbar --autoplay-policy=no-user-gesture-required" 40 | command = f"chromium-browser {self.url} {flags} {log_level_flag}" 41 | 42 | 43 | self.browser = subprocess.Popen(command, shell=True) 44 | if(shared_state.verbose): print(f"Chromium browser started with PID: {self.browser.pid}") 45 | 46 | def close_browser(self): 47 | if self.browser: 48 | try: 49 | # Use subprocess to run a command that kills the process and its children 50 | subprocess.run(['pkill', '-P', str(self.browser.pid)]) 51 | self.browser.wait() 52 | except subprocess.CalledProcessError: 53 | # Handle possible exceptions 54 | print("Failed to close App.") 55 | else: 56 | print("Chromium process not found.") -------------------------------------------------------------------------------- /backend/config/_readme.txt: -------------------------------------------------------------------------------- 1 | ////////////////////////////////////////////////////////////////////////////////////////// 2 | /* */ 3 | /* Message Format: */ 4 | /* 000FFFFE FF FF FF FF FF FF FF FF */ 5 | /* | | | | | | | '--'---- Padding */ 6 | /* | | | | | | '---------- Number of responses expected (query only) */ 7 | /* | | | | | | */ 8 | /* | | | | '--'------------- Parameter */ 9 | /* | | | '------------------- Command */ 10 | /* | | '---------------------- Target ECU address */ 11 | /* | '------------------------- Message-length (always C8 + data byte length) */ 12 | /* '------------------------------- Module ID */ 13 | /* */ 14 | ////////////////////////////////////////////////////////////////////////////////////////// 15 | 16 | 17 | // Template: 18 | // 19 | // "boost": { 20 | // /* CAN-Bus parameter: 21 | // interface: "can0", // CAN bus interface 22 | // parameter: ['12', '9D'], // Request parameter 23 | // app_id: "map:", // Internal identifier for V-Link app 24 | // req_id: req_id[0], // ID for the request message 25 | // rep_id: rep_id[0], // ID for the expected reply 26 | // action: command[3], // Type of operation 27 | // target: target_id[0], // Target ECU 28 | // is_16bit: false, // 8Bit or 16Bit response value 29 | // refresh_rate: 0.02, // How much time to wait to send message again (seconds) 30 | // scale: '((value - 101.0) * 0.01)', // Formula to scale the response 31 | // //UI parameter: 32 | // label: "Boost", // Label for V-Link app 33 | // max_value: 2, // Expected max. value for gauge setup 34 | // limit_start: 1.5, // Start of redline for gauge setup 35 | // }, -------------------------------------------------------------------------------- /backend/config/adc.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "data", 3 | "name": "adc", 4 | 5 | "sensors": { 6 | "pressure": { 7 | "app_id": "oilp", 8 | "label": "Oil Pressure", 9 | "unit": "kPa", 10 | "min_value": 0, 11 | "max_value": 1000, 12 | "limit_start": 3000, 13 | "ntc": false, 14 | "channel": "P1", 15 | "characteristic": { 16 | "0.5": 0, 17 | "4.5": 1000 18 | } 19 | }, 20 | "temperature": { 21 | "app_id": "oilt", 22 | "label": "Oil Temperature", 23 | "unit": "°C", 24 | "min_value": 0, 25 | "max_value": 140, 26 | "limit_start": 120, 27 | "ntc": true, 28 | "channel": "P0", 29 | "characteristic": { 30 | "44864": -40, 31 | "33676": -35, 32 | "25524": -30, 33 | "19525": -25, 34 | "15067": -20, 35 | "11724": -15, 36 | "9195": -10, 37 | "7266": -5, 38 | "5784": 0, 39 | "4636": 5, 40 | "3740": 10, 41 | "3037": 15, 42 | "2480": 20, 43 | "2038": 25, 44 | "1683": 30, 45 | "1398": 35, 46 | "1167": 40, 47 | "978.9": 45, 48 | "825": 50, 49 | "698.5": 55, 50 | "594": 60, 51 | "5072": 65, 52 | "434.9": 70, 53 | "374.3": 75, 54 | "323.4": 80, 55 | "280.4": 85, 56 | "244": 90, 57 | "213": 95, 58 | "186.6": 100, 59 | "164": 105, 60 | "144.5": 110, 61 | "127.8": 115, 62 | "113.3": 120, 63 | "100.7": 125, 64 | "89.8": 130, 65 | "80.2": 135, 66 | "71.9": 140 67 | } 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /backend/config/lin.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "interface", 3 | "name": "lin", 4 | 5 | "channel": "", 6 | "bitrate": 0, 7 | "timing": { 8 | "timeout": 0.1 9 | }, 10 | "swm_id": "0x20", 11 | "sync_id": "0x55", 12 | "zero_code": "0xFF", 13 | "ign_on": ["0x50", "0x0E", "0x00", "0xF1"], 14 | "long_press_duration": 1500, 15 | "click_timeout": 300, 16 | "mouse_speed": 8, 17 | 18 | "commands": { 19 | "button": { 20 | "BTN_NEXT": ["0x20", "0x00", "0x10", "0x00", "0x00"], 21 | "BTN_PREV": ["0x20", "0x00", "0x02", "0x00", "0x00"], 22 | "BTN_VOL_UP": ["0x20", "0x00", "0x00", "0x01", "0x00"], 23 | "BTN_VOL_DOWN": ["0x20", "0x00", "0x80", "0x00", "0x00"], 24 | "BTN_BACK": ["0x20", "0x00", "0x01", "0x00", "0x00"], 25 | "BTN_ENTER": ["0x20", "0x00", "0x08", "0x00", "0x00"] 26 | }, 27 | "joystick": { 28 | "BTN_UP": ["0x20", "0x01", "0x00", "0x00", "0x00"], 29 | "BTN_DOWN": ["0x20", "0x02", "0x00", "0x00", "0x00"], 30 | "BTN_LEFT": ["0x20", "0x04", "0x00", "0x00", "0x00"], 31 | "BTN_RIGHT": ["0x20", "0x08", "0x00", "0x00", "0x00"] 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /backend/config/mmi.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "interface", 3 | "name": "mmi", 4 | "label": "Carplay Settings", 5 | 6 | "audioTransferMode": false, 7 | "keyBindings": { 8 | "left": "ArrowLeft", 9 | "right": "ArrowRight", 10 | "selectDown": "Space", 11 | "selectUp": "Undefined", 12 | "back": "Backspace", 13 | "down": "ArrowDown", 14 | "home": "KeyH", 15 | "play": "KeyP", 16 | "pause": "KeyO", 17 | "next": "KeyN", 18 | "prev": "KeyV" 19 | }, 20 | "boxName": "nodePlay", 21 | "dpi": 160, 22 | "format": 5, 23 | "fps": 60, 24 | "hand": 0, 25 | "iBoxVersion": 2, 26 | "kiosk": true, 27 | "mediaDelay": 300, 28 | "micType": "os", 29 | "microphone": "", 30 | "most": {}, 31 | "nightMode": false, 32 | "packetMax": 49152, 33 | "phoneConfig": { 34 | "3": { 35 | "frameInterval": 5000 36 | }, 37 | "5": { 38 | "frameInterval": null 39 | } 40 | }, 41 | "phoneWorkMode": 2, 42 | "piMost": false, 43 | "width": 800, 44 | "height": 480, 45 | "delay": 0, 46 | "lhd": 0, 47 | "wifiType": "5ghz" 48 | } -------------------------------------------------------------------------------- /backend/config/rti.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "interface", 3 | "name": "rti", 4 | 5 | "interface": { 6 | "type": "serial", 7 | "channel": "/dev/ttyAMA1", 8 | "baudrate": 2400 9 | }, 10 | "timing": { 11 | "timeout": 0.1 12 | }, 13 | "commands": { 14 | "modes": { 15 | "rgb": "0x40", 16 | "pal": "0x45", 17 | "ntsc": "0x4C", 18 | "off": "0x46" 19 | }, 20 | "brightness": ["0x20", "0x61", "0x62", "0x23", "0x64", "0x25", "0x26", "0x67", "0x68", "0x29", "0x2A", "0x2C", "0x6B", "0x6D", "0x6E", "0x2F"] 21 | }, 22 | "messages": { 23 | } 24 | } -------------------------------------------------------------------------------- /backend/dev/setup.sh: -------------------------------------------------------------------------------- 1 | sudo ip link delete vcan0 2 | sudo modprobe vcan 3 | sudo modprobe uinput 4 | sudo ip link add dev vcan0 type vcan 5 | sudo ip link set up vcan0 -------------------------------------------------------------------------------- /backend/dev/vcan.py: -------------------------------------------------------------------------------- 1 | import can 2 | import random 3 | import time 4 | import struct 5 | import threading 6 | import subprocess 7 | import os 8 | 9 | from ..shared.shared_state import shared_state 10 | 11 | 12 | class VCANThread(threading.Thread): 13 | def __init__(self): 14 | super(VCANThread, self).__init__() 15 | self._stop_event = threading.Event() 16 | self.daemon = True 17 | self.can_bus = None 18 | 19 | # Initial values 20 | INTAKE = 20.0 21 | BOOST = 1.0 22 | COOLANT = 90.0 23 | LAMBDA1 = 0.01 24 | LAMBDA2 = 0.85 25 | VOLTAGE = 14.0 26 | 27 | self.VALUES = [INTAKE, BOOST, COOLANT, LAMBDA1, LAMBDA2, VOLTAGE] 28 | self.TEMPLATE = [0xcd, 0x7a, 0xa6, 0x00, 0x00, 0x40, 0x0, 0x0] 29 | 30 | script_directory = os.path.dirname(os.path.abspath(__file__)) 31 | setup_script_path = os.path.join(script_directory, 'setup.sh') 32 | subprocess.run([setup_script_path], shell=True) 33 | 34 | def run(self): 35 | try: 36 | self.can_bus = can.interface.Bus(channel='vcan0', bustype='socketcan', bitrate=500000) 37 | # Use a timeout for recv to allow periodic checks of _stop_event 38 | while not self._stop_event.is_set(): 39 | try: 40 | # Timeout set to 1 second for responsiveness 41 | message = self.can_bus.recv(timeout=1.0) 42 | if message: 43 | self.check_message(message) 44 | except can.CanError as e: 45 | print(f"CAN error: {e}") 46 | except Exception as e: 47 | print(e) 48 | finally: 49 | self.stop_canbus() 50 | 51 | def stop_thread(self): 52 | print("Stopping VCAN thread.") 53 | time.sleep(.5) 54 | self._stop_event.set() 55 | 56 | def stop_canbus(self): 57 | if self.can_bus: 58 | self.can_bus.shutdown() 59 | 60 | def check_message(self, message): 61 | # Define the request array 62 | request = [ 63 | [0x12, 0x9D], 64 | [0x10, 0x34], 65 | [0x10, 0xCE], 66 | [0x10, 0xD8], 67 | [0x10, 0x0A], 68 | [0x10, 0x2C], 69 | ] 70 | 71 | # Check if the 4th and 5th bytes match any entry in the request array 72 | for index, req in enumerate(request): 73 | if list(message.data[3:5]) == req: 74 | self.send_message(req) 75 | 76 | def send_message(self, id): 77 | self.TEMPLATE[3:5] = list(id) 78 | 79 | # Generate a random offset in the range [-0x10, 0x10] 80 | random_offset = random.randint(-0x01, 0x01) 81 | self.TEMPLATE[5:6] = struct.pack('>B', (self.TEMPLATE[5] + random_offset) & 0xFF) 82 | response_message = can.Message(arbitration_id=0x00400021, data=self.TEMPLATE, is_extended_id=True) 83 | 84 | # Send the response 85 | self.can_bus.send(response_message) 86 | -------------------------------------------------------------------------------- /backend/ign.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | import os 4 | import lgpio 5 | from .shared.shared_state import shared_state 6 | 7 | class IGNThread(threading.Thread): 8 | def __init__(self): 9 | super().__init__() 10 | self.IGNITION_PIN = 1 11 | self.chip = lgpio.gpiochip_open(0) # Open GPIO chip 12 | 13 | self._stop_event = threading.Event() 14 | self.daemon = True 15 | 16 | def run(self): 17 | # Initialize GPIO pin here after the thread starts 18 | try: 19 | # Free GPIO pin if it's already claimed 20 | try: 21 | lgpio.gpio_free(self.chip, self.IGNITION_PIN) 22 | except lgpio.error: 23 | print(f"GPIO {self.IGNITION_PIN} ready.") 24 | 25 | # Now claim the GPIO pin 26 | lgpio.gpio_claim_input(self.chip, self.IGNITION_PIN) 27 | 28 | # Monitor ignition pin 29 | self.monitor_ignition() 30 | except lgpio.error as e: 31 | print(f"Error during GPIO initialization: {e}") 32 | 33 | def stop_thread(self): 34 | print("Stopping IGN thread.") 35 | self._stop_event.set() 36 | # Free GPIO pin when stopping 37 | try: 38 | lgpio.gpio_free(self.chip, self.IGNITION_PIN) 39 | except lgpio.error as e: 40 | print(f"Error freeing GPIO {self.IGNITION_PIN}: {e}") 41 | lgpio.gpiochip_close(self.chip) # Close GPIO chip 42 | 43 | def monitor_ignition(self): 44 | previous_state = None # Variable to track the previous state of the ignition pin 45 | 46 | while not self._stop_event.is_set(): 47 | try: 48 | # Read GPIO pin value (LOW = Ignition OFF) 49 | current_state = lgpio.gpio_read(self.chip, self.IGNITION_PIN) 50 | 51 | # Check if the state has changed 52 | if current_state != previous_state: 53 | if current_state == 0: # Pin is raised high when Ignition is turned off. 54 | shared_state.ign_state.clear() # Ignition is OFF, so clear the state 55 | else: 56 | shared_state.ign_state.set() # Ignition is ON, so set the state 57 | # Update previous state for the next iteration 58 | previous_state = current_state 59 | 60 | except lgpio.error as e: 61 | print(f"Error reading GPIO {self.IGNITION_PIN}: {e}") 62 | time.sleep(1) # Avoid tight looping if there's a problem 63 | continue 64 | 65 | time.sleep(1) # Avoid high CPU usage 66 | 67 | -------------------------------------------------------------------------------- /backend/rti.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | import sys 4 | import os 5 | import subprocess 6 | import serial 7 | from .shared.shared_state import shared_state 8 | 9 | class RTIThread(threading.Thread): 10 | def __init__(self): 11 | super().__init__() 12 | self.rti_serial = None 13 | self._stop_event = threading.Event() 14 | self.daemon = True 15 | 16 | try: 17 | if(shared_state.rpiModel == 5): 18 | self.rti_serial = serial.Serial('/dev/ttyAMA2', baudrate = 2400, timeout = 1) 19 | elif (shared_state.rpiModel == 4): 20 | self.rti_serial = serial.Serial('/dev/ttyAMA3', baudrate = 2400, timeout = 1) 21 | elif (shared_state.rpiModel == 3): 22 | self.rti_serial = serial.Serial('/dev/ttyS0', baudrate = 2400, timeout = 1) 23 | except serial.SerialException as e: 24 | print(f"Error initializing RTI Serial port: {e}") 25 | self.rti_serial = None 26 | 27 | def run(self): 28 | self.run_rti() 29 | 30 | def stop_thread(self): 31 | print("Stopping RTI thread.") 32 | time.sleep(.5) 33 | self.cleanup() 34 | self._stop_event.set() 35 | 36 | def write(self, byte): 37 | if self.rti_serial and self.rti_serial.is_open: 38 | self.rti_serial.write(byte.to_bytes(1, 'big')) 39 | time.sleep(0.1) 40 | 41 | def run_rti(self): 42 | while not self._stop_event.is_set(): 43 | try: 44 | if shared_state.rtiStatus: 45 | self.write(0x40) 46 | else: 47 | self.write(0x46) 48 | 49 | self.write(0x20) 50 | self.write(0x83) 51 | except Exception as e: 52 | print(f"Error during RTI operation: {e}") 53 | break 54 | 55 | def cleanup(self): 56 | if self.rti_serial and self.rti_serial.is_open: 57 | #print("Closing RTI Serial port") 58 | self.rti_serial.close() 59 | -------------------------------------------------------------------------------- /backend/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import shutil 4 | 5 | from backend.shared.shared_state import shared_state 6 | 7 | def load_directory(): 8 | # Specify the directory path 9 | config_directory = os.path.expanduser("~/.config/v-link") 10 | 11 | # Check if the directory exists, if not, create it 12 | if not os.path.exists(config_directory): 13 | try: 14 | os.makedirs(config_directory) 15 | #print(f"Created directory at: '{config_directory}'") 16 | except Exception as e: 17 | print(f"Error creating directory: {e}") 18 | return None 19 | 20 | return config_directory 21 | 22 | def load_settings(setting): 23 | # Call load_directory to ensure the directory exists 24 | config_directory = load_directory() 25 | 26 | # Specify the file path 27 | default_settings_file = setting + ".json" 28 | destination_path = os.path.join(config_directory, default_settings_file) 29 | 30 | # Load or create the settings file 31 | if not os.path.exists(destination_path): 32 | try: 33 | shutil.copyfile(os.path.join(os.path.dirname(__file__), "config", default_settings_file), destination_path) 34 | #print(f"Created settings file at: '{destination_path}'") 35 | except Exception as e: 36 | print(f"Error copying default settings file: {e}") 37 | return None 38 | 39 | # Load the JSON settings into Python 40 | try: 41 | with open(destination_path, 'r') as file: 42 | data = json.load(file) 43 | if(shared_state.verbose): print(setting + "-settings loaded.") 44 | return data 45 | except Exception as e: 46 | print(f"Error loading settings from '{destination_path}': {e}") 47 | return None 48 | 49 | def save_settings(setting, data): 50 | # Call load_directory to ensure the directory exists 51 | config_directory = load_directory() 52 | 53 | # Specify the file path 54 | default_settings_file = setting + ".json" 55 | destination_path = os.path.join(config_directory, default_settings_file) 56 | 57 | # Save the settings to the JSON file 58 | try: 59 | with open(destination_path, 'w') as file: 60 | json.dump(data, file, indent=4) 61 | #print(setting + "-settings saved.") 62 | except Exception as e: 63 | print(f"Error saving settings to '{destination_path}': {e}") 64 | 65 | 66 | def reset_settings(setting): 67 | # Call load_directory to ensure the directory exists 68 | config_directory = load_directory() 69 | 70 | # Specify the file paths 71 | default_settings_file = setting + ".json" 72 | destination_path = os.path.join(config_directory, default_settings_file) 73 | 74 | # Reset the settings to the original state 75 | try: 76 | shutil.copyfile(os.path.join(os.path.dirname(__file__), "config", default_settings_file), destination_path) 77 | #print(f"Reset {setting}-settings to the original state.") 78 | except Exception as e: 79 | print(f"Error resetting settings: {e}") -------------------------------------------------------------------------------- /backend/shared/shared_state.py: -------------------------------------------------------------------------------- 1 | # shared_state.py 2 | 3 | import queue 4 | import threading 5 | 6 | class SharedState: 7 | def __init__(self): 8 | #Global Variables 9 | self.verbose = False 10 | 11 | self.rpiModel = 5 12 | self.sessionType = "wayland" 13 | 14 | self.vCan = False 15 | self.vLin = False 16 | 17 | self.vite = True 18 | self.isKiosk = True 19 | 20 | self.rtiStatus = False 21 | self.hdmiStatus = False 22 | 23 | self.update = False 24 | 25 | #Thread States: 26 | self.toggle_app = threading.Event() 27 | 28 | self.toggle_can = threading.Event() 29 | self.toggle_lin = threading.Event() 30 | self.toggle_adc = threading.Event() 31 | self.toggle_rti = threading.Event() 32 | self.toggle_ign = threading.Event() 33 | 34 | self.exit_event = threading.Event() 35 | self.restart_event = threading.Event() 36 | self.update_event = threading.Event() 37 | self.hdmi_event = threading.Event() 38 | 39 | self.ign_state = threading.Event() 40 | 41 | 42 | self.shutdown_pi = threading.Event() 43 | 44 | 45 | # store threads 46 | self.THREADS = { 47 | "server": None, 48 | "app": None, 49 | "can": None, 50 | "lin": None, 51 | "adc": None, 52 | "rti": None, 53 | "ign": None, 54 | "vcan": None, 55 | } 56 | 57 | shared_state = SharedState() -------------------------------------------------------------------------------- /frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["babel-plugin-styled-components"] 3 | } -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /frontend/config-overrides.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | 3 | module.exports = function override(config, env) { 4 | //do stuff with the webpack config... 5 | config.resolve.fallback = { 6 | ...config.resolve.fallback, 7 | stream: require.resolve('stream-browserify'), 8 | buffer: require.resolve('buffer'), 9 | } 10 | config.resolve.extensions = [...config.resolve.extensions, '.ts', '.js'] 11 | config.plugins = [ 12 | ...config.plugins, 13 | new webpack.ProvidePlugin({ 14 | process: 'process/browser', 15 | Buffer: ['buffer', 'Buffer'], 16 | }), 17 | ] 18 | // console.log(config.resolve) 19 | // console.log(config.plugins) 20 | 21 | return config 22 | } 23 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | V-LINK 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "v-link", 3 | "productName": "V-Link", 4 | "version": "3.0.1", 5 | "private": false, 6 | "author": { 7 | "name": "Louis Raymond", 8 | "email": "Louis.Raymond@live.at" 9 | }, 10 | "type": "module", 11 | "scripts": { 12 | "vite": "vite", 13 | "tsc": "tsc", 14 | "build": "vite build", 15 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 16 | "preview": "vite preview" 17 | }, 18 | "dependencies": { 19 | "@emotion/is-prop-valid": "^1.3.1", 20 | "@esbuild-plugins/node-globals-polyfill": "^0.2.3", 21 | "@testing-library/jest-dom": "^5.17.0", 22 | "@types/color-convert": "^2.0.3", 23 | "@types/node": "^16.18.40", 24 | "@webgpu/types": "^0.1.40", 25 | "buffer": "^6.0.3", 26 | "d3": "^7.9.0", 27 | "events": "^3.3.0", 28 | "file-saver": "^2.0.5", 29 | "http-proxy-middleware": "^2.0.6", 30 | "immer": "^10.1.1", 31 | "node-carplay": "github:rhysmorgan134/node-CarPlay", 32 | "pcm-player": "^0.0.16", 33 | "pcm-ringbuf-player": "github:lrymnd/pcm-ringbuf-player", 34 | "process": "^0.11.10", 35 | "react": "^18.3.1", 36 | "react-dom": "^18.3.1", 37 | "react-indiana-drag-scroll": "^2.2.0", 38 | "react-loader-spinner": "^5.3.4", 39 | "react-simple-keyboard": "^3.7.34", 40 | "react-spinners-kit": "^1.9.1", 41 | "react-use-draggable-scroll": "^0.4.7", 42 | "sass": "^1.69.5", 43 | "socket.io-client": "^4.7.2", 44 | "socketmost": "^2.0.6", 45 | "stream-browserify": "^3.0.0", 46 | "styled-components": "^6.1.14", 47 | "typescript": "^5.0.2", 48 | "util": "^0.12.5", 49 | "web-vitals": "^3.4.0", 50 | "zustand": "^4.4.7" 51 | }, 52 | "devDependencies": { 53 | "@esbuild-plugins/node-modules-polyfill": "^0.2.2", 54 | "@types/react": "^19.0.7", 55 | "@types/react-dom": "^19.0.3", 56 | "@typescript-eslint/eslint-plugin": "^6.0.0", 57 | "@typescript-eslint/parser": "^6.0.0", 58 | "@vitejs/plugin-react": "^4.0.3", 59 | "babel-plugin-styled-components": "^2.1.4", 60 | "eslint": "^8.45.0", 61 | "eslint-plugin-react-hooks": "^4.6.0", 62 | "eslint-plugin-react-refresh": "^0.4.3", 63 | "vite": "^4.5.0", 64 | "vite-plugin-node-polyfills": "^0.22.0" 65 | }, 66 | "resolutions": { 67 | "@mui/styled-engine": "npm:@mui/styled-engine-sc@latest" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /frontend/public/assets/audio.worklet.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | // WebAudio's render quantum size. 3 | const RENDER_QUANTUM_FRAMES = 128; 4 | const RING_POINTERS_SIZE = 8; 5 | /** 6 | * A Reader class used by this worklet to read from a Adapted from a SharedArrayBuffer written to by ringbuf.js on the main thread, Adapted from https://github.com/padenot/ringbuf.js 7 | * MPL-2.0 License (see RingBuffer_LICENSE.txt) 8 | * 9 | * @author padenot 10 | */ 11 | class RingBuffReader { 12 | constructor(buffer) { 13 | const storageSize = (buffer.byteLength - RING_POINTERS_SIZE) / Int16Array.BYTES_PER_ELEMENT; 14 | this.storage = new Int16Array(buffer, RING_POINTERS_SIZE, storageSize); 15 | // matching capacity and R/W pointers defined in ringbuf.js 16 | this.writePointer = new Uint32Array(buffer, 0, 1); 17 | this.readPointer = new Uint32Array(buffer, 4, 1); 18 | } 19 | readTo(array) { 20 | const { readPos, available } = this.getReadInfo(); 21 | if (available === 0) { 22 | return 0; 23 | } 24 | const readLength = Math.min(available, array.length); 25 | const first = Math.min(this.storage.length - readPos, readLength); 26 | const second = readLength - first; 27 | this.copy(this.storage, readPos, array, 0, first); 28 | this.copy(this.storage, 0, array, first, second); 29 | Atomics.store(this.readPointer, 0, (readPos + readLength) % this.storage.length); 30 | return readLength; 31 | } 32 | getReadInfo() { 33 | const readPos = Atomics.load(this.readPointer, 0); 34 | const writePos = Atomics.load(this.writePointer, 0); 35 | const available = (writePos + this.storage.length - readPos) % this.storage.length; 36 | return { 37 | readPos, 38 | writePos, 39 | available, 40 | }; 41 | } 42 | copy(input, offset_input, output, offset_output, size) { 43 | for (let i = 0; i < size; i++) { 44 | output[offset_output + i] = input[offset_input + i]; 45 | } 46 | } 47 | } 48 | class PCMWorkletProcessor extends AudioWorkletProcessor { 49 | constructor(options) { 50 | super(); 51 | this.underflowing = false; 52 | const { sab, channels } = options.processorOptions; 53 | this.channels = channels; 54 | this.reader = new RingBuffReader(sab); 55 | this.readerOutput = new Int16Array(RENDER_QUANTUM_FRAMES * channels); 56 | } 57 | toFloat32(value) { 58 | return value / 32768; 59 | } 60 | process(_, outputs) { 61 | const outputChannels = outputs[0]; 62 | const { available } = this.reader.getReadInfo(); 63 | if (available < this.readerOutput.length) { 64 | if (!this.underflowing) { 65 | console.debug('UNDERFLOW', available); 66 | } 67 | this.underflowing = true; 68 | return true; 69 | } 70 | this.reader.readTo(this.readerOutput); 71 | for (let i = 0; i < this.readerOutput.length; i++) { 72 | // split interleaved audio as it comes from the dongle by splitting it across the channels 73 | if (this.channels === 2) { 74 | for (let channel = 0; channel < this.channels; channel++) { 75 | outputChannels[channel][i] = this.toFloat32(this.readerOutput[2 * i + channel]); 76 | } 77 | } 78 | else { 79 | outputChannels[0][i] = this.toFloat32(this.readerOutput[i]); 80 | } 81 | } 82 | this.underflowing = false; 83 | return true; 84 | } 85 | } 86 | registerProcessor('pcm-worklet-processor', PCMWorkletProcessor); 87 | //# sourceMappingURL=audio.worklet.js.map -------------------------------------------------------------------------------- /frontend/public/assets/fonts/Inter18pt-LightItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/frontend/public/assets/fonts/Inter18pt-LightItalic.woff -------------------------------------------------------------------------------- /frontend/public/assets/fonts/Inter18pt-LightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/frontend/public/assets/fonts/Inter18pt-LightItalic.woff2 -------------------------------------------------------------------------------- /frontend/public/assets/fonts/Inter18pt-SemiBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/frontend/public/assets/fonts/Inter18pt-SemiBold.woff -------------------------------------------------------------------------------- /frontend/public/assets/fonts/Inter18pt-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/frontend/public/assets/fonts/Inter18pt-SemiBold.woff2 -------------------------------------------------------------------------------- /frontend/public/assets/fonts/LeagueSpartan-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/frontend/public/assets/fonts/LeagueSpartan-Bold.woff -------------------------------------------------------------------------------- /frontend/public/assets/fonts/LeagueSpartan-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/frontend/public/assets/fonts/LeagueSpartan-Bold.woff2 -------------------------------------------------------------------------------- /frontend/public/assets/fonts/LeagueSpartan-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/frontend/public/assets/fonts/LeagueSpartan-Regular.woff -------------------------------------------------------------------------------- /frontend/public/assets/fonts/LeagueSpartan-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/frontend/public/assets/fonts/LeagueSpartan-Regular.woff2 -------------------------------------------------------------------------------- /frontend/public/assets/svg/background/glow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/background/horizon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/background/road.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/banner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/buttons/carplay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/buttons/dashboard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/buttons/general.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/buttons/interface.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/buttons/keymap.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/buttons/link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/buttons/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/buttons/system.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/gauges/race.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/icons/data/col.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/icons/data/err.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/icons/data/iat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/icons/data/ld1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/icons/data/ld2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/icons/data/map.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/icons/data/oilp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/icons/data/oilt.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/icons/data/spd.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/icons/interface/phone.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/icons/interface/wifi.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/logos/typo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /frontend/public/assets/svg/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 16 | 17 | 26 | Boosted Moose V-Link 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/frontend/public/logo512.png -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | font-size: clamp(14px, 2vw, 18px); 4 | } 5 | 6 | .App-logo { 7 | height: 40vmin; 8 | pointer-events: none; 9 | } 10 | 11 | @media (prefers-reduced-motion: no-preference) { 12 | .App-logo { 13 | animation: App-logo-spin infinite 20s linear; 14 | } 15 | } 16 | 17 | .App-header { 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | color: white; 24 | } 25 | 26 | .App-link { 27 | color: #61dafb; 28 | } 29 | 30 | @keyframes App-logo-spin { 31 | from { 32 | transform: rotate(0deg); 33 | } 34 | to { 35 | transform: rotate(360deg); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | 3 | import { theme } from './theme/Theme'; 4 | import styled, { ThemeProvider, StyleSheetManager } from 'styled-components'; 5 | import isPropValid from '@emotion/is-prop-valid'; // Import isPropValid 6 | 7 | import { APP, MMI, KEY } from './store/Store'; 8 | import { Socket } from './socket/Socket'; 9 | 10 | import Splash from './app/Splash'; 11 | import Content from './app/Content'; 12 | import Modal from './app/components/Modal'; 13 | 14 | 15 | import Carplay from './carplay/Carplay'; 16 | import Cardata from './cardata/Cardata'; 17 | 18 | import './App.css'; 19 | import './theme/fonts.module.css'; 20 | 21 | const AppContainer = styled.div` 22 | position: absolute; 23 | overflow: hidden; 24 | width: 100%; 25 | height: 100%; 26 | background: linear-gradient(180deg, #0D0D0D, #1C1C1C); 27 | `; 28 | 29 | function App() { 30 | const mmi = MMI((state) => state); 31 | const key = KEY((state) => state); 32 | const app = APP((state) => state); 33 | 34 | const system = app.system; 35 | 36 | const [commandCounter, setCommandCounter] = useState(0); 37 | const [keyCommand, setKeyCommand] = useState(''); 38 | 39 | useEffect(() => { 40 | document.addEventListener('keydown', mmiKeyDown); 41 | return () => { 42 | document.removeEventListener('keydown', mmiKeyDown); 43 | }; 44 | }, [system.view, system.switch]); 45 | 46 | const mmiKeyDown = (event: KeyboardEvent) => { 47 | // Store last Keystroke in store to broadcast it 48 | key.setKeyStroke(event.code); 49 | 50 | // Only process Carplay key commands when in Carplay view 51 | if (system.view !== 'Carplay') return; 52 | 53 | // If user is not switching the page, send control to CarPlay 54 | if (system.switch && event.code !== system.switch) { 55 | if (Object.values(mmi!.bindings).includes(event.code)) { 56 | const action = Object.keys(mmi!.bindings).find(key => 57 | mmi!.bindings[key] === event.code 58 | ); 59 | //console.log(action) 60 | if (action !== undefined) { 61 | setKeyCommand(action); 62 | setCommandCounter(prev => prev + 1); 63 | if (action === 'selectDown') { 64 | setTimeout(() => { 65 | setKeyCommand('selectUp'); 66 | setCommandCounter(prev => prev + 1); 67 | }, 200); 68 | } 69 | } 70 | } 71 | } 72 | }; 73 | 74 | // Dimensions of the container 75 | const containerRef = useRef(null); 76 | const [ready, setReady] = useState(false); 77 | /* Observe container resizing and update dimensions. */ 78 | useEffect(() => { 79 | const handleResize = () => { 80 | if (containerRef.current) 81 | if (containerRef.current && system.startedUp) { 82 | 83 | const carplayFullscreen = containerRef.current.offsetHeight; 84 | const carplayWindowed = containerRef.current.offsetHeight - app.settings.side_bars.topBarHeight.value; 85 | 86 | console.log("Fullscreen Height: ", carplayFullscreen); 87 | console.log("Windowed Height: ", carplayWindowed); 88 | console.log("Topbar Height: ", app.settings.side_bars.topBarHeight.value); 89 | 90 | app.update((state) => { 91 | state.system.windowSize.width = containerRef.current.offsetWidth; 92 | state.system.windowSize.height = containerRef.current.offsetHeight; 93 | 94 | state.system.carplaySize.width = containerRef.current.offsetWidth; 95 | state.system.carplaySize.height = (app.settings.side_bars.topBarHeight.value ? carplayFullscreen : carplayWindowed); 96 | }); 97 | 98 | setReady(true); 99 | } 100 | }; 101 | 102 | const resizeObserver = new ResizeObserver(handleResize); 103 | if (containerRef.current) resizeObserver.observe(containerRef.current); 104 | return () => resizeObserver.disconnect(); 105 | }, [system.startedUp, containerRef.current]); 106 | 107 | return ( 108 | 109 | 110 | 111 | 112 | 113 | 114 | {system.startedUp && ready ? ( 115 | 116 | 120 | 127 | app.update((state) => { 128 | state.system.modal.visible = false; 129 | }) 130 | } 131 | /> 132 | 133 | 134 | 135 | ) : ( 136 | <> 137 | )} 138 | 139 | 140 | ); 141 | } 142 | 143 | export default App; 144 | -------------------------------------------------------------------------------- /frontend/src/app/Splash.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import styled, { css, keyframes } from 'styled-components'; 3 | import { APP } from '../store/Store'; 4 | import { Title } from '../theme/styles/Typography'; 5 | 6 | 7 | // Keyframes for fade-out animation 8 | const fadeOut = keyframes` 9 | from { 10 | opacity: 1; 11 | } 12 | to { 13 | opacity: 0; 14 | } 15 | `; 16 | 17 | // Styled-components 18 | const Container = styled.div.withConfig({ 19 | shouldForwardProp: (prop) => !['fadeOutAnimation', 'fadeDuration'].includes(prop) 20 | })` 21 | position: absolute; 22 | z-index: 5; 23 | top: 0; 24 | left: 0; 25 | 26 | height: 100%; 27 | width: 100%; 28 | 29 | display: flex; 30 | flex-direction: column; 31 | justify-content: center; 32 | align-items: center; 33 | gap: 5px; 34 | overflow: hidden; 35 | background-color: black; 36 | 37 | /* Conditionally apply the fade-out animation */ 38 | ${({ fadeOutAnimation, fadeDuration }) => 39 | fadeOutAnimation && 40 | css` 41 | animation: ${fadeOut} ${fadeDuration}ms ease-in-out forwards; 42 | `} 43 | 44 | transition: none; 45 | transform-origin: top; 46 | `; 47 | 48 | const Splash = styled.div` 49 | flex: 1; 50 | display: flex; 51 | flex-direction: column; 52 | 53 | justify-content: center; 54 | align-items: center; 55 | 56 | text-align: center; 57 | color: #DBDBDB; 58 | transform-origin: top; 59 | 60 | svg { 61 | margin-bottom: 1rem; 62 | } 63 | 64 | p { 65 | margin: 0; 66 | } 67 | `; 68 | 69 | const SplashScreen = () => { 70 | 71 | const [showLogo, setShowLogo] = useState(true); // Triggers fade-out animation 72 | const [showSplash, setShowSplash] = useState(true); // Determines if the splash screen is displayed 73 | const [fadeOutAnimation, setFadeOutAnimation] = useState(false); // Triggers fade-out animation 74 | const app = APP((state) => state); 75 | 76 | const displayTime = 2000; // Time before fade-out starts (in ms) 77 | const logoTime = 1750; // Time before fade-out starts (in ms) 78 | const fadeDuration = 1250; // Duration of the fade-out animation (in ms) 79 | 80 | useEffect(() => { 81 | // Start the fade-out animation after the display time 82 | const fadeOutTimeout = setTimeout(() => { 83 | setFadeOutAnimation(true); 84 | }, displayTime); 85 | 86 | const logoTimeOut = setTimeout(() => { 87 | setShowLogo(false); 88 | }, logoTime); 89 | 90 | // Remove the splash screen after the fade-out animation ends 91 | const hideSplashTimeout = setTimeout(() => { 92 | setShowSplash(false); 93 | }, displayTime + fadeDuration); // display time + fade duration 94 | 95 | // Cleanup timeouts on unmount 96 | return () => { 97 | clearTimeout(fadeOutTimeout); 98 | clearTimeout(hideSplashTimeout); 99 | }; 100 | }, []); 101 | 102 | return ( 103 | showSplash && ( 104 | 105 | 106 | {showLogo && 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 |

{app.system.version }

115 |
116 | } 117 | 118 |
119 | ) 120 | ); 121 | }; 122 | 123 | export default SplashScreen; 124 | -------------------------------------------------------------------------------- /frontend/src/app/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import styled from 'styled-components'; 4 | import { Typography } from '../../theme/styles/Typography'; 5 | import { Button } from '../../theme/styles/Inputs'; 6 | 7 | const Overlay = styled.div` 8 | position: fixed; 9 | top: 0; 10 | left: 0; 11 | width: 100%; 12 | height: 100%; 13 | background-color: rgba(0, 0, 0, 0.5); 14 | backdrop-filter: blur(10px); 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | z-index: 4; 19 | 20 | opacity: ${({ $visible }) => ($visible ? 1 : 0)}; 21 | transition: opacity 0.3s ease-in-out; 22 | `; 23 | 24 | const Content = styled.div` 25 | background: #151515; 26 | padding: 20px; 27 | border-radius: 10px; 28 | text-align: center; 29 | color: none; 30 | white-space: pre-line; 31 | 32 | min-width: 300px; 33 | 34 | display: flex; 35 | flex-direction: column; 36 | align-items: center; 37 | justify-content: center; 38 | 39 | opacity: ${({ $visible }) => ($visible ? 1 : 0)}; 40 | transform: ${({ $visible }) => ($visible ? 'scale(1)' : 'scale(0.9)')}; 41 | transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out; 42 | `; 43 | 44 | const Exit = styled.button` 45 | position: absolute; 46 | top: -30px; 47 | right: -30px; 48 | border: none; 49 | border-radius: 25px; 50 | width: 25px; 51 | height: 25px; 52 | background: #151515; 53 | font-size: 10px; 54 | font-weight: bold; 55 | cursor: pointer; 56 | color: #DBDBDB; 57 | `; 58 | 59 | const Modal = ({ isOpen, onClose, title, body, button, action }) => { 60 | const Body1 = Typography.Body1; 61 | const Display2 = Typography.Display2; 62 | const [visible, setVisible] = useState(false); 63 | const [shouldRender, setShouldRender] = useState(isOpen); 64 | 65 | useEffect(() => { 66 | if (isOpen) { 67 | setShouldRender(true); 68 | setTimeout(() => setVisible(true), 100); // Ensure transition triggers 69 | } else { 70 | setVisible(false); 71 | setTimeout(() => setShouldRender(false), 300); // Delay unmounting until fade-out finishes 72 | } 73 | }, [isOpen, shouldRender]); 74 | 75 | if (!shouldRender) return null; // Prevent render when modal is fully closed 76 | 77 | return ReactDOM.createPortal( 78 | 79 | 80 | {title} 81 | {body} 82 | {action ? ( 83 | 86 | ) : null} 87 | X 88 | 89 | , 90 | document.getElementById('root') 91 | ); 92 | }; 93 | 94 | export default Modal; 95 | -------------------------------------------------------------------------------- /frontend/src/app/components/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import styled, { useTheme } from 'styled-components'; 2 | import { APP } from '../../store/Store'; 3 | 4 | const Dots = styled.div` 5 | height: 20px; 6 | width: 100%; 7 | display: flex; 8 | flex-direction: column; 9 | justify-content: center; 10 | align-items: center; 11 | overflow: hidden; 12 | margin-bottom: 20px; 13 | `; 14 | 15 | const Pagination = ({ pages, colorActive, colorInactive, currentPage, dotSize = 20 }) => { 16 | const theme = useTheme() 17 | 18 | const app = APP((state) => state.settings) 19 | const themeColor = (app.general.colorTheme.value).toLowerCase() 20 | 21 | const circles = []; 22 | 23 | for (let i = 0; i < pages; i++) { 24 | const isActive = i === currentPage; 25 | const circleColor = isActive ? theme.colors.theme[themeColor].active : theme.colors.medium; 26 | 27 | circles.push( 28 | 35 | ); 36 | } 37 | 38 | const svgWidth = pages * dotSize * 2; // Adjust the width based on circle count 39 | const svgHeight = dotSize * 2; // Adjust the height based on circle size 40 | 41 | return ( 42 | 43 | 44 | {circles} 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default Pagination; -------------------------------------------------------------------------------- /frontend/src/app/helper/EventEmitter.tsx: -------------------------------------------------------------------------------- 1 | export const eventEmitter = new EventTarget(); -------------------------------------------------------------------------------- /frontend/src/app/helper/HexToRGBA.tsx: -------------------------------------------------------------------------------- 1 | // Utility function to convert hex to rgba 2 | const hexToRgba = (hex, alpha) => { 3 | let r = 0, g = 0, b = 0; 4 | 5 | // Expand shorthand hex like "#fff" to full form "#ffffff" 6 | if (hex.length === 4) { 7 | r = parseInt(hex[1] + hex[1], 16); 8 | g = parseInt(hex[2] + hex[2], 16); 9 | b = parseInt(hex[3] + hex[3], 16); 10 | } else if (hex.length === 7) { 11 | r = parseInt(hex[1] + hex[2], 16); 12 | g = parseInt(hex[3] + hex[4], 16); 13 | b = parseInt(hex[5] + hex[6], 16); 14 | } 15 | 16 | return `rgba(${r}, ${g}, ${b}, ${alpha})`; 17 | }; 18 | 19 | 20 | export default hexToRgba; -------------------------------------------------------------------------------- /frontend/src/app/pages/dashboard/charts/Charts.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import styled, { useTheme } from 'styled-components'; 3 | 4 | import { APP } from '../../../../store/Store'; 5 | 6 | import DataChart from '../../../components/DataChart' 7 | import DataList from '../../../components/DataList' 8 | 9 | 10 | const Container = styled.div` 11 | display: flex; 12 | flex-direction:column; 13 | gap: 30px; 14 | width: 100%; 15 | height: 100%; 16 | `; 17 | 18 | const Chart = styled.div` 19 | display: flex; 20 | height: 60%; 21 | `; 22 | 23 | const List = styled.div` 24 | display: flex; 25 | gap: 20px; 26 | `; 27 | 28 | 29 | const Charts = () => { 30 | const theme = useTheme() 31 | const app = APP((state) => state); 32 | 33 | const setCount = app.settings.constants.chart_input_current; 34 | const Datalist = DataList(app.settings.dash_charts, setCount, 2) // Amount of Items, 2 Columns 35 | 36 | 37 | 38 | return ( 39 | 40 | 41 | 50 | 51 | 52 | {Datalist} 53 | 54 | 55 | 56 | ) 57 | }; 58 | 59 | export default Charts; 60 | -------------------------------------------------------------------------------- /frontend/src/app/pages/dashboard/classic/Classic.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react' 2 | import styled, { useTheme } from 'styled-components'; 3 | 4 | import { APP } from '../../../../store/Store'; 5 | 6 | import RadialGauge from '../../../components/RadialGauge' 7 | import DataBox from '../../../components/DataBox' 8 | 9 | 10 | const Container = styled.div` 11 | display: flex; 12 | flex-direction:column; 13 | width: 100%; 14 | height: 100%; 15 | `; 16 | 17 | const Gauges = styled.div` 18 | display: flex; 19 | flex-direction: row; 20 | justify-content: space-between; 21 | align-items: flex-end; 22 | 23 | 24 | width: 100%; 25 | height: 60%; 26 | 27 | background-image: url(/assets/svg/background/horizon.svg#horizon); 28 | background-size: cover; 29 | background-repeat: no-repeat; 30 | background-position: center; 31 | `; 32 | 33 | 34 | const Classic = () => { 35 | const app = APP((state) => state); 36 | 37 | const theme = useTheme() 38 | const Databox = DataBox(app.settings.dash_race) // Amount of Items, 2 Columns 39 | 40 | 41 | /* Observe container resizing and update dimensions. */ 42 | const containerRef = useRef(null); 43 | const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); 44 | 45 | 46 | useEffect(() => { 47 | const handleResize = () => { 48 | if (containerRef.current) { 49 | setDimensions({ 50 | width: containerRef.current.offsetWidth, 51 | height: containerRef.current.offsetHeight, 52 | }); 53 | } 54 | }; 55 | 56 | const resizeObserver = new ResizeObserver(handleResize); 57 | if (containerRef.current) resizeObserver.observe(containerRef.current); 58 | return () => resizeObserver.disconnect(); 59 | }, []); 60 | 61 | return ( 62 | 63 | 64 |
65 | 71 |
72 | 77 | 81 |
82 | 88 |
89 |
90 |
100 | {Databox} 101 |
102 | 103 |
104 | ) 105 | }; 106 | 107 | 108 | export default Classic; -------------------------------------------------------------------------------- /frontend/src/app/pages/dashboard/race/Race.tsx: -------------------------------------------------------------------------------- 1 | import styled, { useTheme } from 'styled-components'; 2 | 3 | import LinearGauge from './../../../components/LinearGauge'; 4 | import DataList from '../../../components/DataList' 5 | 6 | import { APP } from '../../../../store/Store'; 7 | 8 | const Container = styled.div` 9 | display: flex; 10 | flex-direction:column; 11 | gap: 30px; 12 | width: 100%; 13 | height: 100%; 14 | `; 15 | 16 | const Gauge = styled.div` 17 | height: 60%; 18 | width: 100%; 19 | gap: 20px; 20 | `; 21 | 22 | const List = styled.div` 23 | width: 100%; 24 | gap: 20px; 25 | `; 26 | 27 | const Race = () => { 28 | const app = APP((state) => state); 29 | 30 | const Datalist = DataList(app.settings.dash_race, 6, 2) // Amount of Items, 2 Columns 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | {Datalist} 39 | 40 | 41 | ) 42 | }; 43 | 44 | 45 | export default Race; -------------------------------------------------------------------------------- /frontend/src/app/sidebars/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { APP } from '../../store/Store'; 2 | import { IconNav } from '../../theme/styles/Icons'; 3 | import { GlowLarge } from '../../theme/styles/Effects'; 4 | import styled, { css, useTheme } from 'styled-components'; 5 | 6 | 7 | const Navbar = styled.div` 8 | position: absolute; 9 | bottom: 0; 10 | z-index: 3; 11 | 12 | background-color:${({ theme }) => `${theme.colors.navbar}`}; 13 | display: flex; 14 | flex-direction: row; 15 | justify-content: space-evenly; 16 | align-items: center; 17 | width: 100%; 18 | height: ${({ app }) => `${app.settings.side_bars.navBarHeight.value}px`}; 19 | animation: ${({ app, theme, isActive }) => css` 20 | ${isActive 21 | ? theme.animations.getSlideDown(app.settings.side_bars.navBarHeight.value) 22 | : theme.animations.getSlideUp(app.settings.side_bars.navBarHeight.value)} 0.3s ease-in-out forwards 23 | `}; 24 | `; 25 | 26 | const NavButton = styled.button` 27 | width: 100%; 28 | background: none; 29 | border: none; 30 | 31 | &:hover { 32 | cursor: pointer; 33 | } 34 | `; 35 | 36 | const Indicator = styled.div` 37 | position: absolute; 38 | bottom: 0; 39 | z-index: 3; 40 | 41 | display: ${({ isActive }) => `${isActive ? 'none' : 'flex'}`}; 42 | justify-content: center; 43 | align-items: center; 44 | 45 | width: 100%; 46 | height: 20px; 47 | background: none; 48 | border: none; 49 | `; 50 | 51 | const Blob = styled.div` 52 | width: 100px; 53 | height: 3px; 54 | background: ${({ theme, themeColor, isHovering }) => `${isHovering ? theme.colors.theme[themeColor].active : theme.colors.medium}`}; 55 | 56 | border-radius: 2.5px; 57 | border: none; 58 | 59 | /* Add transition for background color change */ 60 | transition: background 0.4s ease-in-out; 61 | `; 62 | 63 | 64 | const NavBar = ({ isHovering }) => { 65 | const app = APP((state) => state); 66 | const theme = useTheme(); 67 | const themeColor = (app.settings.general.colorTheme.value).toLowerCase() 68 | 69 | const handleClick = () => { 70 | app.update((state) => { state.system.interface.navBar = true }) 71 | } 72 | 73 | return ( 74 | <> 75 | 76 | 77 | {app.system.interface.content && } 78 | 79 | 80 | 81 | {['Dashboard', 'Carplay', 'Settings'].map((view) => ( 82 |
83 | { 84 | //console.log('click, ', view) 85 | app.update((state) => { state.system.view = view }) 86 | }}> 87 | 94 | 95 | 96 | 97 |
98 | ))} 99 |
100 | 101 | ); 102 | }; 103 | 104 | export default NavBar; 105 | -------------------------------------------------------------------------------- /frontend/src/app/sidebars/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { APP, DATA } from '../../store/Store'; 3 | import styled, { css, useTheme } from 'styled-components'; 4 | 5 | import { IconSmall, CustomIcon } from '../../theme/styles/Icons'; 6 | import { Caption1 } from '../../theme/styles/Typography'; 7 | 8 | const Topbar = styled.div` 9 | position: absolute; 10 | top: 0; 11 | z-index: 3; 12 | 13 | background: ${({ theme }) => theme.colors.gradients.gradient1}; 14 | 15 | height: ${({ app }) => `${app.settings.side_bars.topBarHeight.value}px`}; 16 | animation: ${({ app, theme, isActive }) => css` 17 | ${isActive 18 | ? theme.animations.getSlideUp(-app.settings.side_bars.topBarHeight.value) 19 | : theme.animations.getSlideDown(-app.settings.side_bars.topBarHeight.value)} 0.3s ease-in-out forwards 20 | `}; 21 | width: 100%; 22 | display: flex; 23 | flex-direction: row; 24 | justify-content: center; 25 | align-items: center; 26 | box-sizing: border-box; 27 | padding: 5px 20px; 28 | gap: 10px; 29 | 30 | overflow: hidden; 31 | `; 32 | 33 | const Left = styled.div` 34 | display: flex; 35 | flex-direction: row; 36 | justify-content: left; 37 | align-items: center; 38 | width: 100%; 39 | height: 100%; 40 | `; 41 | 42 | const Middle = styled.div` 43 | display: flex; 44 | flex-direction: row; 45 | justify-content: center; 46 | align-items: center; 47 | width: 100%; 48 | height: 100%; 49 | `; 50 | 51 | const Right = styled.div` 52 | display: flex; 53 | flex-direction: row; 54 | justify-content: right; 55 | align-items: center; 56 | width: 100%; 57 | height: 100%; 58 | gap: 10px; 59 | `; 60 | 61 | const Scroller = styled.div` 62 | position: relative; 63 | display: flex; 64 | flex-direction: column; 65 | 66 | 67 | width: 100%; 68 | height: 30px; 69 | 70 | overflow: hidden; 71 | `; 72 | 73 | const ScrollerContent = styled.div` 74 | position: absolute; 75 | 76 | display: flex; 77 | justify-content: flex-start; 78 | align-items: center; 79 | 80 | width: 100%; 81 | height: 30px; /* Each child has a fixed height of 30px */ 82 | gap: 10px; 83 | 84 | top: ${({ active }) => (active ? "0" : "30px")}; 85 | transition: top 0.3s ease-in-out; 86 | 87 | 88 | `; 89 | 90 | const TopBar = () => { 91 | const app = APP((state) => state); 92 | const data = DATA((state) => state.data); 93 | const modules = APP((state) => state.modules); 94 | 95 | 96 | const settings = APP((state) => state.settings.dash_topbar); 97 | const theme = useTheme(); 98 | const themeColor = (app.settings.general.colorTheme.value).toLowerCase() 99 | 100 | 101 | const valueName = settings.value.value 102 | const valueType = settings.value.type 103 | const valueID = modules[valueType]((state) => state.settings.sensors[valueName].app_id) 104 | const valueData = data[valueName] 105 | const valueLimit = modules[valueType]((state) => state.settings.sensors[valueName].limit_start) 106 | 107 | const [time, setDate] = useState(new Date()); 108 | 109 | function updateTime() { 110 | setDate(new Date()); 111 | } 112 | 113 | useEffect(() => { 114 | const timer1 = setInterval(updateTime, 10000); 115 | return () => clearInterval(timer1); 116 | }, []); 117 | 118 | return ( 119 | 126 | 127 | 128 | 129 | {time.toLocaleTimeString('sv-SV', { hour: '2-digit', minute: '2-digit' })} 130 | 131 | 132 | valueLimit} 136 | activeColor={theme.colors.theme[themeColor].highlightDark} 137 | defaultColor={theme.colors.light} 138 | inactiveColor={theme.colors.medium} 139 | glowColor={theme.colors.theme[themeColor].default}> 140 | 141 | 142 | {valueData} 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | {/* 153 | 154 | 155 | 156 | */} 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | ); 166 | }; 167 | 168 | export default TopBar; 169 | -------------------------------------------------------------------------------- /frontend/src/cardata/Cardata.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useMemo, 4 | } from 'react' 5 | import { CANWorker, ADCWorker } from './worker/types' 6 | import { DATA } from './../store/Store'; 7 | import { APP } from './../store/Store'; 8 | 9 | import Display from './helper/Display'; 10 | import Ignition from './helper/Ignition'; 11 | import Recorder from './helper/Recorder'; 12 | 13 | import { io } from "socket.io-client"; 14 | const sysChannel = io("ws://localhost:4001/sys") 15 | 16 | 17 | 18 | 19 | function Cardata() { 20 | 21 | const app = APP((state) => state) 22 | const updateApp = APP((state) => state.update) 23 | 24 | const data = DATA((state) => state) 25 | const updateData = DATA((state) => state.update); 26 | 27 | const canWorker = useMemo(() => { 28 | const worker = new Worker( 29 | new URL('./worker/CAN.worker.ts', import.meta.url), { 30 | type: 'module' 31 | } 32 | ) as CANWorker 33 | return worker 34 | }, []) 35 | 36 | const adcWorker = useMemo(() => { 37 | const worker = new Worker( 38 | new URL('./worker/ADC.worker.ts', import.meta.url), { 39 | type: 'module' 40 | } 41 | ) as ADCWorker 42 | return worker 43 | }, []) 44 | 45 | useEffect(() => { 46 | canWorker.onmessage = (event) => { 47 | const { type, message } = event.data; 48 | const newData = { [type]: message }; 49 | updateData(newData.message) 50 | }; 51 | 52 | adcWorker.onmessage = (event) => { 53 | const { type, message } = event.data; 54 | const newData = { [type]: message }; 55 | updateData(newData.message) 56 | }; 57 | 58 | return () => { 59 | // Clean up the worker when the component is unmounted 60 | adcWorker.terminate(); 61 | canWorker.terminate(); 62 | }; 63 | }, []); 64 | 65 | return ( 66 | <> 67 | 71 | 79 | 87 | 88 | ); 89 | } 90 | 91 | export default Cardata 92 | -------------------------------------------------------------------------------- /frontend/src/cardata/helper/Display.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { Socket } from 'socket.io-client'; 3 | 4 | interface DisplayProps { 5 | autoOpen: boolean; // dynamic sensor data 6 | io: Socket; // update interval in ms 7 | } 8 | 9 | const Display: React.FC = ({ autoOpen, io }) => { 10 | 11 | // Auto-open Display Unit 12 | useEffect(() => { 13 | if (autoOpen) { 14 | console.log('Opening RTI') 15 | io.emit("systemTask", "rti") 16 | } 17 | }, []) 18 | 19 | return null; 20 | 21 | } 22 | 23 | export default Display; -------------------------------------------------------------------------------- /frontend/src/cardata/helper/Ignition.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import { Socket } from 'socket.io-client'; 3 | 4 | interface DisplayProps { 5 | ignition: boolean; 6 | autoShutdown: boolean; 7 | shutdownDelay: number; 8 | messageTimeout: number; 9 | updateApp: (fn: (state: any) => void) => void; 10 | io: Socket; 11 | } 12 | 13 | const Ignition: React.FC = ({ 14 | ignition, 15 | autoShutdown, 16 | shutdownDelay, 17 | messageTimeout, 18 | updateApp, 19 | io, 20 | }) => { 21 | const extendedTimer = useRef(null); 22 | const shutdownTimer = useRef(null); 23 | 24 | const startShutdownTimer = () => { 25 | shutdownTimer.current = setTimeout(() => { 26 | console.log('Shutting Down'); 27 | io.emit('systemTask', 'shutdown'); // Uncomment to actually trigger shutdown 28 | }, shutdownDelay * 1000); 29 | }; 30 | 31 | const startExtendedTimer = () => { 32 | extendedTimer.current = setTimeout(() => { 33 | // If user dismissed, re-trigger the modal 34 | showShutdownModal(); 35 | startShutdownTimer(); 36 | }, messageTimeout * 60 * 1000); // 5 minutes timeout for dismissal 37 | }; 38 | 39 | const handleDismiss = () => { 40 | if (shutdownTimer.current) clearTimeout(shutdownTimer.current); 41 | 42 | // Start the extended timer (5 minutes) 43 | startExtendedTimer(); 44 | 45 | // Update state to hide the modal 46 | updateApp((state) => { 47 | state.system.modal.visible = false; 48 | }); 49 | }; 50 | 51 | const showShutdownModal = () => { 52 | updateApp((state) => { 53 | state.system.modal.visible = true; 54 | state.system.modal.title = 'Ignition Off.'; 55 | state.system.modal.body = `System will shut down in ${shutdownDelay} seconds to prevent battery drain. \nClick to dismiss for ${messageTimeout} minutes.`; 56 | state.system.modal.button = 'DISMISS'; 57 | state.system.modal.action = handleDismiss; 58 | }); 59 | }; 60 | 61 | useEffect(() => { 62 | if (!ignition && autoShutdown) { 63 | // If ignition is off, show the shutdown modal and start the shutdown timer 64 | showShutdownModal(); 65 | startShutdownTimer(); 66 | } else { 67 | // If ignition is on, hide the modal (cleanup) 68 | if (shutdownTimer.current) clearTimeout(shutdownTimer.current); 69 | if (extendedTimer.current) clearTimeout(extendedTimer.current); 70 | 71 | updateApp((state) => { 72 | state.system.modal.visible = false; 73 | }); 74 | } 75 | 76 | // Cleanup timers on component unmount or when conditions change 77 | return () => { 78 | if (shutdownTimer.current) clearTimeout(shutdownTimer.current); 79 | if (extendedTimer.current) clearTimeout(extendedTimer.current); 80 | }; 81 | }, [ignition, autoShutdown, shutdownDelay, messageTimeout, updateApp]); 82 | 83 | return null; 84 | }; 85 | 86 | export default Ignition; 87 | -------------------------------------------------------------------------------- /frontend/src/cardata/helper/Recorder.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { saveAs } from 'file-saver'; 3 | 4 | interface RecorderProps { 5 | data: Record; // dynamic sensor data 6 | resolution: number; // update interval in ms 7 | recording: boolean; 8 | settings: any; // Assuming settings are passed in as a prop, or you can get it from context or store 9 | modules: any; // Assuming modules are passed in as a prop, or you can get it from context or store 10 | } 11 | 12 | const Recorder: React.FC = ({ data, resolution, recording, settings, modules }) => { 13 | const [recordedData, setRecordedData] = useState>({}); 14 | const dataRef = useRef(data); // Reference to keep latest data 15 | const recordedDataRef = useRef(recordedData); // Ref to store the latest recordedData for export 16 | 17 | // Dynamically generate datasets for all the keys in data object 18 | const datasets = Object.keys(data).map((sensorLabel) => { 19 | const config = modules['sensorType']?.(settings); // Retrieve the configuration for each sensor type if needed 20 | 21 | return { 22 | label: sensorLabel, // Use the key as the label (or map it to a human-readable label if desired) 23 | sensorLabel, // Same as key, it's the identifier for the data 24 | yMin: config?.min_value ?? -Infinity, // Provide default min/max if not available 25 | yMax: config?.max_value ?? Infinity, 26 | }; 27 | }); 28 | 29 | // Update data ref to avoid stale closure 30 | useEffect(() => { 31 | dataRef.current = data; 32 | }, [data]); 33 | 34 | // Recording logic 35 | useEffect(() => { 36 | let interval: NodeJS.Timeout | null = null; 37 | 38 | if (recording) { 39 | interval = setInterval(() => { 40 | const timestamp = new Date().toISOString(); 41 | 42 | setRecordedData(prevData => { 43 | const updated = { ...prevData }; 44 | 45 | datasets.forEach(({ label, sensorLabel, yMin, yMax }) => { 46 | const value = dataRef.current[sensorLabel]; // Get the value from data using sensorLabel 47 | const numValue = isNaN(Number(value)) ? 0 : Math.max(yMin, Math.min(Number(value), yMax)); // Ensure value is within yMin and yMax 48 | 49 | if (!updated[label]) updated[label] = []; // Initialize an empty array for each label 50 | updated[label].push({ timestamp, value: numValue }); 51 | }); 52 | 53 | // Update the ref with the latest data for export 54 | recordedDataRef.current = updated; 55 | 56 | console.log(updated); // Log the updated data 57 | 58 | return updated; 59 | }); 60 | }, resolution); 61 | } else { 62 | if (interval) { 63 | clearInterval(interval); // Stop recording when recording is false 64 | exportData(); // Export the data when stopping the recording 65 | } 66 | } 67 | 68 | // Cleanup on component unmount or when recording stops 69 | return () => { 70 | if (interval) { 71 | clearInterval(interval); 72 | exportData(); // Export data if it's unmounted or recording stops 73 | } 74 | }; 75 | }, [recording, resolution, settings, modules]); 76 | 77 | const exportData = () => { 78 | const date = new Date(); 79 | const timestamp = date.toISOString().replace(/[-:T.]/g, '_'); 80 | 81 | // Use the ref value for recordedData to export the latest data 82 | const exportObj = Object.keys(recordedDataRef.current).map((label) => { 83 | return { 84 | label, 85 | data: recordedDataRef.current[label]?.map((entry) => ({ 86 | timestamp: entry.timestamp, 87 | value: entry.value, 88 | })) || [], 89 | }; 90 | }); 91 | 92 | const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: 'application/json' }); 93 | saveAs(blob, `V-Link_Recording_${timestamp}.json`); 94 | }; 95 | 96 | return null; 97 | }; 98 | 99 | export default Recorder; 100 | -------------------------------------------------------------------------------- /frontend/src/cardata/worker/ADC.worker.ts: -------------------------------------------------------------------------------- 1 | import { io } from "socket.io-client"; 2 | 3 | let settings; 4 | 5 | // Connect to the adc namespace to receive continuous data stream 6 | const adcChannel = io("ws://localhost:4001/adc"); 7 | 8 | // Function to handle incoming adc settings 9 | const handlesensorSettings = (data) => { 10 | settings = data.sensors; 11 | adcChannel.connect(); 12 | }; 13 | 14 | // Function to update car data 15 | const update = (data) => { 16 | const updatedData = {}; 17 | 18 | if (settings && data != null) { 19 | Object.keys(settings).forEach((key) => { 20 | const message = settings[key]; 21 | const id = message.app_id + ':'; 22 | const regex = new RegExp(id, 'g'); 23 | 24 | if (regex.test(data)) { 25 | const value = data.replace(regex, ""); 26 | updatedData[key] = Number(value).toFixed(2); 27 | } 28 | }); 29 | } 30 | return updatedData; 31 | }; 32 | 33 | // Function to post updated car data to the main thread 34 | const postCarDataToMain = (data) => { 35 | const message = { 36 | type: 'message', 37 | message: data 38 | }; 39 | postMessage(message); 40 | }; 41 | 42 | // Listen for adc settings 43 | adcChannel.on("settings", handlesensorSettings); 44 | 45 | // Listen for continuous data stream from adc namespace 46 | adcChannel.on("data", (data) => { 47 | data = update(data); 48 | postCarDataToMain(data); 49 | }); 50 | 51 | // Send a request for adc settings 52 | adcChannel.emit("load"); 53 | 54 | 55 | 56 | onmessage = async (event: MessageEvent) => { 57 | switch (event.data.type) { 58 | case 'hello': 59 | postMessage("Webworker says hi?") 60 | break 61 | } 62 | } -------------------------------------------------------------------------------- /frontend/src/cardata/worker/CAN.worker.ts: -------------------------------------------------------------------------------- 1 | import { io } from "socket.io-client"; 2 | 3 | let settings; 4 | 5 | // Connect to the canbus namespace to receive continuous data stream 6 | const canChannel = io("ws://localhost:4001/can"); 7 | 8 | // Function to handle incoming canbus settings 9 | const handlesensorSettings = (data) => { 10 | settings = data.sensors; 11 | canChannel.connect(); 12 | }; 13 | 14 | // Function to update car data 15 | const update = (data) => { 16 | const updatedData = {}; 17 | 18 | if (settings && data != null) { 19 | Object.keys(settings).forEach((key) => { 20 | const message = settings[key]; 21 | const id = message.app_id + ':'; 22 | const regex = new RegExp(id, 'g'); 23 | 24 | if (regex.test(data)) { 25 | const value = data.replace(regex, ""); 26 | updatedData[key] = Number(value).toFixed(2); 27 | } 28 | }); 29 | } 30 | return updatedData; 31 | }; 32 | 33 | // Function to post updated car data to the main thread 34 | const postCarDataToMain = (data) => { 35 | const message = { 36 | type: 'message', 37 | message: data 38 | }; 39 | postMessage(message); 40 | }; 41 | 42 | // Listen for canbus settings 43 | canChannel.on("settings", handlesensorSettings); 44 | 45 | // Listen for continuous data stream from canbus namespace 46 | canChannel.on("data", (data) => { 47 | data = update(data); 48 | postCarDataToMain(data); 49 | }); 50 | 51 | // Send a request for canbus settings 52 | canChannel.emit("load"); 53 | 54 | 55 | 56 | onmessage = async (event: MessageEvent) => { 57 | switch (event.data.type) { 58 | case 'hello': 59 | postMessage("Webworker says hi?") 60 | break 61 | } 62 | } -------------------------------------------------------------------------------- /frontend/src/cardata/worker/types.ts: -------------------------------------------------------------------------------- 1 | export type Command = 2 | | { type: 'hello' } 3 | 4 | export interface CANWorker 5 | extends Omit { 6 | postMessage(message: Command, transfer?: Transferable[]): void 7 | onmessage: ((this: Worker, msg: CardataMessage) => any) | null 8 | } 9 | 10 | export interface ADCWorker 11 | extends Omit { 12 | postMessage(message: Command, transfer?: Transferable[]): void 13 | onmessage: ((this: Worker, msg: CardataMessage) => any) | null 14 | } -------------------------------------------------------------------------------- /frontend/src/carplay/Config.tsx: -------------------------------------------------------------------------------- 1 | import { Stream } from 'socketmost/dist/modules/Messages' 2 | import { DongleConfig } from 'node-carplay/node' 3 | 4 | export type Most = { 5 | stream?: Stream 6 | } 7 | 8 | export type ExtraConfig = DongleConfig & { 9 | kiosk: boolean, 10 | camera: string, 11 | microphone: string, 12 | piMost: boolean, 13 | canbus: boolean, 14 | bindings: KeyBindings, 15 | most?: Most, 16 | canConfig?: CanConfig 17 | } 18 | 19 | export interface KeyBindings { 20 | 'left': string, 21 | 'right': string, 22 | 'selectDown': string, 23 | 'back': string, 24 | 'down': string, 25 | 'home': string, 26 | 'play': string, 27 | 'pause': string, 28 | 'next': string, 29 | 'prev': string 30 | } 31 | 32 | export interface CanMessage { 33 | canId: number, 34 | byte: number, 35 | mask: number 36 | } 37 | 38 | export interface CanConfig { 39 | reverse?: CanMessage, 40 | lights?: CanMessage 41 | } -------------------------------------------------------------------------------- /frontend/src/carplay/useCarplayAudio.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react' 2 | import { 3 | AudioCommand, 4 | AudioData, 5 | WebMicrophone, 6 | decodeTypeMap, 7 | } from 'node-carplay/web' 8 | import { PcmPlayer } from 'pcm-ringbuf-player' 9 | import { AudioPlayerKey, CarPlayWorker } from './worker/types' 10 | import { createAudioPlayerKey } from './worker/utils' 11 | 12 | //TODO: allow to configure 13 | const defaultAudioVolume = 1 14 | const defaultNavVolume = 1 15 | 16 | const useCarplayAudio = ( 17 | worker: CarPlayWorker, 18 | microphonePort: MessagePort, 19 | ) => { 20 | const [mic, setMic] = useState(null) 21 | const [audioPlayers] = useState(new Map()) 22 | 23 | const getAudioPlayer = useCallback( 24 | (audio: AudioData): PcmPlayer => { 25 | const { decodeType, audioType } = audio 26 | const format = decodeTypeMap[decodeType] 27 | const audioKey = createAudioPlayerKey(decodeType, audioType) 28 | let player = audioPlayers.get(audioKey) 29 | if (player) return player 30 | player = new PcmPlayer(format.frequency, format.channel) 31 | console.log(player) 32 | 33 | audioPlayers.set(audioKey, player) 34 | player.volume(defaultAudioVolume) 35 | player.start() 36 | worker.postMessage({ 37 | type: 'audioBuffer', 38 | payload: { 39 | sab: player.getRawBuffer(), 40 | decodeType, 41 | audioType, 42 | }, 43 | }) 44 | return player 45 | }, 46 | [audioPlayers, worker], 47 | ) 48 | 49 | const processAudio = useCallback( 50 | (audio: AudioData) => { 51 | if (audio.volumeDuration) { 52 | const { volume, volumeDuration } = audio 53 | const player = getAudioPlayer(audio) 54 | player.volume(volume, volumeDuration) 55 | } else if (audio.command) { 56 | switch (audio.command) { 57 | case AudioCommand.AudioNaviStart: 58 | const navPlayer = getAudioPlayer(audio) 59 | navPlayer.volume(defaultNavVolume) 60 | break 61 | case AudioCommand.AudioMediaStart: 62 | case AudioCommand.AudioOutputStart: 63 | console.log(audio) 64 | const mediaPlayer = getAudioPlayer(audio) 65 | mediaPlayer.volume(defaultAudioVolume) 66 | break 67 | } 68 | } 69 | }, 70 | [getAudioPlayer], 71 | ) 72 | 73 | // audio init 74 | useEffect(() => { 75 | const initMic = async () => { 76 | try { 77 | const mediaStream = await navigator.mediaDevices.getUserMedia({ 78 | audio: true, 79 | }) 80 | const mic = new WebMicrophone(mediaStream, microphonePort) 81 | setMic(mic) 82 | } catch (err) { 83 | console.warn('Failed to init microphone', err) 84 | } 85 | } 86 | 87 | initMic() 88 | 89 | return () => { 90 | audioPlayers.forEach(p => p.stop()) 91 | } 92 | }, [audioPlayers, worker, microphonePort]) 93 | 94 | const startRecording = useCallback(() => { 95 | mic?.start() 96 | }, [mic]) 97 | 98 | const stopRecording = useCallback(() => { 99 | mic?.stop() 100 | }, [mic]) 101 | 102 | return { processAudio, getAudioPlayer, startRecording, stopRecording } 103 | } 104 | 105 | export default useCarplayAudio 106 | -------------------------------------------------------------------------------- /frontend/src/carplay/useCarplayTouch.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react' 2 | import { TouchAction } from 'node-carplay/web' 3 | import { CarPlayWorker } from './worker/types' 4 | 5 | export const useCarplayTouch = ( 6 | worker: CarPlayWorker, 7 | width: number, 8 | height: number, 9 | ) => { 10 | const [pointerdown, setPointerDown] = useState(false) 11 | 12 | const sendTouchEvent: React.PointerEventHandler = useCallback( 13 | e => { 14 | let action = TouchAction.Up 15 | if (e.type === 'pointerdown') { 16 | action = TouchAction.Down 17 | setPointerDown(true) 18 | } else if (pointerdown) { 19 | switch (e.type) { 20 | case 'pointermove': 21 | action = TouchAction.Move 22 | break 23 | case 'pointerup': 24 | case 'pointercancel': 25 | case 'pointerout': 26 | setPointerDown(false) 27 | action = TouchAction.Up 28 | break 29 | } 30 | } else { 31 | return 32 | } 33 | 34 | const { offsetX: x, offsetY: y } = e.nativeEvent 35 | worker.postMessage({ 36 | type: 'touch', 37 | payload: { x: x / width, y: y / height, action }, 38 | }) 39 | }, 40 | [pointerdown, worker, width, height], 41 | ) 42 | 43 | return sendTouchEvent 44 | } -------------------------------------------------------------------------------- /frontend/src/carplay/worker/CarPlay.worker.ts: -------------------------------------------------------------------------------- 1 | import CarplayWeb, { 2 | CarplayMessage, 3 | DongleConfig, 4 | SendAudio, 5 | SendCommand, 6 | SendTouch, 7 | findDevice, 8 | } from 'node-carplay/web' 9 | import { AudioPlayerKey, Command, KeyCommand } from "./types"; 10 | import { RenderEvent } from './render/RenderEvents' 11 | import { RingBuffer } from 'ringbuf.js' 12 | import { createAudioPlayerKey } from './utils' 13 | 14 | //This shouldn't be here, try to fix vite.config.ts..... 15 | import { Buffer } from 'buffer'; 16 | self.Buffer = Buffer; 17 | 18 | let carplayWeb: CarplayWeb | null = null 19 | let videoPort: MessagePort | null = null 20 | let microphonePort: MessagePort | null = null 21 | let config: Partial | null = null 22 | const audioBuffers: Record> = {} 23 | const pendingAudio: Record = {} 24 | 25 | const handleMessage = (message: CarplayMessage) => { 26 | const { type, message: payload } = message 27 | if (type === 'video' && videoPort) { 28 | videoPort.postMessage(new RenderEvent(payload.data), [payload.data.buffer]) 29 | } else if (type === 'audio' && payload.data) { 30 | const { decodeType, audioType } = payload 31 | const audioKey = createAudioPlayerKey(decodeType, audioType) 32 | if (audioBuffers[audioKey]) { 33 | audioBuffers[audioKey].push(payload.data) 34 | } else { 35 | if (!pendingAudio[audioKey]) { 36 | pendingAudio[audioKey] = [] 37 | } 38 | pendingAudio[audioKey].push(payload.data) 39 | payload.data = undefined 40 | 41 | const getPlayerMessage = { 42 | type: 'getAudioPlayer', 43 | message: { 44 | ...payload, 45 | }, 46 | } 47 | postMessage(getPlayerMessage) 48 | } 49 | } else { 50 | postMessage(message) 51 | } 52 | } 53 | 54 | onmessage = async (event: MessageEvent) => { 55 | switch (event.data.type) { 56 | case 'initialise': 57 | if (carplayWeb) return 58 | videoPort = event.data.payload.videoPort 59 | microphonePort = event.data.payload.microphonePort 60 | microphonePort.onmessage = ev => { 61 | if (carplayWeb) { 62 | const data = new SendAudio(ev.data) 63 | carplayWeb.dongleDriver.send(data) 64 | } 65 | } 66 | break 67 | case 'audioBuffer': 68 | const { sab, decodeType, audioType } = event.data.payload 69 | const audioKey = createAudioPlayerKey(decodeType, audioType) 70 | audioBuffers[audioKey] = new RingBuffer(sab, Int16Array) 71 | if (pendingAudio[audioKey]) { 72 | pendingAudio[audioKey].forEach(buf => { 73 | audioBuffers[audioKey].push(buf) 74 | }) 75 | pendingAudio[audioKey] = [] 76 | } 77 | break 78 | case 'start': 79 | if (carplayWeb) return 80 | config = event.data.payload.config 81 | const device = await findDevice() 82 | if (device) { 83 | carplayWeb = new CarplayWeb(config) 84 | carplayWeb.onmessage = handleMessage 85 | carplayWeb.start(device) 86 | } 87 | break 88 | case 'touch': 89 | if (config && carplayWeb) { 90 | const { x, y, action } = event.data.payload 91 | const data = new SendTouch(x, y, action) 92 | carplayWeb.dongleDriver.send(data) 93 | } 94 | break 95 | case 'stop': 96 | await carplayWeb?.stop() 97 | carplayWeb = null 98 | break 99 | case 'frame': 100 | if (carplayWeb) { 101 | const data = new SendCommand('frame') 102 | carplayWeb.dongleDriver.send(data) 103 | } 104 | break 105 | case 'keyCommand': 106 | const command: KeyCommand = event.data.command 107 | const data = new SendCommand(command) 108 | if (carplayWeb) { 109 | carplayWeb.dongleDriver.send(data) 110 | } 111 | } 112 | } 113 | 114 | export { } -------------------------------------------------------------------------------- /frontend/src/carplay/worker/render/Render.worker.ts: -------------------------------------------------------------------------------- 1 | // Based on https://github.com/codewithpassion/foxglove-studio-h264-extension/tree/main 2 | // MIT License 3 | import { getDecoderConfig, isKeyFrame } from './lib/utils' 4 | import { InitEvent, RenderEvent, WorkerEvent } from './RenderEvents' 5 | import { WebGL2Renderer } from './WebGL2Renderer' 6 | import { WebGLRenderer } from './WebGLRenderer' 7 | import { WebGPURenderer } from './WebGPURenderer' 8 | 9 | export interface FrameRenderer { 10 | draw(data: VideoFrame): void 11 | } 12 | 13 | // eslint-disable-next-line no-restricted-globals 14 | const scope = self as unknown as Worker 15 | 16 | type HostType = Window & typeof globalThis 17 | 18 | export class RenderWorker { 19 | constructor(private host: HostType) {} 20 | 21 | private renderer: FrameRenderer | null = null 22 | private videoPort: MessagePort | null = null 23 | private pendingFrame: VideoFrame | null = null 24 | private startTime: number | null = null 25 | private frameCount = 0 26 | private timestamp = 0 27 | private fps = 0 28 | 29 | private onVideoDecoderOutput = (frame: VideoFrame) => { 30 | // Update statistics. 31 | if (this.startTime == null) { 32 | this.startTime = performance.now() 33 | } else { 34 | const elapsed = (performance.now() - this.startTime) / 1000 35 | this.fps = ++this.frameCount / elapsed 36 | } 37 | 38 | // Schedule the frame to be rendered. 39 | this.renderFrame(frame) 40 | } 41 | 42 | private renderFrame = (frame: VideoFrame) => { 43 | if (!this.pendingFrame) { 44 | // Schedule rendering in the next animation frame. 45 | requestAnimationFrame(this.renderAnimationFrame) 46 | } else { 47 | // Close the current pending frame before replacing it. 48 | this.pendingFrame.close() 49 | } 50 | // Set or replace the pending frame. 51 | this.pendingFrame = frame 52 | } 53 | 54 | private renderAnimationFrame = () => { 55 | if (this.pendingFrame) { 56 | this.renderer?.draw(this.pendingFrame) 57 | this.pendingFrame = null 58 | } 59 | } 60 | 61 | private onVideoDecoderOutputError = (err: Error) => { 62 | console.error(`H264 Render worker decoder error`, err) 63 | } 64 | 65 | private decoder = new VideoDecoder({ 66 | output: this.onVideoDecoderOutput, 67 | error: this.onVideoDecoderOutputError, 68 | }) 69 | 70 | init = (event: InitEvent) => { 71 | switch (event.renderer) { 72 | case 'webgl': 73 | this.renderer = new WebGLRenderer(event.canvas) 74 | break 75 | case 'webgl2': 76 | this.renderer = new WebGL2Renderer(event.canvas) 77 | break 78 | case 'webgpu': 79 | this.renderer = new WebGPURenderer(event.canvas) 80 | break 81 | } 82 | this.videoPort = event.videoPort 83 | this.videoPort.onmessage = ev => { 84 | this.onFrame(ev.data as RenderEvent) 85 | } 86 | 87 | if (event.reportFps) { 88 | setInterval(() => { 89 | if (this.decoder.state === 'configured') { 90 | console.debug(`FPS: ${this.fps}`) 91 | } 92 | }, 5000) 93 | } 94 | } 95 | 96 | onFrame = (event: RenderEvent) => { 97 | const frameData = new Uint8Array(event.frameData) 98 | 99 | if (this.decoder.state === 'unconfigured') { 100 | const decoderConfig = getDecoderConfig(frameData) 101 | if (decoderConfig) { 102 | this.decoder.configure(decoderConfig) 103 | console.log(decoderConfig); 104 | 105 | /* V-Link Mod */ 106 | scope.postMessage({ 107 | type: 'streamStarted', 108 | config: decoderConfig, 109 | }); 110 | /* V-Link Mod */ 111 | } 112 | } 113 | if (this.decoder.state === 'configured') { 114 | try { 115 | this.decoder.decode( 116 | new EncodedVideoChunk({ 117 | type: isKeyFrame(frameData) ? 'key' : 'delta', 118 | data: frameData, 119 | timestamp: this.timestamp++, 120 | }), 121 | ) 122 | } catch (e) { 123 | console.error(`H264 Render Worker decode error`, e) 124 | } 125 | } 126 | } 127 | } 128 | 129 | // eslint-disable-next-line no-restricted-globals 130 | const worker = new RenderWorker(self) 131 | scope.addEventListener('message', (event: MessageEvent) => { 132 | if (event.data.type === 'init') { 133 | worker.init(event.data as InitEvent) 134 | } 135 | }) 136 | 137 | export {} -------------------------------------------------------------------------------- /frontend/src/carplay/worker/render/RenderEvents.ts: -------------------------------------------------------------------------------- 1 | export type WorkerEventType = 'init' | 'frame' | 'renderDone' 2 | 3 | export type Renderer = 'webgl' | 'webgl2' | 'webgpu' 4 | 5 | export interface WorkerEvent { 6 | type: WorkerEventType 7 | } 8 | 9 | export class RenderEvent implements WorkerEvent { 10 | type: WorkerEventType = 'frame' 11 | 12 | constructor(public frameData: ArrayBuffer) {} 13 | } 14 | 15 | export class InitEvent implements WorkerEvent { 16 | type: WorkerEventType = 'init' 17 | 18 | constructor( 19 | public canvas: OffscreenCanvas, 20 | public videoPort: MessagePort, 21 | public renderer: Renderer = 'webgl', 22 | public reportFps: boolean = false, 23 | ) {} 24 | } -------------------------------------------------------------------------------- /frontend/src/carplay/worker/render/WebGL2Renderer.ts: -------------------------------------------------------------------------------- 1 | // Based on https://github.com/w3c/webcodecs/blob/main/samples/video-decode-display/renderer_webgl.js 2 | // License: https://www.w3.org/copyright/software-license-2023/ 3 | 4 | import { FrameRenderer } from './Render.worker' 5 | 6 | export class WebGL2Renderer implements FrameRenderer { 7 | #canvas: OffscreenCanvas | null = null 8 | #ctx: WebGL2RenderingContext | null = null 9 | 10 | static vertexShaderSource = ` 11 | attribute vec2 xy; 12 | 13 | varying highp vec2 uv; 14 | 15 | void main(void) { 16 | gl_Position = vec4(xy, 0.0, 1.0); 17 | // Map vertex coordinates (-1 to +1) to UV coordinates (0 to 1). 18 | // UV coordinates are Y-flipped relative to vertex coordinates. 19 | uv = vec2((1.0 + xy.x) / 2.0, (1.0 - xy.y) / 2.0); 20 | } 21 | ` 22 | 23 | static fragmentShaderSource = ` 24 | varying highp vec2 uv; 25 | 26 | uniform sampler2D texture; 27 | 28 | void main(void) { 29 | gl_FragColor = texture2D(texture, uv); 30 | } 31 | ` 32 | 33 | constructor(canvas: OffscreenCanvas) { 34 | this.#canvas = canvas 35 | const gl = (this.#ctx = canvas.getContext('webgl2')) 36 | if (!gl) { 37 | throw Error('WebGL context is null') 38 | } 39 | 40 | const vertexShader = gl.createShader(gl.VERTEX_SHADER) 41 | 42 | if (!vertexShader) { 43 | throw Error('VertexShader is null') 44 | } 45 | 46 | gl.shaderSource(vertexShader, WebGL2Renderer.vertexShaderSource) 47 | gl.compileShader(vertexShader) 48 | 49 | if (gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS) == null) { 50 | throw gl.getShaderInfoLog(vertexShader) 51 | } 52 | 53 | const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER) 54 | if (!fragmentShader) { 55 | throw Error('FragmentShader is null') 56 | } 57 | gl.shaderSource(fragmentShader, WebGL2Renderer.fragmentShaderSource) 58 | gl.compileShader(fragmentShader) 59 | if (gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS) == null) { 60 | throw gl.getShaderInfoLog(fragmentShader) 61 | } 62 | 63 | const shaderProgram = gl.createProgram() 64 | if (!shaderProgram) { 65 | throw Error('ShaderProgram is null') 66 | } 67 | gl.attachShader(shaderProgram, vertexShader) 68 | gl.attachShader(shaderProgram, fragmentShader) 69 | gl.linkProgram(shaderProgram) 70 | if (gl.getProgramParameter(shaderProgram, gl.LINK_STATUS) == null) { 71 | throw gl.getProgramInfoLog(shaderProgram) 72 | } 73 | gl.useProgram(shaderProgram) 74 | 75 | // Vertex coordinates, clockwise from bottom-left. 76 | const vertexBuffer = gl.createBuffer() 77 | gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer) 78 | gl.bufferData( 79 | gl.ARRAY_BUFFER, 80 | new Float32Array([-1.0, -1.0, -1.0, +1.0, +1.0, +1.0, +1.0, -1.0]), 81 | gl.STATIC_DRAW, 82 | ) 83 | 84 | const xyLocation = gl.getAttribLocation(shaderProgram, 'xy') 85 | gl.vertexAttribPointer(xyLocation, 2, gl.FLOAT, false, 0, 0) 86 | gl.enableVertexAttribArray(xyLocation) 87 | 88 | // Create one texture to upload frames to. 89 | const texture = gl.createTexture() 90 | gl.bindTexture(gl.TEXTURE_2D, texture) 91 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) 92 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) 93 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) 94 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) 95 | } 96 | 97 | draw(frame: VideoFrame): void { 98 | if (this.#canvas) { 99 | this.#canvas.width = frame.displayWidth 100 | this.#canvas.height = frame.displayHeight 101 | } 102 | 103 | const gl = this.#ctx! 104 | 105 | // Upload the frame. 106 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, frame) 107 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 108 | frame.close() 109 | 110 | // Configure and clear the drawing area. 111 | gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight) 112 | gl.clearColor(1.0, 0.0, 0.0, 1.0) 113 | gl.clear(gl.COLOR_BUFFER_BIT) 114 | 115 | // Draw the frame. 116 | gl.drawArrays(gl.TRIANGLE_FAN, 0, 4) 117 | } 118 | } -------------------------------------------------------------------------------- /frontend/src/carplay/worker/render/WebGLRenderer.ts: -------------------------------------------------------------------------------- 1 | // Based on https://github.com/w3c/webcodecs/blob/main/samples/video-decode-display/renderer_webgl.js 2 | // License: https://www.w3.org/copyright/software-license-2023/ 3 | 4 | import { FrameRenderer } from './Render.worker' 5 | 6 | export class WebGLRenderer implements FrameRenderer { 7 | #canvas: OffscreenCanvas | null = null 8 | #ctx: WebGLRenderingContext | null = null 9 | 10 | static vertexShaderSource = ` 11 | attribute vec2 xy; 12 | 13 | varying highp vec2 uv; 14 | 15 | void main(void) { 16 | gl_Position = vec4(xy, 0.0, 1.0); 17 | // Map vertex coordinates (-1 to +1) to UV coordinates (0 to 1). 18 | // UV coordinates are Y-flipped relative to vertex coordinates. 19 | uv = vec2((1.0 + xy.x) / 2.0, (1.0 - xy.y) / 2.0); 20 | } 21 | ` 22 | 23 | static fragmentShaderSource = ` 24 | varying highp vec2 uv; 25 | 26 | uniform sampler2D texture; 27 | 28 | void main(void) { 29 | gl_FragColor = texture2D(texture, uv); 30 | } 31 | ` 32 | 33 | constructor(canvas: OffscreenCanvas) { 34 | this.#canvas = canvas 35 | const gl = (this.#ctx = canvas.getContext('webgl')) 36 | if (!gl) { 37 | throw Error('WebGL context is null') 38 | } 39 | 40 | const vertexShader = gl.createShader(gl.VERTEX_SHADER) 41 | 42 | if (!vertexShader) { 43 | throw Error('VertexShader is null') 44 | } 45 | 46 | gl.shaderSource(vertexShader, WebGLRenderer.vertexShaderSource) 47 | gl.compileShader(vertexShader) 48 | 49 | if (gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS) == null) { 50 | throw gl.getShaderInfoLog(vertexShader) 51 | } 52 | 53 | const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER) 54 | if (!fragmentShader) { 55 | throw Error('FragmentShader is null') 56 | } 57 | gl.shaderSource(fragmentShader, WebGLRenderer.fragmentShaderSource) 58 | gl.compileShader(fragmentShader) 59 | if (gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS) == null) { 60 | throw gl.getShaderInfoLog(fragmentShader) 61 | } 62 | 63 | const shaderProgram = gl.createProgram() 64 | if (!shaderProgram) { 65 | throw Error('ShaderProgram is null') 66 | } 67 | gl.attachShader(shaderProgram, vertexShader) 68 | gl.attachShader(shaderProgram, fragmentShader) 69 | gl.linkProgram(shaderProgram) 70 | if (gl.getProgramParameter(shaderProgram, gl.LINK_STATUS) == null) { 71 | throw gl.getProgramInfoLog(shaderProgram) 72 | } 73 | gl.useProgram(shaderProgram) 74 | 75 | // Vertex coordinates, clockwise from bottom-left. 76 | const vertexBuffer = gl.createBuffer() 77 | gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer) 78 | gl.bufferData( 79 | gl.ARRAY_BUFFER, 80 | new Float32Array([-1.0, -1.0, -1.0, +1.0, +1.0, +1.0, +1.0, -1.0]), 81 | gl.STATIC_DRAW, 82 | ) 83 | 84 | const xyLocation = gl.getAttribLocation(shaderProgram, 'xy') 85 | gl.vertexAttribPointer(xyLocation, 2, gl.FLOAT, false, 0, 0) 86 | gl.enableVertexAttribArray(xyLocation) 87 | 88 | // Create one texture to upload frames to. 89 | const texture = gl.createTexture() 90 | gl.bindTexture(gl.TEXTURE_2D, texture) 91 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) 92 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) 93 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) 94 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) 95 | } 96 | 97 | draw(frame: VideoFrame): void { 98 | if (this.#canvas) { 99 | this.#canvas.width = frame.displayWidth 100 | this.#canvas.height = frame.displayHeight 101 | } 102 | 103 | const gl = this.#ctx! 104 | 105 | // Upload the frame. 106 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, frame) 107 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 108 | frame.close() 109 | 110 | // Configure and clear the drawing area. 111 | gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight) 112 | gl.clearColor(1.0, 0.0, 0.0, 1.0) 113 | gl.clear(gl.COLOR_BUFFER_BIT) 114 | 115 | // Draw the frame. 116 | gl.drawArrays(gl.TRIANGLE_FAN, 0, 4) 117 | } 118 | } -------------------------------------------------------------------------------- /frontend/src/carplay/worker/render/lib/utils.ts: -------------------------------------------------------------------------------- 1 | // Based on https://github.com/codewithpassion/foxglove-studio-h264-extension/tree/main 2 | // MIT License 3 | import { Bitstream, NALUStream, SPS } from './h264-utils' 4 | 5 | type GetNaluResult = { type: NaluTypes; nalu: Uint8Array; rawNalu: Uint8Array } 6 | 7 | enum NaluTypes { 8 | NDR = 1, 9 | IDR = 5, 10 | SEI = 6, 11 | SPS = 7, 12 | PPS = 8, 13 | AUD = 9, 14 | } 15 | 16 | function getNaluFromStream( 17 | buffer: Uint8Array, 18 | type: NaluTypes, 19 | ): GetNaluResult | null { 20 | const stream = new NALUStream(buffer, { type: 'annexB' }) 21 | 22 | for (const nalu of stream.nalus()) { 23 | if (nalu?.nalu) { 24 | const bitstream = new Bitstream(nalu.nalu) 25 | bitstream.seek(3) 26 | const nal_unit_type = bitstream.u(5) 27 | if (nal_unit_type === type) { 28 | return { type: nal_unit_type, ...nalu } 29 | } 30 | } 31 | } 32 | 33 | return null 34 | } 35 | 36 | function isKeyFrame(frameData: Uint8Array): boolean { 37 | const idr = getNaluFromStream(frameData, NaluTypes.IDR) 38 | return Boolean(idr) 39 | } 40 | 41 | function getDecoderConfig(frameData: Uint8Array): VideoDecoderConfig | null { 42 | const spsNalu = getNaluFromStream(frameData, NaluTypes.SPS) 43 | if (spsNalu) { 44 | const sps = new SPS(spsNalu.nalu) 45 | const decoderConfig: VideoDecoderConfig = { 46 | codec: sps.MIME, 47 | codedHeight: sps.picHeight, 48 | codedWidth: sps.picWidth, 49 | } 50 | return decoderConfig 51 | } 52 | return null 53 | } 54 | 55 | export { getDecoderConfig, isKeyFrame } -------------------------------------------------------------------------------- /frontend/src/carplay/worker/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DongleConfig, 3 | CarplayMessage, 4 | AudioData, 5 | TouchAction, 6 | } from 'node-carplay/web' 7 | 8 | export type AudioPlayerKey = string & { __brand: 'AudioPlayerKey' } 9 | 10 | export type CarplayWorkerMessage = 11 | | { data: CarplayMessage } 12 | | { data: { type: 'requestBuffer'; message: AudioData } } 13 | 14 | export type InitialisePayload = { 15 | videoPort: MessagePort 16 | microphonePort: MessagePort 17 | } 18 | 19 | export type AudioPlayerPayload = { 20 | sab: SharedArrayBuffer 21 | decodeType: number 22 | audioType: number 23 | } 24 | 25 | export type StartPayload = { 26 | config: Partial 27 | } 28 | 29 | export type Command = 30 | | { type: 'frame' } 31 | | { type: 'stop' } 32 | | { type: 'initialise'; payload: InitialisePayload } 33 | | { type: 'audioBuffer'; payload: AudioPlayerPayload } 34 | | { type: 'start'; payload: StartPayload } 35 | | { 36 | type: 'touch' 37 | payload: { x: number; y: number; action: TouchAction } 38 | } 39 | 40 | export interface CarPlayWorker 41 | extends Omit { 42 | postMessage(message: Command, transfer?: Transferable[]): void 43 | onmessage: ((this: Worker, ev: CarplayWorkerMessage) => any) | null 44 | } -------------------------------------------------------------------------------- /frontend/src/carplay/worker/utils.ts: -------------------------------------------------------------------------------- 1 | import { decodeTypeMap } from 'node-carplay/web' 2 | import { AudioPlayerKey } from './types' 3 | 4 | export const createAudioPlayerKey = (decodeType: number, audioType: number) => { 5 | const format = decodeTypeMap[decodeType] 6 | const audioKey = [format.frequency, format.channel, audioType].join('_') 7 | return audioKey as AudioPlayerKey 8 | } -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | 9 | -webkit-user-select: none; 10 | /* Safari */ 11 | -moz-user-select: none; 12 | /* Firefox */ 13 | -ms-user-select: none; 14 | /* IE10+/Edge */ 15 | user-select: none; 16 | /* Standard */ 17 | 18 | } 19 | 20 | code { 21 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 22 | monospace; 23 | } -------------------------------------------------------------------------------- /frontend/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client' 2 | import App from './App.tsx' 3 | import './index.css' 4 | 5 | ReactDOM.createRoot(document.getElementById('root')!).render( 6 | 7 | ) 8 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals' 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry) 7 | getFID(onPerfEntry) 8 | getFCP(onPerfEntry) 9 | getLCP(onPerfEntry) 10 | getTTFB(onPerfEntry) 11 | }) 12 | } 13 | } 14 | 15 | export default reportWebVitals 16 | -------------------------------------------------------------------------------- /frontend/src/setupProxy.js: -------------------------------------------------------------------------------- 1 | module.exports = function (app) { 2 | app.use(function (req, res, next) { 3 | res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') 4 | res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp') 5 | next() 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom' 6 | -------------------------------------------------------------------------------- /frontend/src/socket/Socket.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | import { io } from 'socket.io-client'; 3 | import { APP, MMI, CAN, LIN, ADC, RTI } from '../store/Store'; 4 | 5 | // Define all modules for easy iteration and reference 6 | const modules = { 7 | app: APP, 8 | // mmi: MMI, // Uncomment if needed 9 | can: CAN, 10 | lin: LIN, 11 | adc: ADC, 12 | rti: RTI 13 | }; 14 | 15 | // Create socket connections for each module 16 | const socket = {}; 17 | Object.keys(modules).forEach(module => { 18 | socket[module] = io(`ws://localhost:4001/${module}`); 19 | }); 20 | 21 | const sysChannel = io("ws://localhost:4001/sys") 22 | 23 | export const Socket = () => { 24 | 25 | // Initialize all Zustand stores and map them to module names 26 | const store = Object.fromEntries( 27 | Object.entries(modules).map(([key, useStore]) => [key, useStore()]) 28 | ); 29 | 30 | // Track the total number of modules 31 | const totalModules = Object.keys(modules).length; 32 | 33 | // State to track how many modules have fully loaded 34 | const [loadedModules, setLoadedModules] = useState(0); 35 | 36 | // Ref to store a Set of loaded modules, preventing duplicate entries and helping to manage loading state 37 | const loadedModuleSet = useRef(new Set()); 38 | 39 | /* Initialize App */ 40 | useEffect(() => { 41 | // When loadedModules matches totalModules, all modules have been initialized 42 | if (loadedModules === totalModules) { 43 | console.log('App ready.') 44 | store['app'].update((state) => { 45 | state.modules = modules; 46 | state.system.startedUp = true; 47 | state.system.view = state.settings.general.startPage.value; 48 | }); 49 | } 50 | }, [loadedModules]); 51 | 52 | /* Wait for Settings */ 53 | useEffect(() => { 54 | // Handles settings update for each module, ensuring each module loads once 55 | const handleSettings = (module) => (data) => { 56 | // Add the module to the loaded set 57 | loadedModuleSet.current.add(module); 58 | 59 | // Update the loadedModules state based on the set size, ensuring accurate count 60 | setLoadedModules(loadedModuleSet.current.size); 61 | 62 | // Update the store with the new settings data 63 | store[module].update((state) => { 64 | state.settings = data; 65 | }); 66 | }; 67 | 68 | const handleIgnition = () => (ign_state) => { 69 | console.log('Ignition: ', ign_state) 70 | store['app'].update((state) => { 71 | state.system.ignition = ign_state 72 | }); 73 | } 74 | 75 | // Handles state updates for each module 76 | const handleState = (module) => (data) => { 77 | store['app'].update((state) => { 78 | state.system[`${module}State`] = data; 79 | }); 80 | //console.log("handling state, ", module, data); 81 | }; 82 | 83 | // Register state and settings listeners for each module 84 | Object.keys(modules).forEach(module => { 85 | if (module !== 'mmi') { 86 | socket[module].on('state', handleState(module)); 87 | socket[module].emit('ping'); 88 | } 89 | }); 90 | 91 | // Load settings for each module 92 | Object.keys(modules).forEach(module => { 93 | socket[module].on('settings', handleSettings(module)); 94 | socket[module].emit('load'); 95 | }); 96 | 97 | sysChannel.on('ign', handleIgnition()) 98 | sysChannel.emit('systemTask', 'ign') 99 | 100 | // Clean up listeners on component unmount 101 | return () => { 102 | Object.keys(modules).forEach(module => { 103 | socket[module].off('settings', handleSettings(module)); 104 | socket[module].off('state', handleState(module)); 105 | sysChannel.off('ign', handleIgnition()) 106 | 107 | }); 108 | }; 109 | }, []); 110 | 111 | return null; 112 | }; 113 | -------------------------------------------------------------------------------- /frontend/src/store/Types.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/frontend/src/store/Types.tsx -------------------------------------------------------------------------------- /frontend/src/theme/Animations.js: -------------------------------------------------------------------------------- 1 | import { keyframes } from 'styled-components'; 2 | 3 | const getSlideDown = (height) => keyframes` 4 | 0% { 5 | transform: translateY(${height}px); 6 | } 7 | 100% { 8 | transform: translateY(0px); 9 | } 10 | `; 11 | 12 | const getSlideUp = (height) => keyframes` 13 | 0% { 14 | transform: translateY(0px); 15 | } 16 | 100% { 17 | transform: translateY(${height}px); 18 | } 19 | `; 20 | 21 | const getSlideLeft = (height) => keyframes` 22 | 0% { 23 | transform: translateX(${height}px); 24 | } 25 | 100% { 26 | transform: translateX(0px); 27 | } 28 | `; 29 | 30 | const getSlideRight = (height) => keyframes` 31 | 0% { 32 | transform: translateX(0px); 33 | } 34 | 100% { 35 | transform: translateX(${height}px); 36 | } 37 | `; 38 | 39 | const getVerticalExpand = (minHeight, maxHeight) => keyframes` 40 | 0% { 41 | height: ${minHeight}px; /* Hidden */ 42 | } 43 | 100% { 44 | height: ${maxHeight}px; /* Fully visible */ 45 | } 46 | `; 47 | 48 | const getVerticalCollapse = (minHeight, maxHeight) => keyframes` 49 | 0% { 50 | height: ${maxHeight}px; /* Fully visible */ 51 | } 52 | 100% { 53 | height: ${minHeight}px; /* Hidden */ 54 | } 55 | `; 56 | 57 | const getHorizontalExpand = (minWidth, maxWidth) => keyframes` 58 | 0% { 59 | width: ${minWidth}px; /* Starting at the collapsed width */ 60 | } 61 | 100% { 62 | width: ${maxWidth}px; /* Expanding to the full width */ 63 | } 64 | `; 65 | 66 | const getHorizontalCollapse = (minWidth, maxWidth) => keyframes` 67 | 0% { 68 | width: ${maxWidth}px; /* Starting at the full width */ 69 | } 70 | 100% { 71 | width: ${minWidth}px; /* Collapsing to the hidden width */ 72 | display: 'none' 73 | } 74 | `; 75 | 76 | export const animations = { 77 | getSlideDown, 78 | getSlideUp, 79 | getSlideLeft, 80 | getSlideRight, 81 | getVerticalExpand, 82 | getVerticalCollapse, 83 | getHorizontalExpand, 84 | getHorizontalCollapse 85 | }; -------------------------------------------------------------------------------- /frontend/src/theme/Theme.js: -------------------------------------------------------------------------------- 1 | import { animations } from './Animations'; 2 | 3 | export const theme = { 4 | animations: animations, 5 | 6 | fonts: { 7 | spartan: "'League Spartan', sans-serif", 8 | inter: "'Inter 18pt', sans-serif", 9 | }, 10 | 11 | icons: { 12 | small: '14px', 13 | medium: '18px', 14 | large: '32px', 15 | xlarge: '64px', 16 | }, 17 | 18 | interaction: { 19 | toggleHeight: '20', 20 | buttonHeight: '40', 21 | buttonWidth: '200', 22 | }, 23 | 24 | colors: { 25 | button: '#090909', 26 | navbar: '#090909', 27 | 28 | dark: '#202020', 29 | medium: '#404040', 30 | light: '#DBDBDB', 31 | 32 | text: '#DBDBDB', 33 | background: '#141414', 34 | navbar: '#090909', 35 | 36 | bg1: '#0D0D0D', 37 | bg2: '#1C1C1C', 38 | bg3: '#1C1C1C', 39 | 40 | gradients: { 41 | gradient1: 'linear-gradient(180deg, #1C1C1C,#0D0D0D)', 42 | gradient2: 'linear-gradient(180deg, #0D0D0D, #1C1C1C)', 43 | gradient3: 'linear-gradient(180deg, #141414, #0D0D0D)', 44 | }, 45 | 46 | theme: { 47 | green: { 48 | default: '#385538', 49 | active: '#5ADC5A', 50 | navGlow: '0 0 20px rgba(90, 220, 90, 1)', // Define your glow here 51 | highlightDark: '#9E3C3C', 52 | highlightLight: '#FF0000', 53 | }, 54 | 55 | blue: { 56 | default: '#2B4459', 57 | active: '#70B6EF', 58 | navGlow: '0 0 20px rgba(112, 182, 239, 1)', // Define your glow here 59 | highlightDark: '#9E3C3C', 60 | highlightLight: '#FF0000', 61 | }, 62 | 63 | red: { 64 | default: '#492020', 65 | active: '#CC3636', 66 | navGlow: '0 0 20px rgba(204, 54, 54, 1)', // Define your glow here 67 | highlightDark: '#9E3C3C', 68 | highlightLight: '#FF0000', 69 | }, 70 | 71 | white: { 72 | default: '#404040', 73 | active: '#DBDBDB', 74 | navGlow: '0 0 20px rgba(219, 219, 219, 1)', // Define your glow here 75 | highlightDark: '#9E3C3C', 76 | highlightLight: '#FF0000', 77 | }, 78 | }, 79 | 80 | }, 81 | 82 | fontWeights: { 83 | light: 300, 84 | regular: 400, 85 | semiBold: 600, 86 | bold: 700, 87 | }, 88 | 89 | typography: { 90 | display4: { 91 | fontFamily: "'League Spartan', sans-serif", 92 | fontWeight: 700, 93 | fontSize: '64pt', 94 | }, 95 | display3: { 96 | fontFamily: "'League Spartan', sans-serif", 97 | fontWeight: 700, 98 | fontSize: '32pt', 99 | }, 100 | display2: { 101 | fontFamily: "'League Spartan', sans-serif", 102 | fontWeight: 700, 103 | fontSize: '20pt', 104 | }, 105 | display1: { 106 | fontFamily: "'League Spartan', sans-serif", 107 | fontWeight: 400, 108 | fontSize: '17pt', 109 | }, 110 | title: { 111 | fontFamily: "'Inter 18pt', sans-serif", 112 | fontWeight: 600, 113 | fontSize: '17pt', 114 | }, 115 | subtitle: { 116 | fontFamily: "'Inter 18pt', sans-serif", 117 | fontWeight: 600, 118 | fontSize: '14pt', 119 | }, 120 | body2: { 121 | fontFamily: "'League Spartan', sans-serif", 122 | fontWeight: 700, 123 | fontSize: '13pt', 124 | }, 125 | body1: { 126 | fontFamily: "'League Spartan', sans-serif", 127 | fontWeight: 400, 128 | fontSize: '12pt', 129 | }, 130 | caption2: { 131 | fontFamily: "'League Spartan', sans-serif", 132 | fontWeight: 400, 133 | fontSize: '12pt', 134 | }, 135 | caption1: { 136 | fontFamily: "'League Spartan', sans-serif", 137 | fontWeight: 400, 138 | fontSize: '8pt', 139 | }, 140 | button: { 141 | fontFamily: "'League Spartan', sans-serif", 142 | fontWeight: 400, 143 | fontSize: '12pt', 144 | }, 145 | }, 146 | }; -------------------------------------------------------------------------------- /frontend/src/theme/fonts.module.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Inter 18pt'; 3 | src: url('/assets/fonts/Inter18pt-LightItalic.woff2') format('woff2'), 4 | url('Inter18pt-LightItalic.woff') format('woff'); 5 | font-weight: 300; 6 | font-style: italic; 7 | font-display: swap; 8 | } 9 | 10 | @font-face { 11 | font-family: 'Inter 18pt'; 12 | src: url('/assets/fonts/Inter18pt-SemiBold.woff2') format('woff2'), 13 | url('Inter18pt-SemiBold.woff') format('woff'); 14 | font-weight: 600; 15 | font-style: normal; 16 | font-display: swap; 17 | } 18 | 19 | @font-face { 20 | font-family: 'League Spartan'; 21 | src: url('/assets/fonts/LeagueSpartan-Bold.woff2') format('woff2'), 22 | url('LeagueSpartan-Bold.woff') format('woff'); 23 | font-weight: bold; 24 | font-style: normal; 25 | font-display: swap; 26 | } 27 | 28 | @font-face { 29 | font-family: 'League Spartan'; 30 | src: url('/assets/fonts/LeagueSpartan-Regular.woff2') format('woff2'), 31 | url('LeagueSpartan-Regular.woff') format('woff'); 32 | font-weight: normal; 33 | font-style: normal; 34 | font-display: swap; 35 | } 36 | 37 | 38 | -------------------------------------------------------------------------------- /frontend/src/theme/styles/Container.js: -------------------------------------------------------------------------------- 1 | import styled, { css }from 'styled-components'; 2 | 3 | export const FlexBox = styled.div` 4 | display: flex; 5 | flex-direction: row; 6 | justify-content: center; 7 | align-items: center; 8 | 9 | width: 100%; 10 | height: 100%; 11 | ` -------------------------------------------------------------------------------- /frontend/src/theme/styles/Effects.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import hexToRgba from '../../app/helper/HexToRGBA' 3 | 4 | export const Fade = styled.div` 5 | flex: 1; 6 | 7 | display: flex; 8 | flex-direction: column; 9 | 10 | height: 100%; 11 | width: 100%; 12 | 13 | overflow: hidden; 14 | 15 | opacity: 0; 16 | transition: opacity ${({ fadeLength }) => `${fadeLength}s`} ease-in; 17 | 18 | &.fade-in { 19 | opacity: 1; 20 | } 21 | 22 | &.fade-out { 23 | opacity: 0; 24 | } 25 | `; 26 | 27 | export const GlowLarge = styled.div` 28 | box-shadow: 0 0 50px ${({ color, opacity }) => hexToRgba(color, opacity)}; 29 | 30 | transition: box-shadow 0.4s ease-in-out; 31 | background: none; 32 | `; -------------------------------------------------------------------------------- /frontend/src/theme/styles/Icons.js: -------------------------------------------------------------------------------- 1 | import styled, { css, useTheme } from 'styled-components'; 2 | import hexToRgba from '../../app/helper/HexToRGBA' 3 | 4 | export const IconSmall = styled.svg` 5 | fill: none; 6 | stroke-width: 3px; 7 | width: ${({ theme }) => theme.icons.small}; 8 | height: ${({ theme }) => theme.icons.small}; 9 | stroke: ${({ theme, isActive }) => 10 | isActive ? theme.colors.light : theme.colors.medium}; 11 | transition: stroke 1s ease-in-out; 12 | `; 13 | 14 | export const IconMedium = styled.svg` 15 | width: ${({ theme }) => theme.icons.medium}; 16 | height: ${({ theme }) => theme.icons.medium}; 17 | fill: none; 18 | stroke-width: 3px; 19 | stroke: ${({ isActive, theme, color, activeColor, inactiveColor }) => 20 | color ? color : isActive ? activeColor : inactiveColor}; 21 | transition: fill 0.3s ease-in-out; 22 | filter: ${({ isActive, theme, activeColor}) => 23 | isActive ? `drop-shadow(${activeColor})` : 'none'}; 24 | `; 25 | 26 | export const IconLarge = styled.svg` 27 | width: ${({ theme }) => theme.icons.large}; 28 | height: ${({ theme }) => theme.icons.large}; 29 | fill:none; 30 | stroke: ${({ isActive, theme, color, activeColor, inactiveColor }) => 31 | color ? color : isActive ? activeColor : inactiveColor}; 32 | transition: fill 0.3s ease-in-out; 33 | filter: ${({ isActive, theme }) => 34 | isActive ? `drop-shadow(${theme.colors.theme.blue.navGlow})` : 'none'}; 35 | &:hover { 36 | fill: ${({ isActive, theme, activeColor, defaultColor }) => 37 | isActive ? activeColor : defaultColor}; 38 | filter: ${({ isActive, theme }) => 39 | isActive ? `drop-shadow(${activeColor})` : 'none'}; 40 | } 41 | `; 42 | 43 | export const IconExtraLarge = styled.svg` 44 | width: ${({ theme }) => theme.icons.xlarge}; 45 | height: ${({ theme }) => theme.icons.xlarge}; 46 | fill: ${({ isActive, theme, activeColor, defaultColor }) => 47 | isActive ? activeColor : defaultColor}; 48 | transition: fill 0.3s ease-in-out; 49 | filter: ${({ color }) => 50 | `drop-shadow(0 0px 100px ${hexToRgba(color, 1)}) 51 | `}; 52 | &:hover { 53 | fill: ${({ defaultColor }) => defaultColor }; 54 | } 55 | `; 56 | 57 | 58 | export const IconNav = styled.svg` 59 | stroke-linecap: round; 60 | fill: none; 61 | width: ${({ theme }) => theme.icons.large}; 62 | height: ${({ theme }) => theme.icons.large}; 63 | stroke-width: 3px; 64 | stroke: ${({ isActive, theme, activeColor, defaultColor }) => 65 | isActive ? activeColor : defaultColor}; 66 | transition: fill 0.3s ease-in-out; 67 | filter: ${({ isActive, theme, glowColor }) => 68 | isActive ? `drop-shadow(${glowColor})` : 'none'}; 69 | &:hover { 70 | stroke: ${({ isActive, theme, activeColor, defaultColor }) => 71 | isActive ? activeColor : defaultColor}; 72 | filter: ${({ isActive, theme, defaultColor }) => 73 | isActive ? `drop-shadow(${defaultColor})` : 'none'}; 74 | } 75 | `; 76 | 77 | export const CustomIcon = styled.svg` 78 | width: ${({ size }) => size}; 79 | height: ${({ size }) => size}; 80 | stroke-width: ${({ stroke }) => stroke}; 81 | stroke-linecap: round; 82 | fill: none; 83 | stroke: ${({ isActive, theme, color, activeColor, defaultColor }) => 84 | color ? color : isActive ? activeColor : defaultColor}; 85 | transition: fill 0.3s ease-in-out; 86 | filter: ${({ isActive, theme, glowColor }) => 87 | isActive ? `drop-shadow(${glowColor})` : 'none'}; 88 | `; 89 | -------------------------------------------------------------------------------- /frontend/src/theme/styles/Inputs.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Link = styled.button` 4 | height: ${({ theme }) => theme.interaction.buttonHeight}px; 5 | width: 100%; 6 | 7 | color: ${({ theme, isActive, activeColor, inactiveColor }) => isActive ? activeColor : inactiveColor}; 8 | font-size: ${({ theme }) => theme.typography.caption2.fontSize}; 9 | 10 | display: flex; 11 | flex-direction: row; 12 | justify-content: left; 13 | align-items: center; 14 | 15 | gap: 10px; 16 | //color: ${({ theme }) => theme.colors.text}; 17 | background: none; 18 | border: none; 19 | 20 | &:hover { 21 | cursor: pointer; 22 | } 23 | `; 24 | 25 | export const Button = styled.button` 26 | height: ${({ theme }) => theme.interaction.buttonHeight}px; 27 | width: 100%; 28 | 29 | color: ${({ theme }) => theme.colors.text}; 30 | font-family: ${({ theme }) => theme.typography.button.fontFamily}; 31 | font-weight: ${({ theme }) => theme.typography.button.fontWeight}; 32 | font-size: ${({ theme }) => theme.typography.button.fontSize}; 33 | 34 | display: flex; 35 | flex-direction: row; 36 | justify-content: center; 37 | align-items: center; 38 | 39 | gap: 10px; 40 | background-color: ${({ theme }) => theme.colors.button};; 41 | border: none; 42 | 43 | border-radius: 10px; 44 | 45 | &:hover { 46 | cursor: pointer; 47 | } 48 | `; 49 | 50 | export const ToggleSwitch = styled.label` 51 | position: relative; 52 | display: inline-block; 53 | width: 50px; /* Width of the toggle */ 54 | height: ${({ theme }) => theme.interaction.toggleHeight}px}; 55 | cursor: pointer; 56 | 57 | input { 58 | opacity: 0; /* Hide the checkbox input */ 59 | width: 0; 60 | height: 0; 61 | } 62 | 63 | /* The background of the toggle */ 64 | .slider { 65 | position: absolute; 66 | top: 0; 67 | left: 0; 68 | right: 0; 69 | bottom: 0; 70 | background-color: ${({ backgroundColor }) => backgroundColor}; /* Start as primary */ 71 | border-radius: 10px; 72 | transition: background-color 0.2s ease; 73 | } 74 | 75 | /* The sliding circle inside the toggle */ 76 | .slider:before { 77 | position: absolute; 78 | content: ''; 79 | height: 16px; 80 | width: 16px; 81 | left: 3px; 82 | bottom: 2px; 83 | background-color: ${({ defaultColor }) => defaultColor}; /* Start as default */ 84 | border-radius: 50%; 85 | transition: transform 0.2s ease, background-color 0.2s ease; /* Smooth transitions for position and color */ 86 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 87 | } 88 | 89 | /* When the input is checked, update styles */ 90 | input:checked + .slider { 91 | background-color: ${({ defaultColor }) => defaultColor}; /* Change to default */ 92 | } 93 | 94 | input:checked + .slider:before { 95 | transform: translateX(28px); /* Move the circle to the right */ 96 | background-color: ${({ activeColor }) =>activeColor}; /* Change to active */ 97 | } 98 | `; 99 | 100 | export const Select = styled.select` 101 | font-size: ${({ textSize = 1, textScale = 1 }) => `${textSize * textScale}vh`}; 102 | height: ${({ theme }) => theme.interaction.buttonHeight}px; 103 | width: 100%; 104 | border-radius: 10px; 105 | text-align: center; 106 | text-decoration: none; 107 | display: inline-block; 108 | border: none; 109 | color: ${({ theme }) => theme.colors.text}; 110 | background-color: ${({ isActive, theme }) => (isActive ? theme.colors.button : theme.colors.medium)}; 111 | 112 | &:focus { 113 | outline: none; 114 | border-color:${({ theme }) => theme.colors.text};; 115 | background-color:${({ theme }) => theme.colors.dark};; 116 | } 117 | `; 118 | 119 | export const Input = styled.input` 120 | height: ${({ theme }) => theme.interaction.buttonHeight}px; 121 | width: 100%; 122 | text-align: center; 123 | text-decoration: none; 124 | display: inline-block; 125 | border: 0; 126 | border-radius: 10px; 127 | opacity: 1; 128 | color: ${({ theme }) => theme.colors.text};; 129 | background-color: ${({ theme }) => theme.colors.button}; 130 | 131 | `; -------------------------------------------------------------------------------- /frontend/src/theme/styles/Typography.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | // Display 4 | export const Display4 = styled.h1` 5 | color: ${({ theme }) => theme.colors.light}; 6 | margin: 0; 7 | font-family: ${({ theme }) => theme.typography.display4.fontFamily}; 8 | font-weight: ${({ theme }) => theme.typography.display4.fontWeight}; 9 | font-size: ${({ theme }) => theme.typography.display4.fontSize}; 10 | `; 11 | 12 | export const Display3 = styled.h2` 13 | color: ${({ theme }) => theme.colors.light}; 14 | font-family: ${({ theme }) => theme.typography.display3.fontFamily}; 15 | font-weight: ${({ theme }) => theme.typography.display3.fontWeight}; 16 | font-size: ${({ theme }) => theme.typography.display3.fontSize}; 17 | `; 18 | 19 | export const Display2 = styled.h3` 20 | color: ${({ theme }) => theme.colors.light}; 21 | font-family: ${({ theme }) => theme.typography.display2.fontFamily}; 22 | font-weight: ${({ theme }) => theme.typography.display2.fontWeight}; 23 | font-size: ${({ theme }) => theme.typography.display2.fontSize}; 24 | `; 25 | 26 | export const Display1 = styled.h4` 27 | color: ${({ theme }) => theme.colors.light}; 28 | font-family: ${({ theme }) => theme.typography.display1.fontFamily}; 29 | font-weight: ${({ theme }) => theme.typography.display1.fontWeight}; 30 | font-size: ${({ theme }) => theme.typography.display1.fontSize}; 31 | `; 32 | 33 | // Title and Subtitle 34 | export const Title = styled.h5` 35 | color: ${({ theme }) => theme.colors.light}; 36 | font-family: ${({ theme }) => theme.typography.title.fontFamily}; 37 | font-weight: ${({ theme }) => theme.typography.title.fontWeight}; 38 | font-size: ${({ theme }) => theme.typography.title.fontSize}; 39 | `; 40 | 41 | export const Subtitle = styled.h6` 42 | color: ${({ theme }) => theme.colors.light}; 43 | font-family: ${({ theme }) => theme.typography.subtitle.fontFamily}; 44 | font-weight: ${({ theme }) => theme.typography.subtitle.fontWeight}; 45 | font-size: ${({ theme }) => theme.typography.subtitle.fontSize}; 46 | `; 47 | 48 | // Body 49 | export const Body2 = styled.p` 50 | color: ${({ theme }) => theme.colors.light}; 51 | font-family: ${({ theme }) => theme.typography.body2.fontFamily}; 52 | font-weight: ${({ theme }) => theme.typography.body2.fontWeight}; 53 | font-size: ${({ theme }) => theme.typography.body2.fontSize}; 54 | `; 55 | 56 | export const Body1 = styled.p` 57 | color: ${({ theme }) => theme.colors.light}; 58 | font-family: ${({ theme }) => theme.typography.body1.fontFamily}; 59 | font-weight: ${({ theme }) => theme.typography.body1.fontWeight}; 60 | font-size: ${({ theme }) => theme.typography.body1.fontSize}; 61 | `; 62 | 63 | // Captions 64 | export const Caption2 = styled.span` 65 | color: ${({ theme }) => theme.colors.light}; 66 | font-family: ${({ theme }) => theme.typography.caption2.fontFamily}; 67 | font-weight: ${({ theme }) => theme.typography.caption2.fontWeight}; 68 | font-size: ${({ theme }) => theme.typography.caption2.fontSize}; 69 | `; 70 | 71 | export const Caption1 = styled.span` 72 | color: ${({ theme }) => theme.colors.light}; 73 | font-family: ${({ theme }) => theme.typography.caption1.fontFamily}; 74 | font-weight: ${({ theme }) => theme.typography.caption1.fontWeight}; 75 | font-size: ${({ theme }) => theme.typography.caption1.fontSize}; 76 | `; 77 | 78 | // Button 79 | export const ButtonText = styled.span` 80 | color: ${({ theme }) => theme.colors.light}; 81 | font-family: ${({ theme }) => theme.typography.button.fontFamily}; 82 | font-weight: ${({ theme }) => theme.typography.button.fontWeight}; 83 | font-size: ${({ theme }) => theme.typography.button.fontSize}; 84 | `; 85 | 86 | export const Typography = { 87 | Display4, 88 | Display3, 89 | Display2, 90 | Display1, 91 | Title, 92 | Subtitle, 93 | Body2, 94 | Body1, 95 | Caption2, 96 | Caption1, 97 | ButtonText 98 | } 99 | -------------------------------------------------------------------------------- /frontend/src/themes.scss: -------------------------------------------------------------------------------- 1 | /*:root { 2 | --backgroundColor: #000000; 3 | 4 | --textColorLight: #dddddd; 5 | --textColorDefault: #aeaeae; 6 | --textColorDark: #535353; 7 | 8 | --boxColorLighter: #444444; 9 | --boxColorLight: #404040; 10 | --boxColorDefault: #303030; 11 | --boxColorDark: #252525; 12 | --boxColorDarker: #181818; 13 | --boxColorDarkest: #121212; 14 | 15 | 16 | --warmGreyDark: #111111; 17 | --warmGreyMedium: #131313; 18 | --warmGreyLight: #151515; 19 | 20 | --coldGreyDark: #161616; 21 | --coldGreyMedium: #181818; 22 | --coldGreyLight: #202020; 23 | 24 | --boxColor5: #050505; 25 | --boxColor6: #060606; 26 | --boxColor7: #070707; 27 | --boxColor8: #080808; 28 | --boxColor9: #090909; 29 | --boxColor10: #101010; 30 | 31 | --bgGradient1: linear-gradient(0deg, var(--warmGreyDark), var(--warmGreyMedium), var(--warmGreyLight)); 32 | --bgGradient2: linear-gradient(0deg, var(--warmGreyLight), var(--warmGreyMedium), var(--warmGreyDark)); 33 | --boxGradient: linear-gradient(0deg, var(--coldGreyDark), var(--coldGreyMedium), var(--coldGreyLight)); 34 | 35 | 36 | --navBG: #0E0E0E; 37 | 38 | } 39 | 40 | .Blue { 41 | --themeLight: #708fa8; 42 | --themeDefault: #3f77a4; 43 | --themeAccent: #d84224; 44 | } 45 | 46 | .Green { 47 | --themeLight: #aaeeaa; 48 | --themeDefault: #5acc5a; 49 | --themeAccent: #bc0013; 50 | } 51 | 52 | .Red { 53 | --themeLight: #cd6969; 54 | --themeDefault: #832727; 55 | --themeAccent: #d80000; 56 | } 57 | 58 | .White { 59 | --themeLight: #9f9f9f; 60 | --themeDefault: #dddddd; 61 | --themeAccent: #cd2424; 62 | }*/ -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["dom", "dom.iterable", "esnext", "WebWorker"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "Bundler", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "types": ["@webgpu/types", "react", "react-dom"], 19 | "typeRoots": ["types", "node_modules/@types"] 20 | }, 21 | "include": ["src", "types", "src/store"] 22 | } 23 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /frontend/types/ringbuf.js/index.d.ts: -------------------------------------------------------------------------------- 1 | type TypedArray = 2 | | Int8Array 3 | | Uint8Array 4 | | Uint8ClampedArray 5 | | Int16Array 6 | | Uint16Array 7 | | Int32Array 8 | | Uint32Array 9 | | Float32Array 10 | | Float64Array 11 | 12 | type TypedArrayConstructor = { 13 | new (): T 14 | new (size: number): T 15 | new (buffer: ArrayBuffer, byteOffset?: number, length?: number): T 16 | BYTES_PER_ELEMENT: number 17 | } 18 | 19 | declare module 'ringbuf.js' { 20 | export class RingBuffer { 21 | constructor(sab: SharedArrayBuffer, type: TypedArrayConstructor) 22 | buf: SharedArrayBuffer 23 | push(elements: T, length?: number, offset = 0): number 24 | pop(elements: TypedArray, length: number, offset = 0): number 25 | empty(): boolean 26 | full(): boolean 27 | capacity(): number 28 | availableRead(): number 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import inject from '@rollup/plugin-inject'; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | react({ 8 | babel: { 9 | plugins: ['babel-plugin-styled-components'], 10 | }, 11 | }), 12 | { 13 | name: "configure-response-headers", 14 | configureServer: (server) => { 15 | server.middlewares.use((_req, res, next) => { 16 | res.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); 17 | res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); 18 | next(); 19 | }); 20 | }, 21 | }, 22 | ], 23 | optimizeDeps: { 24 | esbuildOptions: { 25 | define: { 26 | global: "globalThis", 27 | }, 28 | }, 29 | }, 30 | resolve: { 31 | alias: { 32 | stream: 'stream-browserify', 33 | buffer: 'buffer', 34 | events: 'events', 35 | }, 36 | }, 37 | build: { 38 | rollupOptions: { 39 | plugins: [ 40 | inject({ Buffer: ['buffer', 'Buffer'] }), 41 | ], 42 | }, 43 | }, 44 | }); 45 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask-SocketIO==5.3.6 2 | Flask==3.1.0 3 | Flask-CORS==5.0.0 4 | python-can==4.2.1 5 | python-socketio==5.11.4 6 | Werkzeug==3.1.3 7 | pyautogui==0.9.54 8 | eventlet==0.38.1 9 | websocket-client==1.6.4 10 | adafruit-circuitpython-ads1x15==2.2.24 11 | numpy==1.26.4 12 | gpiod==2.1.3 13 | requests==2.32.3 14 | python-uinput==1.0.1 15 | tabulate==0.9.0 16 | lgpio==0.2.2.0 -------------------------------------------------------------------------------- /resources/bosch/bosch_0261230482.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/bosch/bosch_0261230482.jpg -------------------------------------------------------------------------------- /resources/dtoverlays/mcp2515-can0.dtbo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/dtoverlays/mcp2515-can0.dtbo -------------------------------------------------------------------------------- /resources/dtoverlays/mcp2515-can1.dtbo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/dtoverlays/mcp2515-can1.dtbo -------------------------------------------------------------------------------- /resources/dtoverlays/mcp2515-can2.dtbo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/dtoverlays/mcp2515-can2.dtbo -------------------------------------------------------------------------------- /resources/dtoverlays/v-link.dtbo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/dtoverlays/v-link.dtbo -------------------------------------------------------------------------------- /resources/lcd/RTI_LCD_Mount_v1.4..stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/lcd/RTI_LCD_Mount_v1.4..stl -------------------------------------------------------------------------------- /resources/media/banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/media/banner.jpg -------------------------------------------------------------------------------- /resources/media/display_mount.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/media/display_mount.jpg -------------------------------------------------------------------------------- /resources/media/screensaver.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/media/screensaver.jpg -------------------------------------------------------------------------------- /resources/media/usb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/media/usb.jpg -------------------------------------------------------------------------------- /resources/psu/gerber/PCB_PSU_LIN_2023-01-12-B_Cu.gbr: -------------------------------------------------------------------------------- 1 | %TF.GenerationSoftware,KiCad,Pcbnew,(6.0.10)*% 2 | %TF.CreationDate,2023-01-15T11:08:54+01:00*% 3 | %TF.ProjectId,PCB_PSU_LIN_2023-01-12,5043425f-5053-4555-9f4c-494e5f323032,rev?*% 4 | %TF.SameCoordinates,Original*% 5 | %TF.FileFunction,Copper,L2,Bot*% 6 | %TF.FilePolarity,Positive*% 7 | %FSLAX46Y46*% 8 | G04 Gerber Fmt 4.6, Leading zero omitted, Abs format (unit mm)* 9 | G04 Created by KiCad (PCBNEW (6.0.10)) date 2023-01-15 11:08:54* 10 | %MOMM*% 11 | %LPD*% 12 | G01* 13 | G04 APERTURE LIST* 14 | %TA.AperFunction,ComponentPad*% 15 | %ADD10C,1.600000*% 16 | %TD*% 17 | %TA.AperFunction,ComponentPad*% 18 | %ADD11O,1.600000X1.600000*% 19 | %TD*% 20 | %TA.AperFunction,ComponentPad*% 21 | %ADD12R,1.050000X1.500000*% 22 | %TD*% 23 | %TA.AperFunction,ComponentPad*% 24 | %ADD13O,1.050000X1.500000*% 25 | %TD*% 26 | %TA.AperFunction,ComponentPad*% 27 | %ADD14R,1.700000X1.700000*% 28 | %TD*% 29 | %TA.AperFunction,ComponentPad*% 30 | %ADD15O,1.700000X1.700000*% 31 | %TD*% 32 | %TA.AperFunction,ComponentPad*% 33 | %ADD16R,2.000000X1.905000*% 34 | %TD*% 35 | %TA.AperFunction,ComponentPad*% 36 | %ADD17O,2.000000X1.905000*% 37 | %TD*% 38 | %TA.AperFunction,ComponentPad*% 39 | %ADD18R,1.500000X1.050000*% 40 | %TD*% 41 | %TA.AperFunction,ComponentPad*% 42 | %ADD19O,1.500000X1.050000*% 43 | %TD*% 44 | %TA.AperFunction,ComponentPad*% 45 | %ADD20R,2.200000X2.200000*% 46 | %TD*% 47 | %TA.AperFunction,ComponentPad*% 48 | %ADD21C,2.200000*% 49 | %TD*% 50 | %TA.AperFunction,ComponentPad*% 51 | %ADD22R,1.600000X2.400000*% 52 | %TD*% 53 | %TA.AperFunction,ComponentPad*% 54 | %ADD23O,1.600000X2.400000*% 55 | %TD*% 56 | G04 APERTURE END LIST* 57 | D10* 58 | %TO.P,R4,1*% 59 | %TO.N,POWEROFF*% 60 | X75311107Y-72393220D03* 61 | D11* 62 | %TO.P,R4,2*% 63 | %TO.N,R4_1*% 64 | X65151107Y-72393220D03* 65 | %TD*% 66 | D12* 67 | %TO.P,Q4,1*% 68 | %TO.N,Q3_2*% 69 | X65052674Y-87741994D03* 70 | D13* 71 | %TO.P,Q4,2*% 72 | %TO.N,R4_1*% 73 | X66322674Y-87741994D03* 74 | %TO.P,Q4,3*% 75 | %TO.N,GND*% 76 | X67592674Y-87741994D03* 77 | %TD*% 78 | D14* 79 | %TO.P,P4,1*% 80 | %TO.N,N/C*% 81 | X87657000Y-99035000D03* 82 | D15* 83 | %TO.P,P4,2*% 84 | X90197000Y-99035000D03* 85 | %TO.P,P4,3*% 86 | X92737000Y-99035000D03* 87 | %TO.P,P4,4*% 88 | X95277000Y-99035000D03* 89 | %TD*% 90 | D16* 91 | %TO.P,Q1,1*% 92 | %TO.N,R1_1*% 93 | X95325935Y-64073305D03* 94 | D17* 95 | %TO.P,Q1,2*% 96 | %TO.N,USB_PWR*% 97 | X95325935Y-66613305D03* 98 | %TO.P,Q1,3*% 99 | %TO.N,BATT*% 100 | X95325935Y-69153305D03* 101 | %TD*% 102 | D10* 103 | %TO.P,R1,1*% 104 | %TO.N,R1_1*% 105 | X92069811Y-63019768D03* 106 | D11* 107 | %TO.P,R1,2*% 108 | %TO.N,BATT*% 109 | X92069811Y-73179768D03* 110 | %TD*% 111 | D10* 112 | %TO.P,R2,1*% 113 | %TO.N,IGN*% 114 | X80601521Y-83820000D03* 115 | D11* 116 | %TO.P,R2,2*% 117 | %TO.N,Q2_2*% 118 | X80601521Y-93980000D03* 119 | %TD*% 120 | D18* 121 | %TO.P,Q5,1*% 122 | %TO.N,GND*% 123 | X76317065Y-86595803D03* 124 | D19* 125 | %TO.P,Q5,2*% 126 | %TO.N,IGN*% 127 | X76317065Y-85325803D03* 128 | %TO.P,Q5,3*% 129 | %TO.N,SHUTDOWN*% 130 | X76317065Y-84055803D03* 131 | %TD*% 132 | D10* 133 | %TO.P,R7,1*% 134 | %TO.N,GND*% 135 | X74755012Y-80595080D03* 136 | D11* 137 | %TO.P,R7,2*% 138 | %TO.N,Q3_2*% 139 | X64595012Y-80595080D03* 140 | %TD*% 141 | D18* 142 | %TO.P,Q2,1*% 143 | %TO.N,R1_1*% 144 | X76336262Y-95490000D03* 145 | D19* 146 | %TO.P,Q2,2*% 147 | %TO.N,Q2_2*% 148 | X76336262Y-94220000D03* 149 | %TO.P,Q2,3*% 150 | %TO.N,GND*% 151 | X76336262Y-92950000D03* 152 | %TD*% 153 | D10* 154 | %TO.P,R6,1*% 155 | %TO.N,GND*% 156 | X71392679Y-84704598D03* 157 | D11* 158 | %TO.P,R6,2*% 159 | %TO.N,Q2_2*% 160 | X71392679Y-94864598D03* 161 | %TD*% 162 | D14* 163 | %TO.P,P3,1*% 164 | %TO.N,POWEROFF*% 165 | X81258320Y-70820081D03* 166 | D15* 167 | %TO.P,P3,2*% 168 | %TO.N,SHUTDOWN*% 169 | X81258320Y-73360081D03* 170 | %TD*% 171 | D10* 172 | %TO.P,R5,1*% 173 | %TO.N,IGN*% 174 | X78132375Y-76114116D03* 175 | D11* 176 | %TO.P,R5,2*% 177 | %TO.N,GND*% 178 | X67972375Y-76114116D03* 179 | %TD*% 180 | D10* 181 | %TO.P,R3,1*% 182 | %TO.N,Q3_2*% 183 | X78740000Y-63500000D03* 184 | D11* 185 | %TO.P,R3,2*% 186 | %TO.N,USB_PWR*% 187 | X88900000Y-63500000D03* 188 | %TD*% 189 | D20* 190 | %TO.P,P2,1*% 191 | %TO.N,USB_PWR_DIODE*% 192 | X68580000Y-66040000D03* 193 | D21* 194 | %TO.P,P2,2*% 195 | %TO.N,GND*% 196 | X66040000Y-66040000D03* 197 | %TD*% 198 | D20* 199 | %TO.P,P1,1*% 200 | %TO.N,LIN*% 201 | X86748032Y-82015000D03* 202 | D21* 203 | %TO.P,P1,2*% 204 | %TO.N,GND*% 205 | X86748032Y-79475000D03* 206 | %TO.P,P1,3*% 207 | %TO.N,BATT*% 208 | X86748032Y-76935000D03* 209 | %TO.P,P1,4*% 210 | %TO.N,IGN*% 211 | X86748032Y-74395000D03* 212 | %TD*% 213 | D22* 214 | %TO.P,IC1,1*% 215 | %TO.N,N/C*% 216 | X87657000Y-94713000D03* 217 | D23* 218 | %TO.P,IC1,2*% 219 | X90197000Y-94713000D03* 220 | %TO.P,IC1,3*% 221 | X92737000Y-94713000D03* 222 | %TO.P,IC1,4*% 223 | X95277000Y-94713000D03* 224 | %TO.P,IC1,5*% 225 | %TO.N,GND*% 226 | X95277000Y-87093000D03* 227 | %TO.P,IC1,6*% 228 | %TO.N,LIN*% 229 | X92737000Y-87093000D03* 230 | %TO.P,IC1,7*% 231 | %TO.N,BATT*% 232 | X90197000Y-87093000D03* 233 | %TO.P,IC1,8*% 234 | %TO.N,N/C*% 235 | X87657000Y-87093000D03* 236 | %TD*% 237 | D12* 238 | %TO.P,Q3,1*% 239 | %TO.N,R1_1*% 240 | X64996066Y-95433173D03* 241 | D13* 242 | %TO.P,Q3,2*% 243 | %TO.N,Q3_2*% 244 | X66266066Y-95433173D03* 245 | %TO.P,Q3,3*% 246 | %TO.N,GND*% 247 | X67536066Y-95433173D03* 248 | %TD*% 249 | M02* 250 | -------------------------------------------------------------------------------- /resources/psu/gerber/PCB_PSU_LIN_2023-01-12-B_Mask.gbr: -------------------------------------------------------------------------------- 1 | %TF.GenerationSoftware,KiCad,Pcbnew,(6.0.10)*% 2 | %TF.CreationDate,2023-01-15T11:08:54+01:00*% 3 | %TF.ProjectId,PCB_PSU_LIN_2023-01-12,5043425f-5053-4555-9f4c-494e5f323032,rev?*% 4 | %TF.SameCoordinates,Original*% 5 | %TF.FileFunction,Soldermask,Bot*% 6 | %TF.FilePolarity,Negative*% 7 | %FSLAX46Y46*% 8 | G04 Gerber Fmt 4.6, Leading zero omitted, Abs format (unit mm)* 9 | G04 Created by KiCad (PCBNEW (6.0.10)) date 2023-01-15 11:08:54* 10 | %MOMM*% 11 | %LPD*% 12 | G01* 13 | G04 APERTURE LIST* 14 | %ADD10C,2.000000*% 15 | %ADD11C,1.600000*% 16 | %ADD12O,1.600000X1.600000*% 17 | %ADD13R,1.050000X1.500000*% 18 | %ADD14O,1.050000X1.500000*% 19 | %ADD15R,1.700000X1.700000*% 20 | %ADD16O,1.700000X1.700000*% 21 | %ADD17R,2.000000X1.905000*% 22 | %ADD18O,2.000000X1.905000*% 23 | %ADD19R,1.500000X1.050000*% 24 | %ADD20O,1.500000X1.050000*% 25 | %ADD21C,1.100000*% 26 | %ADD22R,2.200000X2.200000*% 27 | %ADD23C,2.200000*% 28 | %ADD24R,1.600000X2.400000*% 29 | %ADD25O,1.600000X2.400000*% 30 | G04 APERTURE END LIST* 31 | D10* 32 | %TO.C,REF\u002A\u002A*% 33 | X94995570Y-76212882D03* 34 | %TD*% 35 | D11* 36 | %TO.C,R4*% 37 | X75311107Y-72393220D03* 38 | D12* 39 | X65151107Y-72393220D03* 40 | %TD*% 41 | D13* 42 | %TO.C,Q4*% 43 | X65052674Y-87741994D03* 44 | D14* 45 | X66322674Y-87741994D03* 46 | X67592674Y-87741994D03* 47 | %TD*% 48 | D15* 49 | %TO.C,P4*% 50 | X87657000Y-99035000D03* 51 | D16* 52 | X90197000Y-99035000D03* 53 | X92737000Y-99035000D03* 54 | X95277000Y-99035000D03* 55 | %TD*% 56 | D17* 57 | %TO.C,Q1*% 58 | X95325935Y-64073305D03* 59 | D18* 60 | X95325935Y-66613305D03* 61 | X95325935Y-69153305D03* 62 | %TD*% 63 | D11* 64 | %TO.C,R1*% 65 | X92069811Y-63019768D03* 66 | D12* 67 | X92069811Y-73179768D03* 68 | %TD*% 69 | D11* 70 | %TO.C,R2*% 71 | X80601521Y-83820000D03* 72 | D12* 73 | X80601521Y-93980000D03* 74 | %TD*% 75 | D19* 76 | %TO.C,Q5*% 77 | X76317065Y-86595803D03* 78 | D20* 79 | X76317065Y-85325803D03* 80 | X76317065Y-84055803D03* 81 | %TD*% 82 | D11* 83 | %TO.C,R7*% 84 | X74755012Y-80595080D03* 85 | D12* 86 | X64595012Y-80595080D03* 87 | %TD*% 88 | D19* 89 | %TO.C,Q2*% 90 | X76336262Y-95490000D03* 91 | D20* 92 | X76336262Y-94220000D03* 93 | X76336262Y-92950000D03* 94 | %TD*% 95 | D11* 96 | %TO.C,R6*% 97 | X71392679Y-84704598D03* 98 | D12* 99 | X71392679Y-94864598D03* 100 | %TD*% 101 | D15* 102 | %TO.C,P3*% 103 | X81258320Y-70820081D03* 104 | D16* 105 | X81258320Y-73360081D03* 106 | %TD*% 107 | D11* 108 | %TO.C,R5*% 109 | X78132375Y-76114116D03* 110 | D12* 111 | X67972375Y-76114116D03* 112 | %TD*% 113 | D11* 114 | %TO.C,R3*% 115 | X78740000Y-63500000D03* 116 | D12* 117 | X88900000Y-63500000D03* 118 | %TD*% 119 | D21* 120 | %TO.C,P2*% 121 | X66040000Y-63500000D03* 122 | X68580000Y-63500000D03* 123 | D22* 124 | X68580000Y-66040000D03* 125 | D23* 126 | X66040000Y-66040000D03* 127 | %TD*% 128 | D10* 129 | %TO.C,REF\u002A\u002A*% 130 | X95059983Y-81794719D03* 131 | %TD*% 132 | D22* 133 | %TO.C,P1*% 134 | X86748032Y-82015000D03* 135 | D23* 136 | X86748032Y-79475000D03* 137 | X86748032Y-76935000D03* 138 | X86748032Y-74395000D03* 139 | %TD*% 140 | D24* 141 | %TO.C,IC1*% 142 | X87657000Y-94713000D03* 143 | D25* 144 | X90197000Y-94713000D03* 145 | X92737000Y-94713000D03* 146 | X95277000Y-94713000D03* 147 | X95277000Y-87093000D03* 148 | X92737000Y-87093000D03* 149 | X90197000Y-87093000D03* 150 | X87657000Y-87093000D03* 151 | %TD*% 152 | D13* 153 | %TO.C,Q3*% 154 | X64996066Y-95433173D03* 155 | D14* 156 | X66266066Y-95433173D03* 157 | X67536066Y-95433173D03* 158 | %TD*% 159 | M02* 160 | -------------------------------------------------------------------------------- /resources/psu/gerber/PCB_PSU_LIN_2023-01-12-B_Paste.gbr: -------------------------------------------------------------------------------- 1 | %TF.GenerationSoftware,KiCad,Pcbnew,(6.0.10)*% 2 | %TF.CreationDate,2023-01-15T11:08:54+01:00*% 3 | %TF.ProjectId,PCB_PSU_LIN_2023-01-12,5043425f-5053-4555-9f4c-494e5f323032,rev?*% 4 | %TF.SameCoordinates,Original*% 5 | %TF.FileFunction,Paste,Bot*% 6 | %TF.FilePolarity,Positive*% 7 | %FSLAX46Y46*% 8 | G04 Gerber Fmt 4.6, Leading zero omitted, Abs format (unit mm)* 9 | G04 Created by KiCad (PCBNEW (6.0.10)) date 2023-01-15 11:08:54* 10 | %MOMM*% 11 | %LPD*% 12 | G01* 13 | G04 APERTURE LIST* 14 | G04 APERTURE END LIST* 15 | M02* 16 | -------------------------------------------------------------------------------- /resources/psu/gerber/PCB_PSU_LIN_2023-01-12-B_Silkscreen.gbr: -------------------------------------------------------------------------------- 1 | %TF.GenerationSoftware,KiCad,Pcbnew,(6.0.10)*% 2 | %TF.CreationDate,2023-01-15T11:08:54+01:00*% 3 | %TF.ProjectId,PCB_PSU_LIN_2023-01-12,5043425f-5053-4555-9f4c-494e5f323032,rev?*% 4 | %TF.SameCoordinates,Original*% 5 | %TF.FileFunction,Legend,Bot*% 6 | %TF.FilePolarity,Positive*% 7 | %FSLAX46Y46*% 8 | G04 Gerber Fmt 4.6, Leading zero omitted, Abs format (unit mm)* 9 | G04 Created by KiCad (PCBNEW (6.0.10)) date 2023-01-15 11:08:54* 10 | %MOMM*% 11 | %LPD*% 12 | G01* 13 | G04 APERTURE LIST* 14 | G04 APERTURE END LIST* 15 | M02* 16 | -------------------------------------------------------------------------------- /resources/psu/gerber/PCB_PSU_LIN_2023-01-12-Edge_Cuts.gbr: -------------------------------------------------------------------------------- 1 | %TF.GenerationSoftware,KiCad,Pcbnew,(6.0.10)*% 2 | %TF.CreationDate,2023-01-15T11:08:54+01:00*% 3 | %TF.ProjectId,PCB_PSU_LIN_2023-01-12,5043425f-5053-4555-9f4c-494e5f323032,rev?*% 4 | %TF.SameCoordinates,Original*% 5 | %TF.FileFunction,Profile,NP*% 6 | %FSLAX46Y46*% 7 | G04 Gerber Fmt 4.6, Leading zero omitted, Abs format (unit mm)* 8 | G04 Created by KiCad (PCBNEW (6.0.10)) date 2023-01-15 11:08:54* 9 | %MOMM*% 10 | %LPD*% 11 | G01* 12 | G04 APERTURE LIST* 13 | %TA.AperFunction,Profile*% 14 | %ADD10C,0.254000*% 15 | %TD*% 16 | G04 APERTURE END LIST* 17 | D10* 18 | X99060000Y-101600000D02* 19 | X62329000Y-101600000D01* 20 | X62329000Y-101600000D02* 21 | X62329000Y-60960000D01* 22 | X99060000Y-60960000D02* 23 | X99060000Y-101600000D01* 24 | X62329000Y-60960000D02* 25 | X99060000Y-60960000D01* 26 | M02* 27 | -------------------------------------------------------------------------------- /resources/psu/gerber/PCB_PSU_LIN_2023-01-12-F_Mask.gbr: -------------------------------------------------------------------------------- 1 | %TF.GenerationSoftware,KiCad,Pcbnew,(6.0.10)*% 2 | %TF.CreationDate,2023-01-15T11:08:54+01:00*% 3 | %TF.ProjectId,PCB_PSU_LIN_2023-01-12,5043425f-5053-4555-9f4c-494e5f323032,rev?*% 4 | %TF.SameCoordinates,Original*% 5 | %TF.FileFunction,Soldermask,Top*% 6 | %TF.FilePolarity,Negative*% 7 | %FSLAX46Y46*% 8 | G04 Gerber Fmt 4.6, Leading zero omitted, Abs format (unit mm)* 9 | G04 Created by KiCad (PCBNEW (6.0.10)) date 2023-01-15 11:08:54* 10 | %MOMM*% 11 | %LPD*% 12 | G01* 13 | G04 APERTURE LIST* 14 | %ADD10C,2.000000*% 15 | %ADD11C,1.600000*% 16 | %ADD12O,1.600000X1.600000*% 17 | %ADD13R,1.050000X1.500000*% 18 | %ADD14O,1.050000X1.500000*% 19 | %ADD15R,1.700000X1.700000*% 20 | %ADD16O,1.700000X1.700000*% 21 | %ADD17R,2.000000X1.905000*% 22 | %ADD18O,2.000000X1.905000*% 23 | %ADD19R,1.500000X1.050000*% 24 | %ADD20O,1.500000X1.050000*% 25 | %ADD21C,1.100000*% 26 | %ADD22R,2.200000X2.200000*% 27 | %ADD23C,2.200000*% 28 | %ADD24R,1.600000X2.400000*% 29 | %ADD25O,1.600000X2.400000*% 30 | %ADD26R,3.500000X1.800000*% 31 | G04 APERTURE END LIST* 32 | D10* 33 | %TO.C,REF\u002A\u002A*% 34 | X94995570Y-76212882D03* 35 | %TD*% 36 | D11* 37 | %TO.C,R4*% 38 | X75311107Y-72393220D03* 39 | D12* 40 | X65151107Y-72393220D03* 41 | %TD*% 42 | D13* 43 | %TO.C,Q4*% 44 | X65052674Y-87741994D03* 45 | D14* 46 | X66322674Y-87741994D03* 47 | X67592674Y-87741994D03* 48 | %TD*% 49 | D15* 50 | %TO.C,P4*% 51 | X87657000Y-99035000D03* 52 | D16* 53 | X90197000Y-99035000D03* 54 | X92737000Y-99035000D03* 55 | X95277000Y-99035000D03* 56 | %TD*% 57 | D17* 58 | %TO.C,Q1*% 59 | X95325935Y-64073305D03* 60 | D18* 61 | X95325935Y-66613305D03* 62 | X95325935Y-69153305D03* 63 | %TD*% 64 | D11* 65 | %TO.C,R1*% 66 | X92069811Y-63019768D03* 67 | D12* 68 | X92069811Y-73179768D03* 69 | %TD*% 70 | D11* 71 | %TO.C,R2*% 72 | X80601521Y-83820000D03* 73 | D12* 74 | X80601521Y-93980000D03* 75 | %TD*% 76 | D19* 77 | %TO.C,Q5*% 78 | X76317065Y-86595803D03* 79 | D20* 80 | X76317065Y-85325803D03* 81 | X76317065Y-84055803D03* 82 | %TD*% 83 | D11* 84 | %TO.C,R7*% 85 | X74755012Y-80595080D03* 86 | D12* 87 | X64595012Y-80595080D03* 88 | %TD*% 89 | D19* 90 | %TO.C,Q2*% 91 | X76336262Y-95490000D03* 92 | D20* 93 | X76336262Y-94220000D03* 94 | X76336262Y-92950000D03* 95 | %TD*% 96 | D11* 97 | %TO.C,R6*% 98 | X71392679Y-84704598D03* 99 | D12* 100 | X71392679Y-94864598D03* 101 | %TD*% 102 | D15* 103 | %TO.C,P3*% 104 | X81258320Y-70820081D03* 105 | D16* 106 | X81258320Y-73360081D03* 107 | %TD*% 108 | D11* 109 | %TO.C,R5*% 110 | X78132375Y-76114116D03* 111 | D12* 112 | X67972375Y-76114116D03* 113 | %TD*% 114 | D11* 115 | %TO.C,R3*% 116 | X78740000Y-63500000D03* 117 | D12* 118 | X88900000Y-63500000D03* 119 | %TD*% 120 | D21* 121 | %TO.C,P2*% 122 | X66040000Y-63500000D03* 123 | X68580000Y-63500000D03* 124 | D22* 125 | X68580000Y-66040000D03* 126 | D23* 127 | X66040000Y-66040000D03* 128 | %TD*% 129 | D10* 130 | %TO.C,REF\u002A\u002A*% 131 | X95059983Y-81794719D03* 132 | %TD*% 133 | D22* 134 | %TO.C,P1*% 135 | X86748032Y-82015000D03* 136 | D23* 137 | X86748032Y-79475000D03* 138 | X86748032Y-76935000D03* 139 | X86748032Y-74395000D03* 140 | %TD*% 141 | D24* 142 | %TO.C,IC1*% 143 | X87657000Y-94713000D03* 144 | D25* 145 | X90197000Y-94713000D03* 146 | X92737000Y-94713000D03* 147 | X95277000Y-94713000D03* 148 | X95277000Y-87093000D03* 149 | X92737000Y-87093000D03* 150 | X90197000Y-87093000D03* 151 | X87657000Y-87093000D03* 152 | %TD*% 153 | D13* 154 | %TO.C,Q3*% 155 | X64996066Y-95433173D03* 156 | D14* 157 | X66266066Y-95433173D03* 158 | X67536066Y-95433173D03* 159 | %TD*% 160 | D26* 161 | %TO.C,D1*% 162 | X78283390Y-67081020D03* 163 | X83283390Y-67081020D03* 164 | %TD*% 165 | M02* 166 | -------------------------------------------------------------------------------- /resources/psu/gerber/PCB_PSU_LIN_2023-01-12-F_Paste.gbr: -------------------------------------------------------------------------------- 1 | %TF.GenerationSoftware,KiCad,Pcbnew,(6.0.10)*% 2 | %TF.CreationDate,2023-01-15T11:08:54+01:00*% 3 | %TF.ProjectId,PCB_PSU_LIN_2023-01-12,5043425f-5053-4555-9f4c-494e5f323032,rev?*% 4 | %TF.SameCoordinates,Original*% 5 | %TF.FileFunction,Paste,Top*% 6 | %TF.FilePolarity,Positive*% 7 | %FSLAX46Y46*% 8 | G04 Gerber Fmt 4.6, Leading zero omitted, Abs format (unit mm)* 9 | G04 Created by KiCad (PCBNEW (6.0.10)) date 2023-01-15 11:08:54* 10 | %MOMM*% 11 | %LPD*% 12 | G01* 13 | G04 APERTURE LIST* 14 | %ADD10R,3.500000X1.800000*% 15 | G04 APERTURE END LIST* 16 | D10* 17 | %TO.C,D1*% 18 | X78283390Y-67081020D03* 19 | X83283390Y-67081020D03* 20 | %TD*% 21 | M02* 22 | -------------------------------------------------------------------------------- /resources/psu/gerber/PCB_PSU_LIN_2023-01-12-job.gbrjob: -------------------------------------------------------------------------------- 1 | { 2 | "Header": { 3 | "GenerationSoftware": { 4 | "Vendor": "KiCad", 5 | "Application": "Pcbnew", 6 | "Version": "(6.0.10)" 7 | }, 8 | "CreationDate": "2023-01-15T11:08:54+01:00" 9 | }, 10 | "GeneralSpecs": { 11 | "ProjectId": { 12 | "Name": "PCB_PSU_LIN_2023-01-12", 13 | "GUID": "5043425f-5053-4555-9f4c-494e5f323032", 14 | "Revision": "rev?" 15 | }, 16 | "Size": { 17 | "X": 36.985, 18 | "Y": 40.894 19 | }, 20 | "LayerNumber": 2, 21 | "BoardThickness": 1.6, 22 | "Finish": "None" 23 | }, 24 | "DesignRules": [ 25 | { 26 | "Layers": "Outer", 27 | "PadToPad": 0.0, 28 | "PadToTrack": 0.0, 29 | "TrackToTrack": 0.2, 30 | "MinLineWidth": 0.25 31 | } 32 | ], 33 | "FilesAttributes": [ 34 | { 35 | "Path": "PCB_PSU_LIN_2023-01-12-F_Cu.gbr", 36 | "FileFunction": "Copper,L1,Top", 37 | "FilePolarity": "Positive" 38 | }, 39 | { 40 | "Path": "PCB_PSU_LIN_2023-01-12-B_Cu.gbr", 41 | "FileFunction": "Copper,L2,Bot", 42 | "FilePolarity": "Positive" 43 | }, 44 | { 45 | "Path": "PCB_PSU_LIN_2023-01-12-F_Paste.gbr", 46 | "FileFunction": "SolderPaste,Top", 47 | "FilePolarity": "Positive" 48 | }, 49 | { 50 | "Path": "PCB_PSU_LIN_2023-01-12-B_Paste.gbr", 51 | "FileFunction": "SolderPaste,Bot", 52 | "FilePolarity": "Positive" 53 | }, 54 | { 55 | "Path": "PCB_PSU_LIN_2023-01-12-F_Silkscreen.gbr", 56 | "FileFunction": "Legend,Top", 57 | "FilePolarity": "Positive" 58 | }, 59 | { 60 | "Path": "PCB_PSU_LIN_2023-01-12-B_Silkscreen.gbr", 61 | "FileFunction": "Legend,Bot", 62 | "FilePolarity": "Positive" 63 | }, 64 | { 65 | "Path": "PCB_PSU_LIN_2023-01-12-F_Mask.gbr", 66 | "FileFunction": "SolderMask,Top", 67 | "FilePolarity": "Negative" 68 | }, 69 | { 70 | "Path": "PCB_PSU_LIN_2023-01-12-B_Mask.gbr", 71 | "FileFunction": "SolderMask,Bot", 72 | "FilePolarity": "Negative" 73 | }, 74 | { 75 | "Path": "PCB_PSU_LIN_2023-01-12-Edge_Cuts.gbr", 76 | "FileFunction": "Profile", 77 | "FilePolarity": "Positive" 78 | } 79 | ], 80 | "MaterialStackup": [ 81 | { 82 | "Type": "Legend", 83 | "Name": "Top Silk Screen" 84 | }, 85 | { 86 | "Type": "SolderPaste", 87 | "Name": "Top Solder Paste" 88 | }, 89 | { 90 | "Type": "SolderMask", 91 | "Name": "Top Solder Mask" 92 | }, 93 | { 94 | "Type": "Copper", 95 | "Name": "F.Cu" 96 | }, 97 | { 98 | "Type": "Dielectric", 99 | "Material": "FR4", 100 | "Name": "F.Cu/B.Cu", 101 | "Notes": "Type: dielectric layer 1 (from F.Cu to B.Cu)" 102 | }, 103 | { 104 | "Type": "Copper", 105 | "Name": "B.Cu" 106 | }, 107 | { 108 | "Type": "SolderMask", 109 | "Name": "Bottom Solder Mask" 110 | }, 111 | { 112 | "Type": "SolderPaste", 113 | "Name": "Bottom Solder Paste" 114 | }, 115 | { 116 | "Type": "Legend", 117 | "Name": "Bottom Silk Screen" 118 | } 119 | ] 120 | } 121 | -------------------------------------------------------------------------------- /resources/psu/gerber/PCB_PSU_LIN_2023-01-12.drl: -------------------------------------------------------------------------------- 1 | M48 2 | ; DRILL file {KiCad (6.0.10)} date Sun Jan 15 11:08:47 2023 3 | ; FORMAT={-:-/ absolute / inch / decimal} 4 | ; #@! TF.CreationDate,2023-01-15T11:08:47+01:00 5 | ; #@! TF.GenerationSoftware,Kicad,Pcbnew,(6.0.10) 6 | ; #@! TF.FileFunction,MixedPlating,1,2 7 | FMAT,2 8 | INCH 9 | ; #@! TA.AperFunction,Plated,PTH,ComponentDrill 10 | T1C0.0295 11 | ; #@! TA.AperFunction,Plated,PTH,ComponentDrill 12 | T2C0.0315 13 | ; #@! TA.AperFunction,Plated,PTH,ComponentDrill 14 | T3C0.0394 15 | ; #@! TA.AperFunction,Plated,PTH,ComponentDrill 16 | T4C0.0433 17 | ; #@! TA.AperFunction,NonPlated,NPTH,ComponentDrill 18 | T5C0.0433 19 | ; #@! TA.AperFunction,NonPlated,NPTH,ComponentDrill 20 | T6C0.0787 21 | % 22 | G90 23 | G05 24 | T1 25 | X2.5589Y-3.7572 26 | X2.5611Y-3.4544 27 | X2.6089Y-3.7572 28 | X2.6111Y-3.4544 29 | X2.6589Y-3.7572 30 | X2.6611Y-3.4544 31 | X3.0046Y-3.3093 32 | X3.0046Y-3.3593 33 | X3.0046Y-3.4093 34 | X3.0054Y-3.6594 35 | X3.0054Y-3.7094 36 | X3.0054Y-3.7594 37 | T2 38 | X2.5431Y-3.173 39 | X2.565Y-2.8501 40 | X2.6761Y-2.9966 41 | X2.8107Y-3.3348 42 | X2.8107Y-3.7348 43 | X2.9431Y-3.173 44 | X2.965Y-2.8501 45 | X3.0761Y-2.9966 46 | X3.1Y-2.5 47 | X3.1733Y-3.3 48 | X3.1733Y-3.7 49 | X3.4511Y-3.4289 50 | X3.4511Y-3.7289 51 | X3.5Y-2.5 52 | X3.5511Y-3.4289 53 | X3.5511Y-3.7289 54 | X3.6248Y-2.4811 55 | X3.6248Y-2.8811 56 | X3.6511Y-3.4289 57 | X3.6511Y-3.7289 58 | X3.7511Y-3.4289 59 | X3.7511Y-3.7289 60 | T3 61 | X3.1991Y-2.7882 62 | X3.1991Y-2.8882 63 | X3.4511Y-3.899 64 | X3.5511Y-3.899 65 | X3.6511Y-3.899 66 | X3.7511Y-3.899 67 | T4 68 | X2.6Y-2.6 69 | X2.7Y-2.6 70 | X3.4153Y-2.9289 71 | X3.4153Y-3.0289 72 | X3.4153Y-3.1289 73 | X3.4153Y-3.2289 74 | X3.753Y-2.5226 75 | X3.753Y-2.6226 76 | X3.753Y-2.7226 77 | T5 78 | X2.6Y-2.5 79 | X2.7Y-2.5 80 | T6 81 | X3.74Y-3.0005 82 | X3.7425Y-3.2203 83 | T0 84 | M30 85 | -------------------------------------------------------------------------------- /resources/psu/psu_schematic.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/psu/psu_schematic.JPG -------------------------------------------------------------------------------- /resources/schematics/cem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/schematics/cem.png -------------------------------------------------------------------------------- /resources/schematics/components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/schematics/components.png -------------------------------------------------------------------------------- /resources/schematics/icm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/schematics/icm.png -------------------------------------------------------------------------------- /resources/schematics/installation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/schematics/installation.png -------------------------------------------------------------------------------- /resources/schematics/layout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/schematics/layout.png -------------------------------------------------------------------------------- /resources/schematics/rti.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BoostedMoose/v-link/0511318c3b95825d16946a83ac0f51897049d98e/resources/schematics/rti.png -------------------------------------------------------------------------------- /resources/tools/plotter.py: -------------------------------------------------------------------------------- 1 | import json 2 | import argparse 3 | import matplotlib.pyplot as plt 4 | import pandas as pd 5 | 6 | # Function to load data from a JSON file 7 | def load_json_data(file_path): 8 | with open(file_path, 'r') as f: 9 | return json.load(f) 10 | 11 | # Main function to plot the data 12 | def plot_data(file_path): 13 | # Load the JSON data from the provided file path 14 | data = load_json_data(file_path) 15 | 16 | # Initialize the plot 17 | plt.figure(figsize=(10, 6)) 18 | 19 | # Iterate through all the datasets (each with a 'label' and 'data') 20 | for entry in data: 21 | if 'data' in entry: # Check if the 'data' key exists 22 | data_points = entry['data'] # Get the data points (list of dictionaries) 23 | 24 | # Extract timestamps and values from the data 25 | timestamps = [point['timestamp'] for point in data_points] 26 | values = [point['value'] for point in data_points] 27 | 28 | # Convert timestamps to datetime objects 29 | timestamps = pd.to_datetime(timestamps) 30 | 31 | # Plot the data on the same chart 32 | plt.plot(timestamps, values, label=entry.get('label', 'Unknown Label')) 33 | 34 | # Customize the chart 35 | plt.xlabel('Timestamp') 36 | plt.ylabel('Value') 37 | plt.title('Sensor Data - Line Chart') 38 | plt.xticks(rotation=45) # Rotate timestamps for better readability 39 | plt.tight_layout() # Adjust the plot to fit everything 40 | plt.grid(True) 41 | plt.legend() # Show the legend for all datasets 42 | 43 | # Show the plot 44 | plt.show() 45 | 46 | # Set up argument parsing 47 | if __name__ == "__main__": 48 | parser = argparse.ArgumentParser(description="Plot data from a JSON file") 49 | parser.add_argument("file_path", help="Path to the JSON file") 50 | args = parser.parse_args() 51 | 52 | # Plot the data 53 | plot_data(args.file_path) 54 | --------------------------------------------------------------------------------