UniDeck Embedded Control Deck With Unihiker

by AahanSharma in Circuits > Linux

105 Views, 1 Favorites, 0 Comments

UniDeck Embedded Control Deck With Unihiker

DIY UniDeck
IMG_E4045.JPG
FS6SG5YML1YU8FG.gif
05.gif

Hey everyone,

Unideck is a small embedded control deck device designed to serve as a remote dashboard for macOS. Since Unihiker is primarily used in IoT sensors, I was thinking of making something related to it. However, since I code at my desk most of the time, I thought it would be useful to use Unihiker as a stream deck, Unideck that opens the VScode terminal or Autodesk Fusion. I chose to try it after learning that no one had ever done anything like this.

It took me a while to understand it. I then found a library, tested each one separately, and used PIL Customtkinker to make it work. Despite the initial errors, it eventually made progress even though it had to create a file in Mac in order to connect via wifi. Like a bridge that initially tested a lot of features. After that, I reasoned that since it only supports and won't carry out such tasks, it would be preferable to use a single Python program rather than several.

These constraints were seen as a design opportunity to transform this project into an embedded user interface exercise rather than as barriers. They can use simple commands and state updates to communicate over wifi by building a stable system that grows without failing. Because Unihiker only represents the state and never makes decisions, it is fast and predictable.

Supplies

These are the components used in projects:

  1. Unihiker M10
  2. Mac
  3. 3D Printed Parts

3D Model

WhatsApp Image 2026-01-30 at 16.39.13.jpeg
WhatsApp Image 2026-01-30 at 16.39.13 (2).jpeg

our goal for this project was to build a simple design that will get hold of the unihiker to easy to use on the desk and a bit rotated from the desk. so it may get used for accessing with ease. made a structure like steam deck looking not to hard just to use it in a better way to perform decent looks also and it may suit it too

Downloads

3D Parts

01.gif
WhatsApp Image 2026-01-30 at 16.39.14 (3).jpeg
WhatsApp Image 2026-01-30 at 16.39.13 (4).jpeg
WhatsApp Image 2026-01-30 at 16.39.14 (1).jpeg
WhatsApp Image 2026-01-30 at 16.39.13 (5).jpeg

We used the Hyper PLA filament we had on hand to print the Base of the UniDeck using our Anycubic Kobra S1 3D printer.

Assembly Process

02.gif
03.gif
WhatsApp Image 2026-01-30 at 16.39.13 (3).jpeg
WhatsApp Image 2026-01-30 at 16.39.13 (1).jpeg

We start the assembly process by positioning two partitions together. Using the mounting holes for proper alignment

Code

1.png
2.png
3.png
4.png
5.png
7.png
6.png
import customtkinter as ctk

class Config:
"""Application configuration constants."""

# Screen dimensions (Unihiker M10)
SCREEN_WIDTH = 240
SCREEN_HEIGHT = 320

# Network settings
HOST_IP = "192.168.xx.xxx"
HOST_PORT = 9999

# Colors (Dark Modern Palette)
BG_PRIMARY = "#1a1a1a"
BG_SECONDARY = "#2b2b2b"
TEXT_PRIMARY = "#ffffff"
TEXT_SECONDARY = "#a0a0a0"

# Accent colors for app buttons
ACCENT_SPOTIFY = "#1DB954"
ACCENT_VSCODE = "#007ACC"

# Animation Settings
TARGET_FPS = 30
FRAME_TIME = 1000 // TARGET_FPS # 33ms for 30 FPS

# Basic App Structure
class RemoteDeckApp(ctk.CTk):
def __init__(self):
super().__init__()
self.title("Unihiker Remote Deck")
self.geometry(f"{Config.SCREEN_WIDTH}x{Config.SCREEN_HEIGHT}")
self.configure(fg_color=Config.BG_PRIMARY)

if __name__ == "__main__":
ctk.set_appearance_mode("dark")
app = RemoteDeckApp()
app.mainloop()

