diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e99bdea --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/.gitattributes text eol=lf +*.sh text eol=lf diff --git a/README.md b/README.md index fb17335..6ef4c02 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,16 @@ dotnet test tests/bluejay-infra-lint/BluejayInfraLint.Tests.csproj -c Release That test project sweeps `bluejay-infra/apps/**` plus the canonical sibling `FlowerCore.*\\k8s` manifests that share the same workspace. Matching `conftest.dev` policy files live under `tests/bluejay-infra-lint/conftest.dev/` for environments that also have `conftest` or `opa`. +## Non-K8s Pi Artifacts + +Some `apps/*` directories are deployment artifact bundles consumed by Puppet +instead of Kubernetes workloads. `apps/fc-signage-pi-player/` carries the +Chromium signage Pi player, `apps/fc-divoom-dm-pi-device/` carries the additive +edge2 Divoom-as-DeviceManagement-device profile/Hiera contract, and +`apps/fc-divoom-tv-pi/` carries the Divoom TV Pi HDMI systemd/Puppet shape. +These bundles intentionally avoid Deployment, IngressRoute, Certificate, and +OnePasswordItem resources. + ## References - OpenVox noc1 durability runbook: `docs/runbooks/openvoxserver-quadlet-durability.md` diff --git a/apps/fc-divoom-dm-pi-device/README.md b/apps/fc-divoom-dm-pi-device/README.md new file mode 100644 index 0000000..28b1cb1 --- /dev/null +++ b/apps/fc-divoom-dm-pi-device/README.md @@ -0,0 +1,45 @@ +# FlowerCore Divoom DM Pi Device + +Source-controlled Puppet/Hiera deployment contract for registering the edge2 +Divoom MiniToo panel as a FlowerCore DeviceManagement-managed Pi device. + +This is not a Kubernetes application. The live panel remains the existing +edge2 `flowercore-divoom.service` managed by `FlowerCore.Puppet` +`profile::pi::service::divoom`, with the .NET payload deployed out of band +and `/opt/flowercore/divoom/data` plus the Bluetooth shell wrappers preserved. +Because edge2 is already Hiera-driven through `profile::pi::service::apps`, +the deploy home is additive `profile::pi::service` data/profile source, not +`profile::edge::service::apps` and not an ArgoCD/K8s app. + +## Scope + +- Stage DeviceManagement registration metadata for the edge2 Divoom MiniToo. +- Stage a separate, disabled-by-default DM Agent executor unit for privileged + Bluetooth operations once the DM-RPC lane lands. +- Keep `flowercore-divoom.service` and `flowercore-divoom-bt.service` + untouched: no service replacement, no restart subscription, no K8s surface. +- Preserve the current wrapper contract: + `/opt/flowercore/divoom/bt-link.sh`, + `/opt/flowercore/divoom/bt-reset.sh`, and + `/opt/flowercore/divoom/audio-link.sh`. +- Keep FM radio disabled and require visible render proof; device-info echo is + not render proof. + +## Artifact Map + +| Path | Use | +| --- | --- | +| `hiera/edge2-divoom-dm-device.overlay.yaml` | Additive Hiera overlay for edge2. Merge into the existing node YAML without removing `fc-pimanager` or `fc-divoom`. | +| `puppet/profile/pi/service/divoom_dm_device.pp` | Puppet profile shape to vendor into `FlowerCore.Puppet` after the DM-RPC executor binary exists. | +| `puppet/templates/divoom-device-registration.json.epp` | DM device registration metadata rendered on edge2. | +| `puppet/templates/flowercore-divoom-dm-agent.service.epp` | Separate DM Agent systemd unit. Defaults are stopped and disabled until a later cutover. | + +## Rollout Notes + +1. Land these artifacts in bluejay-infra as the deploy contract. +2. Vendor the Puppet profile and EPP templates into `FlowerCore.Puppet`. +3. Merge the Hiera overlay into `data/nodes/edge2.iamworkin.lan.yaml`. +4. Run Puppet in noop first, preferably with a node-local validation directory + under `~/.fcv` rather than `/tmp`. +5. Only enable the DM Agent service after the DeviceManagement BT executor has + landed and passed operator-eyeball render proof. diff --git a/apps/fc-divoom-dm-pi-device/hiera/edge2-divoom-dm-device.overlay.yaml b/apps/fc-divoom-dm-pi-device/hiera/edge2-divoom-dm-device.overlay.yaml new file mode 100644 index 0000000..1e6cb2a --- /dev/null +++ b/apps/fc-divoom-dm-pi-device/hiera/edge2-divoom-dm-device.overlay.yaml @@ -0,0 +1,32 @@ +--- +# Merge into FlowerCore.Puppet data/nodes/edge2.iamworkin.lan.yaml. +# Additive overlay only: keep the existing fc-pimanager version/tarball entry, +# keep fc-divoom enabled, and do not move Divoom into Kubernetes. + +profile::pi::service::apps: + fc-pimanager: + binary: 'FlowerCore.PiManager.Web' + install_dir: '/opt/fc-pimanager' + port: 5000 + environment: 'edge2' + version: '2026.05.28.1646' + tarball_source: 'puppet:///modules/profile/pi/builds/fc-pimanager.tar.gz' + fc-divoom: + enabled: true + +profile::pi::service::divoom_dm_device::ensure: 'present' +profile::pi::service::divoom_dm_device::service_enabled: false +profile::pi::service::divoom_dm_device::service_ensure: 'stopped' +profile::pi::service::divoom_dm_device::device_id: 'edge2-divoom-minitoo' +profile::pi::service::divoom_dm_device::display_name: 'edge2 Divoom MiniToo' +profile::pi::service::divoom_dm_device::host_fqdn: 'edge2.iamworkin.lan' +profile::pi::service::divoom_dm_device::dm_web_url: 'https://devicemgmt.iamworkin.lan' +profile::pi::service::divoom_dm_device::divoom_install_dir: '/opt/flowercore/divoom' +profile::pi::service::divoom_dm_device::agent_install_dir: '/opt/flowercore/devicemanagement-agent' +profile::pi::service::divoom_dm_device::bt_candidate_channels: + - '1' + - '10' +profile::pi::service::divoom_dm_device::default_bt_channel: '1' +profile::pi::service::divoom_dm_device::a2dp_default_state: 'off' +profile::pi::service::divoom_dm_device::fm_radio_enabled: false +profile::pi::service::divoom_dm_device::visible_render_proof_required: true diff --git a/apps/fc-divoom-dm-pi-device/puppet/profile/pi/service/divoom_dm_device.pp b/apps/fc-divoom-dm-pi-device/puppet/profile/pi/service/divoom_dm_device.pp new file mode 100644 index 0000000..b4ac3cc --- /dev/null +++ b/apps/fc-divoom-dm-pi-device/puppet/profile/pi/service/divoom_dm_device.pp @@ -0,0 +1,140 @@ +# Drop into FlowerCore.Puppet site-modules/profile/manifests/pi/service/divoom_dm_device.pp. +# This profile is additive to profile::pi::service::divoom. It must not manage, +# restart, replace, or subscribe the existing flowercore-divoom.service. +class profile::pi::service::divoom_dm_device ( + Enum['present', 'absent'] $ensure = 'present', + Boolean $service_enabled = false, + Enum['running', 'stopped'] $service_ensure = 'stopped', + String $service_name = 'flowercore-divoom-dm-agent', + String $device_id = 'edge2-divoom-minitoo', + String $display_name = 'edge2 Divoom MiniToo', + String $host_fqdn = 'edge2.iamworkin.lan', + String $dm_web_url = 'https://devicemgmt.iamworkin.lan', + String $divoom_install_dir = '/opt/flowercore/divoom', + String $agent_install_dir = '/opt/flowercore/devicemanagement-agent', + String $agent_binary = 'FlowerCore.DeviceManagement.Agent', + Array[String] $bt_candidate_channels = ['1', '10'], + String $default_bt_channel = '1', + Enum['on', 'off'] $a2dp_default_state = 'off', + Boolean $fm_radio_enabled = false, + Boolean $visible_render_proof_required = true, +) { + include profile::workstation::safe_account_exclusion + + $safe_account = $profile::workstation::safe_account_exclusion::safe_account + $config_dir = '/etc/flowercore/device-management/devices' + $state_dir = '/var/lib/flowercore/divoom-dm-agent' + $log_dir = '/var/log/flowercore/divoom-dm-agent' + $registration_path = "${config_dir}/${device_id}.json" + $agent_binary_path = "${agent_install_dir}/${agent_binary}" + $bt_channels_json = inline_template('[<%= @bt_candidate_channels.map { |c| "\"#{c}\"" }.join(", ") %>]') + + if $safe_account { + notify { 'fc-divoom-dm-device safe-account exclusion': + message => 'SAFE-ACCOUNT-EXCLUSION: Divoom DM Pi device profile refused to apply on operator workstation', + } + + if $facts['os']['family'] != 'windows' { + ensure_resource('file', '/var/log/flowercore-audit', { + 'ensure' => 'directory', + 'owner' => 'root', + 'group' => 'root', + 'mode' => '0755', + }) + + file { '/var/log/flowercore-audit/safe-account-noop-fc-divoom-dm-device.log': + ensure => file, + owner => 'root', + group => 'root', + mode => '0644', + content => "noop: divoom dm pi device profile refused to apply on safe-account host\n", + require => File['/var/log/flowercore-audit'], + } + } + } elsif $ensure == 'absent' { + service { $service_name: + ensure => stopped, + enable => false, + } + + file { [ + "/etc/systemd/system/${service_name}.service", + $registration_path, + ]: + ensure => absent, + } + + exec { 'fc-divoom-dm-agent-systemd-reload': + command => '/usr/bin/systemctl daemon-reload', + refreshonly => true, + path => ['/usr/bin', '/bin'], + } + } else { + case $facts['os']['family'] { + 'Debian': {} + default: { fail("profile::pi::service::divoom_dm_device only supports Debian-family OS, got ${facts['os']['family']}") } + } + + file { [$config_dir, $state_dir, $log_dir]: + ensure => directory, + owner => 'root', + group => 'root', + mode => '0755', + } + + file { $registration_path: + ensure => file, + owner => 'root', + group => 'root', + mode => '0644', + content => epp('profile/pi/fc_divoom_dm/divoom-device-registration.json.epp', { + 'device_id' => $device_id, + 'display_name' => $display_name, + 'host_fqdn' => $host_fqdn, + 'divoom_install_dir' => $divoom_install_dir, + 'bt_channels_json' => $bt_channels_json, + 'default_bt_channel' => $default_bt_channel, + 'a2dp_default_state' => $a2dp_default_state, + 'fm_radio_enabled' => $fm_radio_enabled, + 'visible_render_proof_required' => $visible_render_proof_required, + }), + require => File[$config_dir], + } + + file { "/etc/systemd/system/${service_name}.service": + ensure => file, + owner => 'root', + group => 'root', + mode => '0644', + content => epp('profile/pi/fc_divoom_dm/flowercore-divoom-dm-agent.service.epp', { + 'service_name' => $service_name, + 'device_id' => $device_id, + 'dm_web_url' => $dm_web_url, + 'registration_path' => $registration_path, + 'divoom_install_dir' => $divoom_install_dir, + 'agent_install_dir' => $agent_install_dir, + 'agent_binary_path' => $agent_binary_path, + 'state_dir' => $state_dir, + 'log_dir' => $log_dir, + }), + notify => Exec['fc-divoom-dm-agent-systemd-reload'], + require => File[$registration_path], + } + + exec { 'fc-divoom-dm-agent-systemd-reload': + command => '/usr/bin/systemctl daemon-reload', + refreshonly => true, + path => ['/usr/bin', '/bin'], + } + + service { $service_name: + ensure => $service_ensure, + enable => $service_enabled, + require => [ + File["/etc/systemd/system/${service_name}.service"], + File[$registration_path], + Exec['fc-divoom-dm-agent-systemd-reload'], + ], + } + } +} diff --git a/apps/fc-divoom-dm-pi-device/puppet/templates/divoom-device-registration.json.epp b/apps/fc-divoom-dm-pi-device/puppet/templates/divoom-device-registration.json.epp new file mode 100644 index 0000000..00902c7 --- /dev/null +++ b/apps/fc-divoom-dm-pi-device/puppet/templates/divoom-device-registration.json.epp @@ -0,0 +1,34 @@ +{ + "deviceId": "<%= $device_id %>", + "displayName": "<%= $display_name %>", + "hostFqdn": "<%= $host_fqdn %>", + "kind": "DivoomMiniToo", + "managedBy": "FlowerCore.DeviceManagement", + "executionMode": "Pi", + "transport": { + "kind": "BluetoothSerial", + "candidateChannels": <%= $bt_channels_json %>, + "defaultChannel": "<%= $default_bt_channel %>", + "deviceInfoIsRenderProof": false, + "visibleRenderProofRequired": <%= $visible_render_proof_required %> + }, + "paths": { + "divoomInstallDir": "<%= $divoom_install_dir %>", + "btLink": "<%= $divoom_install_dir %>/bt-link.sh", + "btReset": "<%= $divoom_install_dir %>/bt-reset.sh", + "audioLink": "<%= $divoom_install_dir %>/audio-link.sh" + }, + "capabilities": { + "supportsBluetoothSerial": true, + "supportsBtChannelRedetect": true, + "supportsBtHardReset": true, + "supportsBtAudioProfileSwitch": true, + "a2dpDefaultState": "<%= $a2dp_default_state %>", + "fmRadioEnabled": <%= $fm_radio_enabled %> + }, + "safety": { + "preserveExistingService": "flowercore-divoom.service", + "preserveDataDirectory": "<%= $divoom_install_dir %>/data", + "doNotEnableFmRadio": true + } +} diff --git a/apps/fc-divoom-dm-pi-device/puppet/templates/flowercore-divoom-dm-agent.service.epp b/apps/fc-divoom-dm-pi-device/puppet/templates/flowercore-divoom-dm-agent.service.epp new file mode 100644 index 0000000..abc4fe9 --- /dev/null +++ b/apps/fc-divoom-dm-pi-device/puppet/templates/flowercore-divoom-dm-agent.service.epp @@ -0,0 +1,36 @@ +[Unit] +Description=FlowerCore Divoom DM Agent Bluetooth executor +Documentation=https://github.com/astoltz/FlowerCore.Notes/blob/master/docs/standards/divoom-tv-hdmi-multitarget-render-substrate.md +Wants=network-online.target +After=network-online.target bluetooth.service +Requires=bluetooth.service +ConditionPathExists=<%= $agent_binary_path %> +ConditionPathExists=<%= $registration_path %> +ConditionPathExists=<%= $divoom_install_dir %>/bt-link.sh +ConditionPathExists=<%= $divoom_install_dir %>/bt-reset.sh +ConditionPathExists=<%= $divoom_install_dir %>/audio-link.sh + +[Service] +Type=simple +User=stoltz +Group=stoltz +WorkingDirectory=<%= $agent_install_dir %> +Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1 +Environment=FLOWERCORE_DM_DEVICE_REGISTRATION=<%= $registration_path %> +Environment=Divoom__Bluetooth__DeviceInfoIsRenderProof=false +Environment=Divoom__Bluetooth__VisibleRenderProofRequired=true +Environment=Divoom__Bluetooth__A2dpDefaultState=off +ExecStart=<%= $agent_binary_path %> --mode=Pi --device-id=<%= $device_id %> --dm-web-url=<%= $dm_web_url %> --registration=<%= $registration_path %> +Restart=on-failure +RestartSec=10s +StartLimitBurst=3 +StartLimitIntervalSec=300s +SupplementaryGroups=bluetooth audio dialout +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=<%= $state_dir %> <%= $log_dir %> + +[Install] +WantedBy=multi-user.target diff --git a/apps/fc-divoom-tv-pi/README.md b/apps/fc-divoom-tv-pi/README.md new file mode 100644 index 0000000..39554cb --- /dev/null +++ b/apps/fc-divoom-tv-pi/README.md @@ -0,0 +1,44 @@ +# FlowerCore Divoom TV Pi HDMI + +Source-controlled deploy shape for the native `FlowerCore.Divoom.Tv` +Avalonia HDMI renderer on a Raspberry Pi connected to a TV. + +This is a Puppet/systemd appliance bundle, not a Kubernetes application. It +mirrors the existing `fc-signage-pi-player` pattern: bluejay-infra carries the +systemd units, scripts, Hiera shape, and Puppet profile source that +`FlowerCore.Puppet` vendors and installs. + +## Scope + +- Launch the future `FlowerCore.Divoom.Tv` linux-arm64 self-contained payload + from `/opt/flowercore/divoom-tv/FlowerCore.Divoom.Tv`. +- Prefer `cage` as the Wayland fullscreen compositor, with direct app launch as + a fallback for development images. +- Restart the app after HDMI hotplug with a 2 second DRM settle delay. +- Keep all runtime state local: `/var/lib/fc-divoom-tv` and + `/var/log/fc-divoom-tv`. +- Avoid CDN/runtime fetches; the app renders the in-house Divoom scene catalog + locally. + +## Artifact Map + +| Path | Use | +| --- | --- | +| `systemd/flowercore-divoom-tv.service` | Fullscreen Avalonia HDMI app service. | +| `systemd/flowercore-divoom-tv-hdmi.service` | HDMI hotplug responder service. | +| `systemd/99-flowercore-divoom-tv-hdmi.rules` | DRM udev hotplug rule. | +| `scripts/flowercore-divoom-tv-prelaunch.sh` | Preflight checks and local directory creation. | +| `scripts/flowercore-divoom-tv-launch.sh` | Cage-first fullscreen launcher. | +| `scripts/flowercore-divoom-tv-hdmi-respond.sh` | Hotplug settle and restart script. | +| `puppet/profile/pi/service/divoom_tv.pp` | Puppet profile shape to vendor into `FlowerCore.Puppet`. | +| `hiera/example-divoom-tv-pi.iamworkin.lan.yaml` | Example node Hiera for a Divoom TV Pi. | + +## Rollout Notes + +1. Build `FlowerCore.Divoom.Tv` with `dotnet.exe publish -c Release -r linux-arm64 --self-contained`. +2. Stage the payload to `/opt/flowercore/divoom-tv/` through the standard noc1 + jump path and avoid `/tmp` for unprivileged Pi scratch. +3. Vendor the profile and static files into `FlowerCore.Puppet`. +4. Run Puppet noop, then apply on the target Pi. +5. Prove deployment with `systemctl is-active flowercore-divoom-tv.service`, + journal lines showing frames presented, and a visible HDMI display check. diff --git a/apps/fc-divoom-tv-pi/hiera/example-divoom-tv-pi.iamworkin.lan.yaml b/apps/fc-divoom-tv-pi/hiera/example-divoom-tv-pi.iamworkin.lan.yaml new file mode 100644 index 0000000..f43cc85 --- /dev/null +++ b/apps/fc-divoom-tv-pi/hiera/example-divoom-tv-pi.iamworkin.lan.yaml @@ -0,0 +1,19 @@ +--- +# Example node data for a dedicated Pi -> HDMI -> TV Divoom renderer. +# Copy into FlowerCore.Puppet data/nodes/.iamworkin.lan.yaml only +# after the Pi has a static DHCP/DNS entry and the linux-arm64 payload exists. + +facts: + role: pi_prototype + +profile::motd::role: 'Divoom TV HDMI Renderer' + +profile::pi::service::divoom_tv::ensure: 'present' +profile::pi::service::divoom_tv::service_enabled: true +profile::pi::service::divoom_tv::service_ensure: 'running' +profile::pi::service::divoom_tv::install_dir: '/opt/flowercore/divoom-tv' +profile::pi::service::divoom_tv::state_dir: '/var/lib/fc-divoom-tv' +profile::pi::service::divoom_tv::log_dir: '/var/log/fc-divoom-tv' +profile::pi::service::divoom_tv::presentation_mode: 'PillarboxSquare' +profile::pi::service::divoom_tv::startup_scene: 'bluejay-clock' +profile::pi::service::divoom_tv::reduced_motion: false diff --git a/apps/fc-divoom-tv-pi/puppet/profile/pi/service/divoom_tv.pp b/apps/fc-divoom-tv-pi/puppet/profile/pi/service/divoom_tv.pp new file mode 100644 index 0000000..21e8fb7 --- /dev/null +++ b/apps/fc-divoom-tv-pi/puppet/profile/pi/service/divoom_tv.pp @@ -0,0 +1,149 @@ +# Drop into FlowerCore.Puppet site-modules/profile/manifests/pi/service/divoom_tv.pp. +# Static files come from profile/pi/fc_divoom_tv/ after this bluejay-infra +# bundle is vendored into the Puppet control repo. +class profile::pi::service::divoom_tv ( + Enum['present', 'absent'] $ensure = 'present', + Boolean $service_enabled = false, + Enum['running', 'stopped'] $service_ensure = 'stopped', + String $service_name = 'flowercore-divoom-tv', + String $user = 'fc-divoom-tv', + String $group = 'fc-divoom-tv', + String $install_dir = '/opt/flowercore/divoom-tv', + String $state_dir = '/var/lib/fc-divoom-tv', + String $log_dir = '/var/log/fc-divoom-tv', + String $presentation_mode = 'PillarboxSquare', + String $startup_scene = 'bluejay-clock', + Boolean $reduced_motion = false, +) { + include profile::workstation::safe_account_exclusion + + $safe_account = $profile::workstation::safe_account_exclusion::safe_account + + if $safe_account { + notify { 'fc-divoom-tv safe-account exclusion': + message => 'SAFE-ACCOUNT-EXCLUSION: Divoom TV Pi profile refused to apply on operator workstation', + } + } elsif $ensure == 'absent' { + service { $service_name: + ensure => stopped, + enable => false, + } + + file { [ + "/etc/systemd/system/${service_name}.service", + "/etc/systemd/system/${service_name}-hdmi.service", + '/etc/udev/rules.d/99-flowercore-divoom-tv-hdmi.rules', + '/usr/local/bin/flowercore-divoom-tv-prelaunch.sh', + '/usr/local/bin/flowercore-divoom-tv-launch.sh', + '/usr/local/bin/flowercore-divoom-tv-hdmi-respond.sh', + '/etc/flowercore/divoom-tv.env', + ]: + ensure => absent, + } + } else { + case $facts['os']['family'] { + 'Debian': {} + default: { fail("profile::pi::service::divoom_tv only supports Debian-family OS, got ${facts['os']['family']}") } + } + + package { ['cage', 'libgbm1', 'libdrm2', 'libxkbcommon0', 'fonts-dejavu-core']: + ensure => installed, + } + + group { $group: + ensure => present, + system => true, + } + + user { $user: + ensure => present, + system => true, + gid => $group, + home => $state_dir, + managehome => false, + shell => '/usr/sbin/nologin', + require => Group[$group], + } + + file { [$install_dir, $state_dir, $log_dir, '/etc/flowercore']: + ensure => directory, + owner => $user, + group => $group, + mode => '0755', + } + + file { '/etc/flowercore/divoom-tv.env': + ensure => file, + owner => 'root', + group => 'root', + mode => '0644', + content => "FC_DIVOOM_TV_PRESENTATION_MODE=${presentation_mode}\nFC_DIVOOM_TV_START_SCENE=${startup_scene}\nFC_DIVOOM_TV_REDUCED_MOTION=${reduced_motion}\n", + require => File['/etc/flowercore'], + } + + $script_map = { + '/usr/local/bin/flowercore-divoom-tv-prelaunch.sh' => 'profile/pi/fc_divoom_tv/flowercore-divoom-tv-prelaunch.sh', + '/usr/local/bin/flowercore-divoom-tv-launch.sh' => 'profile/pi/fc_divoom_tv/flowercore-divoom-tv-launch.sh', + '/usr/local/bin/flowercore-divoom-tv-hdmi-respond.sh' => 'profile/pi/fc_divoom_tv/flowercore-divoom-tv-hdmi-respond.sh', + } + + $script_map.each |$dest, $src| { + file { $dest: + ensure => file, + owner => 'root', + group => 'root', + mode => '0755', + source => "puppet:///modules/${src}", + } + } + + $unit_map = { + "/etc/systemd/system/${service_name}.service" => 'profile/pi/fc_divoom_tv/flowercore-divoom-tv.service', + "/etc/systemd/system/${service_name}-hdmi.service" => 'profile/pi/fc_divoom_tv/flowercore-divoom-tv-hdmi.service', + } + + $unit_map.each |$dest, $src| { + file { $dest: + ensure => file, + owner => 'root', + group => 'root', + mode => '0644', + source => "puppet:///modules/${src}", + notify => Exec['fc-divoom-tv-systemd-reload'], + } + } + + file { '/etc/udev/rules.d/99-flowercore-divoom-tv-hdmi.rules': + ensure => file, + owner => 'root', + group => 'root', + mode => '0644', + source => 'puppet:///modules/profile/pi/fc_divoom_tv/99-flowercore-divoom-tv-hdmi.rules', + notify => Exec['fc-divoom-tv-udev-reload'], + } + + exec { 'fc-divoom-tv-systemd-reload': + command => '/usr/bin/systemctl daemon-reload', + refreshonly => true, + path => ['/usr/bin', '/bin'], + } + + exec { 'fc-divoom-tv-udev-reload': + command => '/usr/bin/udevadm control --reload-rules', + refreshonly => true, + path => ['/usr/bin', '/bin'], + } + + service { $service_name: + ensure => $service_ensure, + enable => $service_enabled, + require => [ + File["/etc/systemd/system/${service_name}.service"], + File['/etc/flowercore/divoom-tv.env'], + File['/usr/local/bin/flowercore-divoom-tv-prelaunch.sh'], + File['/usr/local/bin/flowercore-divoom-tv-launch.sh'], + Exec['fc-divoom-tv-systemd-reload'], + ], + } + } +} diff --git a/apps/fc-divoom-tv-pi/scripts/flowercore-divoom-tv-hdmi-respond.sh b/apps/fc-divoom-tv-pi/scripts/flowercore-divoom-tv-hdmi-respond.sh new file mode 100644 index 0000000..d4b78ac --- /dev/null +++ b/apps/fc-divoom-tv-pi/scripts/flowercore-divoom-tv-hdmi-respond.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +sleep 2 +systemctl restart flowercore-divoom-tv.service diff --git a/apps/fc-divoom-tv-pi/scripts/flowercore-divoom-tv-launch.sh b/apps/fc-divoom-tv-pi/scripts/flowercore-divoom-tv-launch.sh new file mode 100644 index 0000000..d06b10b --- /dev/null +++ b/apps/fc-divoom-tv-pi/scripts/flowercore-divoom-tv-launch.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_BIN="${FC_DIVOOM_TV_BIN:-/opt/flowercore/divoom-tv/FlowerCore.Divoom.Tv}" +STATE_DIR="${FC_DIVOOM_TV_STATE_DIR:-/var/lib/fc-divoom-tv}" +LOG_DIR="${FC_DIVOOM_TV_LOG_DIR:-/var/log/fc-divoom-tv}" +PRESENTATION_MODE="${FC_DIVOOM_TV_PRESENTATION_MODE:-PillarboxSquare}" +START_SCENE="${FC_DIVOOM_TV_START_SCENE:-bluejay-clock}" +REDUCED_MOTION="${FC_DIVOOM_TV_REDUCED_MOTION:-false}" + +COMMON_ARGS=( + "--target=hdmi" + "--presentation-mode=${PRESENTATION_MODE}" + "--startup-scene=${START_SCENE}" + "--reduced-motion=${REDUCED_MOTION}" + "--state-dir=${STATE_DIR}" + "--log-dir=${LOG_DIR}" +) + +if command -v cage >/dev/null 2>&1; then + exec cage -- "${APP_BIN}" "${COMMON_ARGS[@]}" "$@" +fi + +echo "[$(date -Is)] cage not found; launching FlowerCore.Divoom.Tv directly" >&2 +exec "${APP_BIN}" "${COMMON_ARGS[@]}" "$@" diff --git a/apps/fc-divoom-tv-pi/scripts/flowercore-divoom-tv-prelaunch.sh b/apps/fc-divoom-tv-pi/scripts/flowercore-divoom-tv-prelaunch.sh new file mode 100644 index 0000000..8cbf29c --- /dev/null +++ b/apps/fc-divoom-tv-pi/scripts/flowercore-divoom-tv-prelaunch.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_BIN="${FC_DIVOOM_TV_BIN:-/opt/flowercore/divoom-tv/FlowerCore.Divoom.Tv}" +STATE_DIR="${FC_DIVOOM_TV_STATE_DIR:-/var/lib/fc-divoom-tv}" +LOG_DIR="${FC_DIVOOM_TV_LOG_DIR:-/var/log/fc-divoom-tv}" + +mkdir -p "${STATE_DIR}" "${LOG_DIR}" + +if [[ ! -x "${APP_BIN}" ]]; then + echo "[$(date -Is)] missing executable ${APP_BIN}" >&2 + exit 1 +fi + +if [[ -d /sys/class/drm ]] && ! find /sys/class/drm -maxdepth 1 -name 'card*-HDMI-A-*' -print -quit | grep -q .; then + echo "[$(date -Is)] no HDMI connector visible yet; continuing so the app can wait for display" >&2 +fi + +if command -v cage >/dev/null 2>&1; then + echo "[$(date -Is)] cage available for fullscreen Wayland launch" +else + echo "[$(date -Is)] cage not installed; direct launch fallback will be used" >&2 +fi diff --git a/apps/fc-divoom-tv-pi/systemd/99-flowercore-divoom-tv-hdmi.rules b/apps/fc-divoom-tv-pi/systemd/99-flowercore-divoom-tv-hdmi.rules new file mode 100644 index 0000000..ad9e333 --- /dev/null +++ b/apps/fc-divoom-tv-pi/systemd/99-flowercore-divoom-tv-hdmi.rules @@ -0,0 +1,2 @@ +# Settle DRM for 2s before restarting the fullscreen Avalonia renderer. +SUBSYSTEM=="drm", KERNEL=="card?-HDMI-A-?", ACTION=="change", RUN+="/usr/bin/systemctl start flowercore-divoom-tv-hdmi.service" diff --git a/apps/fc-divoom-tv-pi/systemd/flowercore-divoom-tv-hdmi.service b/apps/fc-divoom-tv-pi/systemd/flowercore-divoom-tv-hdmi.service new file mode 100644 index 0000000..3426143 --- /dev/null +++ b/apps/fc-divoom-tv-pi/systemd/flowercore-divoom-tv-hdmi.service @@ -0,0 +1,7 @@ +[Unit] +Description=FlowerCore Divoom TV HDMI hotplug responder +DefaultDependencies=no + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/flowercore-divoom-tv-hdmi-respond.sh diff --git a/apps/fc-divoom-tv-pi/systemd/flowercore-divoom-tv.service b/apps/fc-divoom-tv-pi/systemd/flowercore-divoom-tv.service new file mode 100644 index 0000000..05e166d --- /dev/null +++ b/apps/fc-divoom-tv-pi/systemd/flowercore-divoom-tv.service @@ -0,0 +1,40 @@ +[Unit] +Description=FlowerCore Divoom TV HDMI Renderer (Avalonia fullscreen) +Documentation=https://github.com/astoltz/FlowerCore.Notes/blob/master/docs/standards/divoom-tv-hdmi-multitarget-render-substrate.md +Wants=network-online.target +After=network-online.target systemd-user-sessions.service +ConditionPathExists=/opt/flowercore/divoom-tv/FlowerCore.Divoom.Tv + +[Service] +Type=simple +User=fc-divoom-tv +Group=fc-divoom-tv +WorkingDirectory=/opt/flowercore/divoom-tv +EnvironmentFile=-/etc/flowercore/divoom-tv.env +Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1 +Environment=XDG_RUNTIME_DIR=/run/fc-divoom-tv +RuntimeDirectory=fc-divoom-tv +RuntimeDirectoryMode=0700 +ExecStartPre=/usr/local/bin/flowercore-divoom-tv-prelaunch.sh +ExecStart=/usr/local/bin/flowercore-divoom-tv-launch.sh +Restart=always +RestartSec=10s +StartLimitBurst=5 +StartLimitIntervalSec=300s +MemoryMax=2G +MemoryHigh=1500M +PrivateTmp=true +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/fc-divoom-tv /var/log/fc-divoom-tv /run/fc-divoom-tv +TTYPath=/dev/tty1 +StandardInput=tty +StandardOutput=journal +StandardError=journal +TTYReset=yes +TTYVHangup=yes +TTYVTDisallocate=yes + +[Install] +WantedBy=graphical.target diff --git a/tests/bluejay-infra-lint/DivoomPiDeployArtifactTests.cs b/tests/bluejay-infra-lint/DivoomPiDeployArtifactTests.cs new file mode 100644 index 0000000..a6b3de5 --- /dev/null +++ b/tests/bluejay-infra-lint/DivoomPiDeployArtifactTests.cs @@ -0,0 +1,206 @@ +using FluentAssertions; +using Xunit; + +namespace BluejayInfraLint.Tests; + +[Trait("Category", "Unit")] +public sealed class DivoomPiDeployArtifactTests +{ + private static readonly string Root = FindRepoRoot(); + private static readonly string DmRoot = Path.Combine(Root, "apps", "fc-divoom-dm-pi-device"); + private static readonly string TvRoot = Path.Combine(Root, "apps", "fc-divoom-tv-pi"); + + public static TheoryData DmRequiredArtifacts => new() + { + "README.md", + "hiera/edge2-divoom-dm-device.overlay.yaml", + "puppet/profile/pi/service/divoom_dm_device.pp", + "puppet/templates/divoom-device-registration.json.epp", + "puppet/templates/flowercore-divoom-dm-agent.service.epp", + }; + + public static TheoryData TvRequiredArtifacts => new() + { + "README.md", + "hiera/example-divoom-tv-pi.iamworkin.lan.yaml", + "puppet/profile/pi/service/divoom_tv.pp", + "systemd/flowercore-divoom-tv.service", + "systemd/flowercore-divoom-tv-hdmi.service", + "systemd/99-flowercore-divoom-tv-hdmi.rules", + "scripts/flowercore-divoom-tv-prelaunch.sh", + "scripts/flowercore-divoom-tv-launch.sh", + "scripts/flowercore-divoom-tv-hdmi-respond.sh", + }; + + [Theory] + [MemberData(nameof(DmRequiredArtifacts))] + public void DmDeviceArtifacts_ArePresent(string relativePath) + { + File.Exists(Path.Combine(DmRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))).Should().BeTrue(relativePath); + } + + [Theory] + [MemberData(nameof(TvRequiredArtifacts))] + public void TvPiArtifacts_ArePresent(string relativePath) + { + File.Exists(Path.Combine(TvRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))).Should().BeTrue(relativePath); + } + + [Fact] + public void DmDeviceReadme_DeclaresPuppetSystemdNotKubernetes() + { + var readme = ReadDm("README.md"); + + readme.Should().Contain("not a Kubernetes application"); + readme.Should().Contain("profile::pi::service::divoom"); + readme.Should().Contain("no K8s surface"); + } + + [Fact] + public void DmHieraOverlay_PreservesExistingEdge2DivoomService() + { + var hiera = ReadDm("hiera/edge2-divoom-dm-device.overlay.yaml"); + + hiera.Should().Contain("fc-pimanager:"); + hiera.Should().Contain("fc-divoom:"); + hiera.Should().Contain("enabled: true"); + hiera.Should().Contain("profile::pi::service::divoom_dm_device::service_enabled: false"); + hiera.Should().Contain("profile::pi::service::divoom_dm_device::service_ensure: 'stopped'"); + } + + [Fact] + public void DmPuppetProfile_DefaultsToStoppedDisabledService() + { + var profile = ReadDm("puppet/profile/pi/service/divoom_dm_device.pp"); + + profile.Should().Contain("Boolean $service_enabled = false"); + profile.Should().Contain("Enum['running', 'stopped'] $service_ensure = 'stopped'"); + profile.Should().Contain("service { $service_name:"); + profile.Should().Contain("ensure => $service_ensure"); + profile.Should().Contain("enable => $service_enabled"); + } + + [Fact] + public void DmPuppetProfile_DoesNotManageLiveDivoomWebUnit() + { + var profile = ReadDm("puppet/profile/pi/service/divoom_dm_device.pp"); + + profile.Should().NotContain("Service['flowercore-divoom.service']"); + profile.Should().NotContain("service { 'flowercore-divoom.service'"); + profile.Should().NotContain("notify => Service"); + } + + [Fact] + public void DmAgentUnit_IsSeparateAndGatedByExistingWrappers() + { + var unit = ReadDm("puppet/templates/flowercore-divoom-dm-agent.service.epp"); + + unit.Should().Contain("ConditionPathExists=<%= $divoom_install_dir %>/bt-link.sh"); + unit.Should().Contain("ConditionPathExists=<%= $divoom_install_dir %>/bt-reset.sh"); + unit.Should().Contain("ConditionPathExists=<%= $divoom_install_dir %>/audio-link.sh"); + unit.Should().Contain("ExecStart=<%= $agent_binary_path %> --mode=Pi"); + unit.Should().NotContain("flowercore-divoom.service"); + } + + [Fact] + public void DmRegistration_CarriesRenderProofAndSafetyPolicy() + { + var registration = ReadDm("puppet/templates/divoom-device-registration.json.epp"); + + registration.Should().Contain("\"candidateChannels\": <%= $bt_channels_json %>"); + registration.Should().Contain("\"deviceInfoIsRenderProof\": false"); + registration.Should().Contain("\"visibleRenderProofRequired\": <%= $visible_render_proof_required %>"); + registration.Should().Contain("\"preserveExistingService\": \"flowercore-divoom.service\""); + registration.Should().Contain("\"doNotEnableFmRadio\": true"); + } + + [Fact] + public void TvService_UsesAvaloniaHdmiSafetyGates() + { + var unit = ReadTv("systemd/flowercore-divoom-tv.service"); + + unit.Should().Contain("ConditionPathExists=/opt/flowercore/divoom-tv/FlowerCore.Divoom.Tv"); + unit.Should().Contain("Environment=XDG_RUNTIME_DIR=/run/fc-divoom-tv"); + unit.Should().Contain("RuntimeDirectoryMode=0700"); + unit.Should().Contain("ExecStartPre=/usr/local/bin/flowercore-divoom-tv-prelaunch.sh"); + unit.Should().Contain("ExecStart=/usr/local/bin/flowercore-divoom-tv-launch.sh"); + unit.Should().Contain("MemoryMax=2G"); + unit.Should().Contain("PrivateTmp=true"); + unit.Should().NotContain("/tmp"); + } + + [Fact] + public void TvLauncher_PrefersCageAndFallsBackToDirectLaunch() + { + var script = ReadTv("scripts/flowercore-divoom-tv-launch.sh"); + + script.Should().Contain("command -v cage"); + script.Should().Contain("exec cage --"); + script.Should().Contain("launching FlowerCore.Divoom.Tv directly"); + script.Should().Contain("--target=hdmi"); + script.Should().Contain("--presentation-mode=${PRESENTATION_MODE}"); + } + + [Fact] + public void TvHotplugRule_SettlesAndRestartsRenderer() + { + var rule = ReadTv("systemd/99-flowercore-divoom-tv-hdmi.rules"); + var responder = ReadTv("scripts/flowercore-divoom-tv-hdmi-respond.sh"); + + rule.Should().Contain("KERNEL==\"card?-HDMI-A-?\""); + rule.Should().Contain("start flowercore-divoom-tv-hdmi.service"); + responder.Should().Contain("sleep 2"); + responder.Should().Contain("systemctl restart flowercore-divoom-tv.service"); + } + + [Fact] + public void TvPuppetProfile_InstallsCageAndStaticArtifacts() + { + var profile = ReadTv("puppet/profile/pi/service/divoom_tv.pp"); + + profile.Should().Contain("package { ['cage', 'libgbm1', 'libdrm2', 'libxkbcommon0', 'fonts-dejavu-core']"); + profile.Should().Contain("'profile/pi/fc_divoom_tv/flowercore-divoom-tv.service'"); + profile.Should().Contain("'profile/pi/fc_divoom_tv/flowercore-divoom-tv-launch.sh'"); + profile.Should().Contain("profile/pi/fc_divoom_tv/99-flowercore-divoom-tv-hdmi.rules"); + profile.Should().Contain("Boolean $service_enabled = false"); + } + + [Fact] + public void DivoomArtifacts_DoNotAddKubernetesWorkloads() + { + var allText = Directory.GetFiles(DmRoot, "*", SearchOption.AllDirectories) + .Concat(Directory.GetFiles(TvRoot, "*", SearchOption.AllDirectories)) + .Select(File.ReadAllText); + + foreach (var text in allText) + { + text.Should().NotContain("kind: Deployment"); + text.Should().NotContain("kind: IngressRoute"); + text.Should().NotContain("kind: Certificate"); + text.Should().NotContain("kind: OnePasswordItem"); + } + } + + private static string ReadDm(string relativePath) + => File.ReadAllText(Path.Combine(DmRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))); + + private static string ReadTv(string relativePath) + => File.ReadAllText(Path.Combine(TvRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))); + + private static string FindRepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (Directory.Exists(Path.Combine(current.FullName, "apps")) + && File.Exists(Path.Combine(current.FullName, "README.md"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not find bluejay-infra root."); + } +}