Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/planktoscope-org.backend.service
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Description=PlanktoScope backend service
Wants=mosquitto.service
After=mosquitto.service
After=planktoscope-org.controller.display.service

[Service]
Type=simple
Expand Down
28 changes: 28 additions & 0 deletions backend/src/network.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { configureDisplay } from "../../lib/scope.js"
import {
getWiredIPAddress,
onWiredConnectivityChange,
} from "../../lib/network.js"
import { reconfigureMediaMTX } from "../../lib/mediamtx.js"
import { reconfigureCockpit } from "../../lib/cockpit.js"
import os from "os"

async function updateDisplay(address) {
let status = ""
if (!address) status = "Offline"
else if (address) status = `http://${address}`
await configureDisplay({ status })
}

async function update() {
const hostname = os.hostname()
const address = await getWiredIPAddress()
await Promise.all([
updateDisplay(address),
reconfigureMediaMTX({ hostname, address }),
])
await reconfigureCockpit({ hostname, address })
}

update()
onWiredConnectivityChange(update)
1 change: 1 addition & 0 deletions backend/src/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import cors from "cors"
import "./factory.js"
import "./config.js"
import "./led-operating-time.js"
import "./network.js"
import { readSoftwareConfig, removeConfig } from "../../lib/file-config.js"
import { capture } from "../../lib/scope.js"

Expand Down
35 changes: 18 additions & 17 deletions controller/display/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
if os.path.exists(libdir):
sys.path.append(libdir)

machine_name = None
epd = None
fontsmall = ImageFont.truetype(os.path.join(picdir, "Font.ttc"), 18)
fontnormal = ImageFont.truetype(os.path.join(picdir, "Font.ttc"), 19)
Expand All @@ -54,26 +55,28 @@
BAR_HEIGHT = 26


