├── .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 | 
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 | | [](https://www.buymeacoffee.com/lrymnd) | [](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 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/background/horizon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/background/road.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/banner.svg:
--------------------------------------------------------------------------------
1 |
2 |
29 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/buttons/carplay.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/buttons/dashboard.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/buttons/general.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/buttons/interface.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/buttons/keymap.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/buttons/link.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/buttons/settings.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/buttons/system.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/gauges/race.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/icons/data/col.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/icons/data/err.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/icons/data/iat.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/icons/data/ld1.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/icons/data/ld2.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/icons/data/map.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/icons/data/oilp.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/icons/data/oilt.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/icons/data/spd.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/icons/interface/phone.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/icons/interface/wifi.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/public/assets/svg/logos/typo.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
111 |
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 |
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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------