OK after a lot of learning and frustrations due to various limitations of HAOS containers, alpine distro and supported browsers, an X’s insistance on having access to /dev/tty0, I finally got it to work and have the following add-on:
Dockerfile
ARG BUILD_FROM
FROM $BUILD_FROM
# Install Luakit and necessary dependencies
RUN apk update && apk add --no-cache \
luakit \
xorg-server \
xf86-video-fbdev \
xf86-input-evdev \
openbox \
ttf-dejavu \
util-linux \
xset \
bash \
&& rm -rf /var/cache/apk/*
# Set the display environment variable
ENV DISPLAY=:0
ENV DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/dbus-session
# Copy over 'xorg.conf' and lua 'userconf.lua' file
COPY xorg.conf /etc/X11/xorg.conf
COPY userconf.lua /root/.config/luakit/userconf.lua
COPY run.sh /
RUN chmod a+x /run.sh
CMD ["/run.sh"]
run.sh
#!/usr/bin/with-contenv bashio
################################################################################
#Get config variables
START_URL=$(bashio::config 'start_url' || echo "http://localhost:8123")
LOGIN_DELAY=$(bashio::config 'login_delay' || echo "2")
HA_USERNAME=$(bashio::config 'ha_username' || echo "")
HA_PASSWORD=$(bashio::config 'ha_password' || echo "")
BROWSER_REFRESH=$(bashio::config 'browser_refresh' || echo "30") #Default to 30 seconds
export START_URL LOGIN_DELAY HA_USERNAME HA_PASSWORD BROWSER_REFRESH #Referenced in 'userconfig.lua'
HDMI_PORT=$(bashio::config 'hdmi_port' || echo "0")
#NOTE: For now, both HDMI ports are mirrored and there is only /dev/fb0
# Not sure how to get them unmirrored so that console can be on /dev/fb0 and X on /dev/fb1
# As a result, setting HDMI=0 vs. 1 has no effect
SCREEN_TIMEOUT=$(bashio::config 'screen_timeout' || echo "600") #Default to 600 seconds
#Validate environment variables set by config.yaml
if [ -z "$HA_USERNAME" ] || [ -z "$HA_PASSWORD" ]; then
echo "Error: HA_USERNAME and HA_PASSWORD must be set" >&2
exit 1
fi
################################################################################
#Note need to delete /dev/tty0 since X won't start if it is there
#because X doesn't have permissions to access it in the container
#First, remount /dev as read-write since X absolutely, must have /dev/tty access
#Note: need to use the version in util-linux, not busybox
if [ -e "/dev/tty0" ]; then
echo "Attempting to (temporarily) delete /dev/tty0..." >&2
mount -o remount,rw /dev
if [ $? -ne 0 ]; then
echo "Failed to remount /dev as read-write..." >&2
exit 1
fi
rm /dev/tty0
if [ $? -ne 0 ]; then
mount -o remount,ro /dev
echo "Failed to delete /dev/tty0..." >&2
exit 1
fi
TTY0_DELETED=1
fi
#Start Xorg in the background
Xorg $DISPLAY -layout Layout${HDMI_PORT} & < /dev/null
XSTARTUP=30
for ((i=0; i<=$XSTARTUP; i++)); do
if xset q >/dev/null 2>&1; then
break
fi
sleep 1
done
#Restore /dev/tty0 and 'ro' mode for /dev if deleted
if [ -n "TTY0_DELETED" ]; then
if ! ( mknod -m 620 /dev/tty0 c 4 0 && mount -o remount,ro /dev ); then
echo "Failed to restore /dev/tty0 and remount /dev/ read only..." >&2
fi
fi
if ! xset q >/dev/null 2>&1; then
echo "Error: X server failed to start within $XSTARTUP seconds." >&2
exit 1
fi
echo "X started successfully..." >&2
#Stop console blinking cursor (this projects through the X-screen)
echo -e "\033[?25l" > /dev/console
#Start Openbox in the background
openbox &
O_PID=$!
sleep 0.5 #Ensure Openbox starts
if ! kill -0 "$O_PID" 2>/dev/null; then #Checks if process alive
echo "Failed to start Openbox window manager" >&2
exit 1
fi
echo "Openbox started successfully..." >&2
#Start D-Bus session (otherwise luakit hangs for 5 minutes befor starting)
dbus-daemon --session --address=unix:path=/tmp/dbus-session &
sleep 0.5 #Allow DBUS to initialize
export DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/dbus-session
echo "DBUS started..." >&2
#Configure screen timeout
if [ "$SCREEN_TIMEOUT" -eq 0 ]; then #Disable screen saver and DPMS for no timeout
xset s 0
xset dpms 0 0 0
xset -dpms
echo "Screen timeout disabled..." >&2
else
xset s "$SCREEN_TIMEOUT"
xset dpms "$SCREEN_TIMEOUT" "$SCREEN_TIMEOUT" "$SCREEN_TIMEOUT" #DPMS standby, suspend, off
xset +dpms
echo "Screen timeout after $SCREEN_TIMEOUT seconds..." >&2
fi
#Run Luakit in the foreground
echo "Launching Luakit browser..." >&2
exec luakit "$START_URL"
config.yaml
name: "LocalDashboard"
description: >
Start X server and launch dashboard from browser on local HAOS server (Jeff Kosowsky)
version: "0.9.0"
slug: "localdashboard"
arch:
- aarch64
- amd64
- armhf
- armv7
- i386
startup: application
host_network: true
host_dbus: true
init: false
devices:
- /dev/dri
- /dev/fb0
- /dev/fb1
- /dev/input/event0
- /dev/input/event1
privileged:
- SYS_ADMIN
security_opt:
- apparmor:unconfined
options:
start_url: "http://localhost:8123"
login_delay: 2
hdmi_port: 0
screen_timeout: 0
browser_refresh: 600
schema:
ha_username: str
ha_password: password
start_url: str
login_delay: int(0,)
hdmi_port: int(0,1)
screen_timeout: int(0,)
browser_refresh: int(0,)
environment:
DISPLAY: ":0"
xorg.conf
Section "ServerLayout"
Identifier "Layout0"
Screen 0 "Screen0" 0 0
InputDevice "Keyboard0" "CoreKeyboard"
InputDevice "Mouse0" "CorePointer"
EndSection
Section "ServerLayout"
Identifier "Layout1"
Screen 0 "Screen1" 0 0
InputDevice "Keyboard0" "CoreKeyboard"
InputDevice "Mouse0" "CorePointer"
EndSection
Section "Device"
Identifier "FBDEV0"
Driver "fbdev"
Option "fbdev" "/dev/fb0"
EndSection
Section "Device"
Identifier "FBDEV1"
Driver "fbdev"
Option "fbdev" "/dev/fb1"
EndSection
Section "Screen"
Identifier "Screen0"
Device "FBDEV"
EndSection
Section "Screen"
Identifier "Screen1"
Device "FBDEV"
EndSection
Section "InputDevice"
Identifier "Keyboard0"
Driver "evdev"
Option "Device" "/dev/input/event0"
Option "XkbLayout" "us"
Option "GrabDevice" "on" #Exclusively grab device
EndSection
Section "InputDevice"
Identifier "Mouse0"
Driver "evdev"
Option "Device" "/dev/input/event1"
Option "Protocol" "auto"
Option "ZAxisMapping" "4 5" # Optional, for mouse wheel
Option "Buttons" "5" # Optional, configure number of buttons
EndSection
userconf.lua
local webview = require("webview")
-- Define username, password, delay, and refresh from environment variables
local username = os.getenv("HA_USERNAME")
local password = os.getenv("HA_PASSWORD")
local login_delay = tonumber(os.getenv("LOGIN_DELAY")) or 2 -- Default to 2 seconds
local start_url = os.getenv("START_URL") or "http://localhost:8123"
local browser_refresh = tonumber(os.getenv("BROWSER_REFRESH")) or 600 -- Default to 600 seconds
-- Check for required environment variables
if not username or not password then
print("Error: HA_USERNAME and HA_PASSWORD environment variables must be set")
os.exit(1)
end
-- Convert delays to milliseconds
local delay_ms = login_delay * 1000
if not delay_ms or delay_ms < 0 then
print("Error: LOGIN_DELAY must be a non-negative number (in seconds)")
os.exit(1)
end
local refresh_ms = browser_refresh * 1000
if not refresh_ms or refresh_ms < 0 then
print("Error: BROWSER_REFRESH must be a non-negative number (in seconds)")
os.exit(1)
end
local window = require("window")
window.add_signal("init", function(w)
w.win.fullscreen = true
end)
webview.add_signal("init", function(view)
-- Auto-login and refresh on every page load
view:add_signal("load-status", function(v, status)
if status == "finished" then
-- Auto-login on auth page
if v.uri:match("^" .. start_url .. "/auth/authorize%?response_type=code") then
v:eval_js([[
setTimeout(function() {
var userField = document.querySelector('input[name="username"]');
var passField = document.querySelector('input[name="password"]');
var submitBtn = document.querySelector('mwc-button');
if (userField && passField && submitBtn) {
userField.value = "";
userField.dispatchEvent(new Event('input', { bubbles: true }));
userField.value = "]] .. username .. [[";
userField.dispatchEvent(new Event('input', { bubbles: true }));
passField.value = "";
passField.dispatchEvent(new Event('input', { bubbles: true }));
passField.value = "]] .. password .. [[";
passField.dispatchEvent(new Event('input', { bubbles: true }));
submitBtn.click();
}
}, ]] .. delay_ms .. [[);
]], { source = "auto_login.js" })
end
-- Periodic refresh of current page if refresh_ms > 0
if refresh_ms > 0 then
v:eval_js([[
// Clear any existing interval to avoid duplicates
if (window.refreshInterval) clearInterval(window.refreshInterval);
window.refreshInterval = setInterval(function() {
location.reload();
}, ]] .. refresh_ms .. [[);
]], { source = "auto_refresh.js" })
end
end
end)
end)
To install and run add-on:
- Copy the above 5 files to `/addons/local_dashboard
- In Home Assistant, go to:
Settings->Add-ons
. Click on 'ADD-ON STOREand find the new add-on in the
Local add-onssection, labeled
LocalDashboard`
- Click on it, click
INSTALL
- Go to
Configuration
to enter mandatory HA username and password
- Click on
START
Note: I have only validated this on a RPi5. Other RPi’s and devices may require modification to work…
Note: Currently there is no difference between HDMI0 and HDMI1 since HAOS mirrors them…