Because the code is written entirely in Python, it was a little complicated. For the foundation, we use customtkinker to configure the application's basic structure and make it executable. It became complicated to create because it only supports its libraries, but this is just the foundation—the top layer.


import customtkinter as ctk

class SplashScreen(ctk.CTkFrame):
def __init__(self, master):
super().__init__(master, fg_color="#1a1a1a")
self.setup_ui()
self.progress_value = 0.0
self.animation_running = False

def setup_ui(self):
# Center container
container = ctk.CTkFrame(self, fg_color="transparent")
container.place(relx=0.5, rely=0.5, anchor="center")

# Logo/Title
title_label = ctk.CTkLabel(
container,
text="UNIHIKER",
font=ctk.CTkFont(family="Arial", size=24, weight="bold"),
text_color="#ffffff"
)
title_label.pack(pady=(0, 30))

# Progress bar
self.progress_bar = ctk.CTkProgressBar(
container,
width=180,
height=6,
corner_radius=3,
fg_color="#2b2b2b",
progress_color="#1DB954"
)
self.progress_bar.pack(pady=(0, 10))
self.progress_bar.set(0)

# Status text
self.status_label = ctk.CTkLabel(
container,
text="System Initializing...",
font=ctk.CTkFont(family="Arial", size=10),
text_color="#666666"
)
self.status_label.pack()

def start_animation(self):
"""Start the boot animation."""
self.progress_value = 0.0
self.animation_running = True
self._animate_progress()

def _animate_progress(self):
"""Animate the progress bar."""
if not self.animation_running:
return

# Calculate step for smooth animation
step = 1.0 / (2.0 * 30) # 2 seconds at 30 FPS
self.progress_value += step

if self.progress_value >= 1.0:
self.progress_bar.set(1.0)
self.status_label.configure(text="Ready!")
else:
self.progress_bar.set(self.progress_value)
if self.progress_value > 0.7:
self.status_label.configure(text="Preparing interface...")
elif self.progress_value > 0.4:
self.status_label.configure(text="Loading components...")

self.after(33, self._animate_progress) # 30 FPS

# Demo usage
if __name__ == "__main__":
ctk.set_appearance_mode("dark")
app = ctk.CTk()
app.geometry("240x320")

splash = SplashScreen(app)
splash.pack(fill="both", expand=True)
splash.start_animation()

app.mainloop()

We attempted to include an idle animation to save time when it isn't in use. It can be altered, like a clock or something, but I wanted to include a gif, but we are unable to do so with Python. GIFs had to be converted into frames using PIL in order for it to function properly. However, it must be stable for 30 to 60 frames. Additionally, we used a loading bar and some simple splash animations for the initial UI animation. It wasn't necessary, but without it, the user interface wouldn't load smoothly, which could aid in loading the entire script. It may take some time to load.


import customtkinter as ctk
from PIL import Image

class AppLauncher(ctk.CTkFrame):
def __init__(self, master):
super().__init__(master, fg_color="#1a1a1a")
self.setup_ui()

def setup_ui(self):
# Header
header = ctk.CTkFrame(self, fg_color="transparent", height=40)
header.pack(fill="x", padx=10, pady=(10, 5))

header_label = ctk.CTkLabel(
header,
text="Remote Deck",
font=ctk.CTkFont(family="Arial", size=14, weight="bold"),
text_color="#ffffff"
)
header_label.pack(side="left", pady=5)

# Button grid container
grid_container = ctk.CTkFrame(self, fg_color="transparent")
grid_container.pack(fill="both", expand=True, padx=10, pady=5)

# Configure grid
grid_container.grid_columnconfigure(0, weight=1)
grid_container.grid_columnconfigure(1, weight=1)

# App buttons data
apps = [
{"name": "Spotify", "color": "#1DB954", "command": "OPEN_SPOTIFY"},
{"name": "VS Code", "color": "#007ACC", "command": "OPEN_VSCODE"},
{"name": "Safari", "color": "#0A84FF", "command": "OPEN_SAFARI"},
{"name": "Terminal", "color": "#6B7280", "command": "OPEN_TERMINAL"},
]

