diff --git a/backend/planktoscope-org.backend.service b/backend/planktoscope-org.backend.service index 448bf1af3..2179bc59f 100644 --- a/backend/planktoscope-org.backend.service +++ b/backend/planktoscope-org.backend.service @@ -2,6 +2,7 @@ Description=PlanktoScope backend service Wants=mosquitto.service After=mosquitto.service +After=planktoscope-org.controller.display.service [Service] Type=simple diff --git a/backend/src/network.js b/backend/src/network.js new file mode 100644 index 000000000..4f7372c6f --- /dev/null +++ b/backend/src/network.js @@ -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) diff --git a/backend/src/service.js b/backend/src/service.js index a7b5cf4a0..52a18397d 100755 --- a/backend/src/service.js +++ b/backend/src/service.js @@ -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" diff --git a/controller/display/main.py b/controller/display/main.py index 786e30fde..9605b2cb6 100644 --- a/controller/display/main.py +++ b/controller/display/main.py @@ -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) @@ -54,7 +55,7 @@ BAR_HEIGHT = 26 -def drawURL(url): +def drawStatus(status=""): assert draw is not None assert width is not None assert height is not None @@ -62,18 +63,20 @@ def drawURL(url): 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(): @@ -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 @@ -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) @@ -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(): @@ -142,7 +144,7 @@ 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() @@ -150,9 +152,9 @@ async def start() -> None: 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) @@ -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() diff --git a/controller/display/test.js b/controller/display/test.js index 805bfe52d..f4a62258d 100644 --- a/controller/display/test.js +++ b/controller/display/test.js @@ -1,5 +1,4 @@ -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) { @@ -7,13 +6,7 @@ watch("display").then(async (messages) => { } }) -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() diff --git a/frontend/.gitignore b/frontend/.gitignore index 6845695d7..7f5d8fbd0 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,2 +1,3 @@ dist .vite +src/pages/preview/reader.js diff --git a/frontend/src/pages/preview/reader.js b/frontend/src/pages/preview/reader.js index bc2155e55..d6b4348be 100644 --- a/frontend/src/pages/preview/reader.js +++ b/frontend/src/pages/preview/reader.js @@ -1,5 +1,3 @@ -// https://github.com/bluenviron/mediamtx/blob/v1.16.1/internal/servers/webrtc/reader.js - 'use strict'; /** diff --git a/lib/cockpit.js b/lib/cockpit.js new file mode 100644 index 000000000..e3092c930 --- /dev/null +++ b/lib/cockpit.js @@ -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" }) +} diff --git a/lib/mediamtx.js b/lib/mediamtx.js new file mode 100644 index 000000000..4e75005ee --- /dev/null +++ b/lib/mediamtx.js @@ -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" }) +} diff --git a/lib/network.js b/lib/network.js index 00f1eddeb..836f5919e 100644 --- a/lib/network.js +++ b/lib/network.js @@ -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 diff --git a/os/cockpit/cockpit.ini b/os/cockpit/cockpit.ini index 751783d96..63f481e30 100644 --- a/os/cockpit/cockpit.ini +++ b/os/cockpit/cockpit.ini @@ -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/ diff --git a/os/cockpit/justfile b/os/cockpit/justfile index 3f0174d8b..3274d5b16 100644 --- a/os/cockpit/justfile +++ b/os/cockpit/justfile @@ -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 diff --git a/os/filebrowser/justfile b/os/filebrowser/justfile index 90958ada1..d941fbc99 100644 --- a/os/filebrowser/justfile +++ b/os/filebrowser/justfile @@ -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 diff --git a/os/justfile b/os/justfile index c3b512b77..b5ccca717 100644 --- a/os/justfile +++ b/os/justfile @@ -1,4 +1,5 @@ setup: + # Should be first just --justfile machine-name/justfile setup just --justfile localization/justfile setup just --justfile caddy/justfile setup diff --git a/os/machine-name/generate-hostname.sh b/os/machine-name/generate-hostname.sh index 1e81cd7bc..b951f9ac4 100755 --- a/os/machine-name/generate-hostname.sh +++ b/os/machine-name/generate-hostname.sh @@ -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 diff --git a/os/mediamtx/.gitignore b/os/mediamtx/.gitignore new file mode 100644 index 000000000..bdd15dee1 --- /dev/null +++ b/os/mediamtx/.gitignore @@ -0,0 +1 @@ +mediamtx.yml diff --git a/os/mediamtx/justfile b/os/mediamtx/justfile index 6f6b5df74..27a4279db 100644 --- a/os/mediamtx/justfile +++ b/os/mediamtx/justfile @@ -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 @@ -15,4 +15,4 @@ test: dev: -sudo systemctl stop mediamtx - mediamtx /usr/local/etc/mediamtx.yml + mediamtx mediamtx.yml diff --git a/os/mediamtx/mediamtx.service b/os/mediamtx/mediamtx.service index fbbb3cef7..a8d9d55cd 100644 --- a/os/mediamtx/mediamtx.service +++ b/os/mediamtx/mediamtx.service @@ -5,7 +5,7 @@ After=network.target Wants=network.target [Service] -ExecStart=/usr/local/bin/mediamtx /usr/local/etc/mediamtx.yml +ExecStart=/usr/local/bin/mediamtx /home/pi/PlanktoScope/os/mediamtx/mediamtx.yml [Install] WantedBy=multi-user.target diff --git a/os/mediamtx/mediamtx.yml b/os/mediamtx/mediamtx.template.yml similarity index 83% rename from os/mediamtx/mediamtx.yml rename to os/mediamtx/mediamtx.template.yml index 73324d0c2..6f9d1cf6c 100644 --- a/os/mediamtx/mediamtx.yml +++ b/os/mediamtx/mediamtx.template.yml @@ -1,8 +1,15 @@ -# https://github.com/bluenviron/mediamtx/blob/v1.16.1/mediamtx.yml +# https://github.com/bluenviron/mediamtx/blob/v1.16.2/mediamtx.yml # https://mediamtx.org/docs/usage/webrtc-specific-features#solving-webrtc-connectivity-issues webrtcAdditionalHosts: - [localhost, planktoscope.local, 192.168.4.1, raspberrypi, raspberrypi.local] + [ + localhost, + planktoscope.local, + 192.168.4.1, + raspberrypi, + raspberrypi.local, + 192.0.2.1, + ] # https://mediamtx.org/docs/usage/decrease-packet-loss writeQueueSize: 1024