def drawURL(url):
def drawStatus(status=""):
assert draw is not None
assert width is not None
assert height is not None
# Black bar across the bottom
draw.rectangle((0, height - BAR_HEIGHT, width, height), fill=0)
# White text centered in the bar
draw.text(
(width // 2, height - BAR_HEIGHT // 2), text=url, anchor="mm", font=fontnormal, fill=255
(width // 2, height - BAR_HEIGHT // 2), text=status, anchor="mm", font=fontnormal, fill=255
)


def drawHostname(hostname):
def drawMachineName(machine_name):
assert width is not None
assert height is not None
assert draw is not None
# Black bar across the top
draw.rectangle((0, 0, width, BAR_HEIGHT), fill=0)
# White text centered in the bar
draw.text((width // 2, BAR_HEIGHT // 2 + 2), text=hostname, anchor="mm", font=fontbig, fill=255)
draw.text(
(width // 2, BAR_HEIGHT // 2 + 2), text=machine_name, anchor="mm", font=fontbig, fill=255
)


def drawBrand():
Expand All @@ -89,7 +92,7 @@ def drawBrand():
image.paste(logo, (x, y))


def render(url="", hostname=""):
def render(status=""):
assert epd is not None
assert width is not None
assert height is not None
Expand All @@ -103,12 +106,12 @@ def render(url="", hostname=""):
# # TODO: only clear relevant area ?
draw.rectangle((0, 0, height, width), fill=255)

# top black bar with hostname
drawHostname(hostname)
# top black bar with machine_name
drawMachineName(machine_name)
# center logo
drawBrand()
# bottom black bar with URL
drawURL(url)
# bottom black bar with status
drawStatus(status)

epd.init()
epd.Clear(0xFF)
Expand All @@ -118,9 +121,8 @@ def render(url="", hostname=""):


async def configure(config):
url = config.get("url", "")
machine_name = config.get("machine-name", "")
render(url, machine_name)
status = config.get("status", "")
render(status)


async def clear():
Expand All @@ -142,17 +144,17 @@ async def start() -> None:
if (await helpers.get_hat_version()) != 3.3:
sys.exit()

global epd, epd2in9_V2, width, height
global epd, epd2in9_V2, width, height, machine_name
from waveshare_epd import epd2in9_V2 # type: ignore

epd = epd2in9_V2.EPD()
# horizontal
width = epd.height
height = epd.width

url = "http://192.168.4.1"
machine_name = helpers.get_machine_name()
render(url=url, hostname=machine_name)

render(status="http://192.168.4.1")

global client
client = aiomqtt.Client(hostname="localhost", port=1883, protocol=aiomqtt.ProtocolVersion.V5)
Expand Down Expand Up @@ -188,9 +190,8 @@ async def handle_action(action: str, payload) -> None:


async def stop() -> None:
machine_name = helpers.get_machine_name()
if epd is not None:
render(url="OFF", hostname=machine_name)
render(status="OFF")
loop.stop()


Expand Down
13 changes: 3 additions & 10 deletions controller/display/test.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
import { configureDisplay, clearDisplay, watch } from "../../lib/scope.js"
import { setTimeout } from "timers/promises"
import { configureDisplay, watch } from "../../lib/scope.js"

watch("display").then(async (messages) => {
for await (const message of messages) {
console.log("display", message)
}
})

const url = `http://planktoscope-sponge-bob`
const hostname = "sponge-bob"

const status = `http://planktoscope-sponge-bob`
await configureDisplay({
url,
hostname,
status,
})

await setTimeout(5000)
await clearDisplay()
1 change: 1 addition & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
dist
.vite
src/pages/preview/reader.js
2 changes: 0 additions & 2 deletions frontend/src/pages/preview/reader.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// https://github.com/bluenviron/mediamtx/blob/v1.16.1/internal/servers/webrtc/reader.js

'use strict';

/**
Expand Down
40 changes: 40 additions & 0 deletions lib/cockpit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { readFile, writeFile } from "node:fs/promises"
import os from "os"
import { $ } from "execa"
import { Systemctl } from "systemctl.js"

const config_template_path = "/home/pi/PlanktoScope/os/cockpit/cockpit.ini"
const config_tmp_path = "/tmp/cockpit.conf"
const config_path = "/etc/cockpit/cockpit.conf"

async function configureCockpit({ hostname, address } = {}) {
let content = await readFile(config_template_path, "utf8")
if (address) {
content = content.replaceAll("192.0.2.1", address)
}
if (hostname) {
content = content.replaceAll("raspberrypi", hostname)
}
await writeFile(config_tmp_path, content)
// FIXME: We should not need sudo, let's figure out
// how we can write cockpit config without root
await $`sudo mv ${config_tmp_path} ${config_path}`
}

async function restartCockpit() {
const systemctl = new Systemctl()
await systemctl.init()
await systemctl.restart("cockpit")
await systemctl.deinit()
}

export async function reconfigureCockpit(config) {
await configureCockpit(config)
await restartCockpit()
}

/* eslint-disable n/no-top-level-await */
if (import.meta.main) {
const hostname = os.hostname()
await reconfigureCockpit({ hostname, address: "10.42.0.94" })
}
29 changes: 29 additions & 0 deletions lib/mediamtx.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { readFile, writeFile } from "node:fs/promises"
import os from "os"

const config_template_path =
"/home/pi/PlanktoScope/os/mediamtx/mediamtx.template.yml"
const config_path = "/home/pi/PlanktoScope/os/mediamtx/mediamtx.yml"

async function configureMediaMTX({ hostname, address } = {}) {
let content = await readFile(config_template_path, "utf8")
if (address) {
content = content.replaceAll("192.0.2.1", address)
}
if (hostname) {
content = content.replaceAll("raspberrypi", hostname)
}
await writeFile(config_path, content)
}

export async function reconfigureMediaMTX(config) {
await configureMediaMTX(config)
// mediamtx watches for file change on the config file
// so we don't need to reload/restart the service
}

/* eslint-disable n/no-top-level-await */
if (import.meta.main) {
const hostname = os.hostname()
await reconfigureMediaMTX({ hostname, address: "192.0.2.1" })
}
52 changes: 52 additions & 0 deletions lib/network.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,55 @@ export async function getWifis() {
export async function connectToWifi(path) {
await NetworkManager.AddAndActivateConnection([], device_path, path)
}

const [wired_device_path] = await NetworkManager.GetDeviceByIpIface("eth0")
const [wired_device, wired_device_Properties] = await Promise.all([
service.getInterface(
wired_device_path,
"org.freedesktop.NetworkManager.Device",
),
service.getInterface(wired_device_path, "org.freedesktop.DBus.Properties"),
])

export async function getWiredIPAddress() {
const IP4Config_path = await readProp(wired_device, "Ip4Config")
const IP4Config = await service.getInterface(
IP4Config_path,
"org.freedesktop.NetworkManager.IP4Config",
)
const addressData = await readProp(IP4Config, "AddressData")
const address = addressData?.[0]?.[0]?.[1]?.[1]?.[0]
return address
}

let wired_connectivity_change_callback
export function onWiredConnectivityChange(handler) {
wired_connectivity_change_callback = handler
}
await wired_device_Properties.subscribe(
"PropertiesChanged",
function handler(interface_name, changed_properties) {
// console.dir(changed_properties, { colors: true, depth: null })
if (interface_name === "org.freedesktop.NetworkManager.Device") {
const Ip4Connectivity = changed_properties.find((changed_property) => {
const [property_name] = changed_property
return property_name === "Ip4Connectivity"
})
if (!Ip4Connectivity) return
wired_connectivity_change_callback?.()
}
},
)

// If the DHCP server goes offline "softly", for example disabling ICS
// NetworkManager won't notice one possible solution is to use connectivity checking and
// await wired_device.subscribe(
// "StateChanged",
// function handler(new_state, old_state) {
// // NM_DEVICE_STATE_ACTIVATED
// if (new_state !== "ACTIVATED") {
// wired_connectivity_change_callback?.()
// }
// },
// )
// an proably better solution is to open a TCP connection to the host and watch for state changes
2 changes: 1 addition & 1 deletion os/cockpit/cockpit.ini
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[WebService]
AllowUnencrypted = true
# Cannot use "*://" or "*"
Origins = http://localhost http://planktoscope.local http://192.168.4.1 http://raspberrypi http://raspberrypi.local
Origins = http://localhost http://planktoscope.local http://192.168.4.1 http://raspberrypi http://raspberrypi.local http://raspberrypi.local http://192.0.2.1
ProtocolHeader = X-Forwarded-Proto
ForwardedForHeader = X-Forwarded-For
UrlRoot = /admin/cockpit/
14 changes: 5 additions & 9 deletions os/cockpit/justfile
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
setup: cockpit-files
# Install cockpit
sudo apt install -y --no-install-recommends cockpit cockpit-networkmanager cockpit-storaged cockpit-system cockpit-sosreport pcp
sudo cp cockpit.ini /etc/cockpit/cockpit.conf
# https://cockpit-project.org/guide/latest/feature-pcp
sudo systemctl reenable pmlogger
sudo systemctl restart pmlogger
node ../../lib/cockpit.js
sudo systemctl reenable cockpit.socket
sudo systemctl restart cockpit.socket
# TODO consider
# https://github.com/gbraad-cockpit/cockpit-tailscale
# https://github.com/gbraad-cockpit/cockpit-headscale

# unavailable in debian
# cockpit-files is unavailable in debian for now

# https://github.com/cockpit-project/cockpit-files/issues/635
# watch out for https://github.com/cockpit-project/cockpit-files/issues/635
cockpit-files:
sudo apt install -y gettext
wget https://github.com/cockpit-project/cockpit-files/releases/download/30/cockpit-files-30.tar.xz -P /tmp
cd /tmp && tar -xf cockpit-files-30.tar.xz
rm -rf ~/.local/share/cockpit/files
wget https://github.com/cockpit-project/cockpit-files/releases/download/36/cockpit-files-36.tar.xz -P /tmp
cd /tmp && tar -xf cockpit-files-36.tar.xz
sudo rm -rf /usr/local/share/cockpit/files
cd /tmp/cockpit-files && make
cd /tmp/cockpit-files && sudo make install
Expand Down
2 changes: 1 addition & 1 deletion os/filebrowser/justfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
setup:
wget https://github.com/filebrowser/filebrowser/releases/download/v2.44.2/linux-arm64-filebrowser.tar.gz -P /tmp
wget https://github.com/filebrowser/filebrowser/releases/download/v2.60.0/linux-arm64-filebrowser.tar.gz -P /tmp
cd /tmp && tar -xzf /tmp/linux-arm64-filebrowser.tar.gz filebrowser
-sudo systemctl stop filebrowser
sudo cp /tmp/filebrowser /usr/local/bin/filebrowser
Expand Down
1 change: 1 addition & 0 deletions os/justfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
setup:
# Should be first
just --justfile machine-name/justfile setup
just --justfile localization/justfile setup
just --justfile caddy/justfile setup
Expand Down
2 changes: 0 additions & 2 deletions os/machine-name/generate-hostname.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,4 @@ echo "Hostname: $hostname"
printf "%s" "$hostname" > /etc/hostname

sed -i "s/raspberrypi/$hostname/g" /etc/hosts || true
sed -i "s/raspberrypi/$hostname/g" /etc/cockpit/cockpit.conf || true
sed -i "s/raspberrypi/PlanktoScope $machine_name/g" /etc/NetworkManager/system-connections/wlan0-hotspot.nmconnection || true
sed -i "s/raspberrypi/$hostname/g" /usr/local/etc/mediamtx.yml || true
1 change: 1 addition & 0 deletions os/mediamtx/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
mediamtx.yml
10 changes: 5 additions & 5 deletions os/mediamtx/justfile
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
setup:
sudo ./setup_h264_sysctl.sh
wget https://github.com/bluenviron/mediamtx/releases/download/v1.16.1/mediamtx_v1.16.1_linux_arm64.tar.gz -P /tmp
cd /tmp && tar -xf /tmp/mediamtx_v1.16.1_linux_arm64.tar.gz
wget https://github.com/bluenviron/mediamtx/releases/download/v1.16.2/mediamtx_v1.16.2_linux_arm64.tar.gz -P /tmp
wget https://raw.githubusercontent.com/bluenviron/mediamtx/refs/tags/v1.16.2/internal/servers/webrtc/reader.js -O /home/pi/PlanktoScope/frontend/src/pages/preview/reader.js
cd /tmp && tar -xf /tmp/mediamtx_v1.16.2_linux_arm64.tar.gz
-sudo systemctl stop mediamtx
sudo cp /tmp/mediamtx /usr/local/bin/mediamtx
sudo cp mediamtx.service /etc/systemd/system/
sudo cp mediamtx.yml /usr/local/etc/
sudo sed -i "s/raspberrypi/$(hostname)/g" /usr/local/etc/mediamtx.yml
node ../../lib/mediamtx.js
sudo systemctl reenable mediamtx
sudo systemctl restart mediamtx

Expand All @@ -15,4 +15,4 @@ test:

dev:
-sudo systemctl stop mediamtx
mediamtx /usr/local/etc/mediamtx.yml
mediamtx mediamtx.yml
Loading