# Create buttons
for idx, app in enumerate(apps):
row = idx // 2
col = idx % 2

# Button frame
btn_frame = ctk.CTkFrame(
grid_container,
fg_color="#2b2b2b",
corner_radius=15
)
btn_frame.grid(row=row, column=col, padx=5, pady=5, sticky="nsew")
grid_container.grid_rowconfigure(row, weight=1)

# Accent color bar
accent_bar = ctk.CTkFrame(
btn_frame,
width=4,
height=30,
corner_radius=2,
fg_color=app["color"]
)
accent_bar.place(x=8, rely=0.5, anchor="w")

# App name
name_label = ctk.CTkLabel(
btn_frame,
text=app["name"],
font=ctk.CTkFont(family="Arial", size=11, weight="bold"),
text_color="#ffffff"
)
name_label.place(relx=0.5, rely=0.5, anchor="center")

# Make clickable
self._make_clickable(btn_frame, app["command"])
self._make_clickable(name_label, app["command"])

def _make_clickable(self, widget, command):
widget.bind("<Button-1>", lambda e: self._on_app_click(command))

def _on_app_click(self, command):
print(f"Launching: {command}")
# Here you would send the command to your Mac server

# Demo usage
if __name__ == "__main__":
ctk.set_appearance_mode("dark")
app = ctk.CTk()
app.geometry("240x320")

launcher = AppLauncher(app)
launcher.pack(fill="both", expand=True)

app.mainloop()

Although creating a homepage user interface can be challenging, some libraries, like PIL, are used to import images, such as the logos of apps like Safari, Spotify, and VScode. to give it a nice appearance


import socket
import threading

class NetworkManager:
def __init__(self, host_ip="192.168.xx.xxx", host_port=9999):
self.host_ip = host_ip
self.host_port = host_port

def send_command(self, command):

def send_in_thread():
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(3)
sock.connect((self.host_ip, self.host_port))
sock.send(command.encode("utf-8"))
response = sock.recv(1024).decode("utf-8")
print(f"Response: {response}")
sock.close()
except Exception as e:
print(f"Network error: {e}")

threading.Thread(target=send_in_thread, daemon=True).start()

def check_connection(self):

try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2)
result = sock.connect_ex((self.host_ip, self.host_port))
sock.close()
return result == 0
except:
return False

# Usage example
if __name__ == "__main__":
network = NetworkManager()

# Test connection
if network.check_connection():
print("Connected to Mac!")
network.send_command("OPEN_SPOTIFY")
else:
print("Cannot connect to Mac server")

The most crucial aspect of Unideck is networking. We wrote a script to connect Mac to Unihiker via a local network because it would be useless without a bridge between them. and function effectively, obtaining input and output with ease. each time your Mac boots up. In order to connect to the Unihiker, it must be running in the terminal; otherwise, it will not be connected. Therefore, a bridge is required to access it. and send or receive signals.


import customtkinter as ctk

class SpotifyPlayer(ctk.CTkFrame):
def __init__(self, master):
super().__init__(master, fg_color="#1a1a1a")
self.is_playing = False
self.setup_ui()

def setup_ui(self):
# Header with back button
header = ctk.CTkFrame(self, fg_color="transparent", height=35)
header.pack(fill="x", padx=8, pady=(5, 0))

back_btn = ctk.CTkButton(
header,
text="<",
width=35,
height=28,
corner_radius=14,
fg_color="#3d3d3d",
text_color="#ffffff",
command=self._go_back
)
back_btn.pack(side="left")

header_label = ctk.CTkLabel(
header,
text="SPOTIFY",
font=ctk.CTkFont(family="Arial", size=11, weight="bold"),
text_color="#1DB954"
)
header_label.pack(side="right", padx=8)

# Album art placeholder
art_frame = ctk.CTkFrame(
self,
width=105,
height=105,
corner_radius=12,
fg_color="#1a1a2e"
)
art_frame.pack(pady=(8, 0))

art_icon = ctk.CTkLabel(
art_frame,
text="♫",
font=ctk.CTkFont(size=42, weight="bold"),
text_color="#1DB954"
)
art_icon.place(relx=0.5, rely=0.5, anchor="center")

# Track info
self.track_label = ctk.CTkLabel(
self,
text="Not Playing",
font=ctk.CTkFont(family="Arial", size=13, weight="bold"),
text_color="#ffffff"
)
self.track_label.pack(pady=(8, 0))

self.artist_label = ctk.CTkLabel(
self,
text="Open Spotify on Mac",
font=ctk.CTkFont(family="Arial", size=10),
text_color="#a0a0a0"
)
self.artist_label.pack(pady=(0, 6))

# Progress bar
self.progress_bar = ctk.CTkProgressBar(
self,
width=140,
height=6,
corner_radius=3,
fg_color="#3d3d3d",
progress_color="#1DB954"
)
self.progress_bar.pack(pady=(0, 5))
self.progress_bar.set(0)

# Main controls
controls_frame = ctk.CTkFrame(self, fg_color="transparent")
controls_frame.pack(pady=3)

# Previous button
prev_btn = ctk.CTkButton(
controls_frame,
text="<<",
width=48,
height=48,
corner_radius=24,
fg_color="#333333",
text_color="#ffffff",
command=self._prev_track
)
prev_btn.pack(side="left", padx=6)

# Play/Pause button
self.play_btn = ctk.CTkButton(
controls_frame,
text="▶",
width=60,
height=60,
corner_radius=30,
fg_color="#1DB954",
text_color="#000000",
font=ctk.CTkFont(size=22, weight="bold"),
command=self._play_pause
)
self.play_btn.pack(side="left", padx=8)

# Next button
next_btn = ctk.CTkButton(
controls_frame,
text=">>",
width=48,
height=48,
corner_radius=24,
fg_color="#333333",
text_color="#ffffff",
command=self._next_track
)
next_btn.pack(side="left", padx=6)

# Volume controls
volume_frame = ctk.CTkFrame(self, fg_color="transparent")
volume_frame.pack(pady=(8, 3))

vol_down_btn = ctk.CTkButton(
volume_frame,
text="-",
width=50,
height=32,
fg_color="#2a2a2a",
command=self._volume_down
)
vol_down_btn.pack(side="left", padx=3)

vol_up_btn = ctk.CTkButton(
volume_frame,
text="+",
width=50,
height=32,
fg_color="#2a2a2a",
command=self._volume_up
)
vol_up_btn.pack(side="left", padx=3)

def _play_pause(self):

self.is_playing = not self.is_playing
self.play_btn.configure(text="||" if self.is_playing else "▶")
print("Media: Play/Pause")

def _next_track(self):
print("Media: Next Track")

def _prev_track(self):
print("Media: Previous Track")

def _volume_up(self):
print("Media: Volume Up")

def _volume_down(self):
print("Media: Volume Down")

def _go_back(self):
print("Going back to main deck")

# Demo usage
if __name__ == "__main__":
ctk.set_appearance_mode("dark")
app = ctk.CTk()
app.geometry("240x320")

player = SpotifyPlayer(app)
player.pack(fill="both", expand=True)

app.mainloop()

This is not required. This facilitates the use of Spotify on the screen. additional features like the ability to switch between songs. Replace them or post them repeatedly. It just makes using it easier. to take control of it without using a mouse in order to be interactive because of the intense writing codes to switch apps or simply open git through the terminal with a single click. If I want to change it to code peacefully, I simply switch the playlist. With the ability to quickly switch up a somewhat useful device, it truly helps me during hours of saving.


import customtkinter as ctk

class BaseState(ctk.CTkFrame):


def __init__(self, master, app_controller):
super().__init__(master, fg_color="#1a1a1a")
self.app = app_controller
self.setup_ui()

def setup_ui(self):

raise NotImplementedError("Subclasses must implement setup_ui()")

def on_enter(self):

pass

def on_exit(self):

pass

class SplashState(BaseState):


def setup_ui(self):
label = ctk.CTkLabel(
self,
text="UNIHIKER\nRemote Deck",
font=ctk.CTkFont(size=20, weight="bold"),
text_color="#ffffff"
)
label.place(relx=0.5, rely=0.5, anchor="center")

def on_enter(self):
# Auto-transition after 2 seconds
self.after(2000, lambda: self.app.change_state("deck"))

class DeckState(BaseState):


def setup_ui(self):
# Header
header_label = ctk.CTkLabel(
self,
text="Remote Deck",
font=ctk.CTkFont(size=14, weight="bold"),
text_color="#ffffff"
)
header_label.pack(pady=20)

# Spotify button
spotify_btn = ctk.CTkButton(
self,
text="Open Spotify Player",
width=200,
height=50,
fg_color="#1DB954",
command=lambda: self.app.change_state("spotify")
)
spotify_btn.pack(pady=10)

class SpotifyState(BaseState):


def setup_ui(self):
# Back button
back_btn = ctk.CTkButton(
self,
text="← Back",
width=100,
height=30,
command=lambda: self.app.change_state("deck")
)
back_btn.pack(pady=10, anchor="w", padx=10)

# Player UI
player_label = ctk.CTkLabel(
self,
text="♫\nSpotify Player\nControls Here",
font=ctk.CTkFont(size=16),
text_color="#1DB954"
)
player_label.place(relx=0.5, rely=0.5, anchor="center")

class RemoteDeckApp(ctk.CTk):


def __init__(self):
super().__init__()

# Window setup
self.title("Unihiker Remote Deck")
self.geometry("240x320")
self.configure(fg_color="#1a1a1a")

# Initialize states
self.states = {}
self.current_state = None
self._init_states()

# Start with splash screen
self.change_state("splash")

def _init_states(self):

self.states = {
"splash": SplashState(self, self),
"deck": DeckState(self, self),
"spotify": SpotifyState(self, self),
}

def change_state(self, state_name):

if state_name not in self.states:
print(f"Error: Unknown state '{state_name}'")
return

# Exit current state
if self.current_state:
self.current_state.on_exit()
self.current_state.place_forget()

# Enter new state
self.current_state = self.states[state_name]
self.current_state.place(x=0, y=0, relwidth=1, relheight=1)
self.current_state.on_enter()

print(f"State changed to: {state_name}")

# Run the complete app
if __name__ == "__main__":
ctk.set_appearance_mode("dark")
app = RemoteDeckApp()
app.mainloop()

The system is constructed as a thin embedded user interface that is parked with a desktop logic agent. It connects via a minimal commands and state protocol with animation input handling and a coordinate system for stability.


import customtkinter as ctk

class BaseState(ctk.CTkFrame):


def __init__(self, master, app_controller):
super().__init__(master, fg_color="#1a1a1a")
self.app = app_controller
self.setup_ui()

def setup_ui(self):

raise NotImplementedError("Subclasses must implement setup_ui()")

def on_enter(self):

pass

def on_exit(self):

pass

class SplashState(BaseState):


def setup_ui(self):
label = ctk.CTkLabel(
self,
text="UNIHIKER\nRemote Deck",
font=ctk.CTkFont(size=20, weight="bold"),
text_color="#ffffff"
)
label.place(relx=0.5, rely=0.5, anchor="center")

def on_enter(self):
# Auto-transition after 2 seconds
self.after(2000, lambda: self.app.change_state("deck"))

class DeckState(BaseState):


def setup_ui(self):
# Header
header_label = ctk.CTkLabel(
self,
text="Remote Deck",
font=ctk.CTkFont(size=14, weight="bold"),
text_color="#ffffff"
)
header_label.pack(pady=20)

# Spotify button
spotify_btn = ctk.CTkButton(
self,
text="Open Spotify Player",
width=200,
height=50,
fg_color="#1DB954",
command=lambda: self.app.change_state("spotify")
)
spotify_btn.pack(pady=10)

class SpotifyState(BaseState):


def setup_ui(self):
# Back button
back_btn = ctk.CTkButton(
self,
text="← Back",
width=100,
height=30,
command=lambda: self.app.change_state("deck")
)
back_btn.pack(pady=10, anchor="w", padx=10)

# Player UI
player_label = ctk.CTkLabel(
self,
text="♫\nSpotify Player\nControls Here",
font=ctk.CTkFont(size=16),
text_color="#1DB954"
)
player_label.place(relx=0.5, rely=0.5, anchor="center")

class RemoteDeckApp(ctk.CTk):


def __init__(self):
super().__init__()

# Window setup
self.title("Unihiker Remote Deck")
self.geometry("240x320")
self.configure(fg_color="#1a1a1a")

# Initialize states
self.states = {}
self.current_state = None
self._init_states()

# Start with splash screen
self.change_state("splash")

def _init_states(self):

self.states = {
"splash": SplashState(self, self),
"deck": DeckState(self, self),
"spotify": SpotifyState(self, self),
}

def change_state(self, state_name):

if state_name not in self.states:
print(f"Error: Unknown state '{state_name}'")
return

# Exit current state
if self.current_state:
self.current_state.on_exit()
self.current_state.place_forget()

# Enter new state
self.current_state = self.states[state_name]
self.current_state.place(x=0, y=0, relwidth=1, relheight=1)
self.current_state.on_enter()

print(f"State changed to: {state_name}")

# Run the complete app
if __name__ == "__main__":
ctk.set_appearance_mode("dark")
app = RemoteDeckApp()
app.mainloop()

The system is constructed as a thin embedded user interface that is parked with a desktop logic agent. It connects via a minimal commands and state protocol with animation input handling and a coordinate system for stability.


from pinpong.board import Board
from pinpong.extension.unihiker import *
import threading

class BuzzerFeedback:
def __init__(self):
try:
Board().begin()
self.enabled = True
print("Buzzer initialized")
except Exception as e:
print(f"Buzzer init failed: {e}")
self.enabled = False

def click(self):
if self.enabled:
def beep():
try:
buzzer.pitch(800, 1)
except Exception as e:
print(f"Buzzer failed: {e}")
self.enabled = False
threading.Thread(target=beep, daemon=True).start()

buzzer_feedback = BuzzerFeedback()

# Add to button clicks:
def on_button_click():
buzzer_feedback.click()
print("Button clicked!")

# Usage in your app:
# In DeckState._send_command():
# buzzer_feedback.click()
#
# In SpotifyState._play_pause():
# buzzer_feedback.click()
#
# In SpotifyState._next_track():
# buzzer_feedback.click()

We can use its buzzer to make it more engaging and interactive. For example, when we open Terminal or VS Code, the buzzer will beep to indicate that we have switched apps or simply changed the song. Perhaps it can be used for something better. Although it is primarily utilized in IoT sensors, it can also be used for everyday purposes. but I didn't do it too crazy; it was only a quick thing to create from scratch, so it's nice to engage with

Result

04.gif
06.gif
IMG_4043.JPG
IMG_E4048.JPG
IMG_E4049.JPG

As it turned out, a fully functional embedded control deck that functions more like a dedicated product than a prototype unihiker boots straight into a minimal device, connects to a Mac via wifi, and offers a quick, touch-friendly interface for opening apps and viewing real-time status

The system is responsive even on a small screen, and limited hardware only causes unihiker responses for rendering and input. However, decision-making and complex logic are live on Mac separation, which allowed for the addition of features like idle animation and full UI rotation without altering the current UI logic.

It handled idle behavior neatly with a lightweight frame-based animation that only appears when the device is inactive and exits instantly upon touch to use it again. Most importantly, it offered a flexible user interface (UI) and layout adjustment, allowing visual improvements to be made without any problems and backend features to evolve without requiring UI. This project made it easy to maintain significantly reduced rig of regression.

began as a straightforward shortcut board concept and evolved into a useful lesson that included UI design and building a dependable system.

Visit my GitHub page to see the completed project.