Files
home-manager/modules/services/openlinkhub.nix
2026-09-23 23:50:10 -05:00

561 lines
16 KiB
Nix

{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
attrsToList
concatLines
escapeShellArg
filterAttrsRecursive
getExe
hasSuffix
hm
isAttrs
isString
literalExpression
maintainers
makeBinPath
mkEnableOption
mkIf
mkOption
mkPackageOption
optionalAttrs
optionals
optionalString
platforms
types
;
inherit (pkgs)
coreutils
formats
jq
rsync
runCommand
systemd
writeShellScript
writeText
;
optionalValue = cond: value: if cond then value else null;
filterNullOrEmptyAttrsRecursive = filterAttrsRecursive (
_: v:
v != null && v != { } && (if isAttrs v then (filterNullOrEmptyAttrsRecursive v) != { } else true)
);
jsonFormat = formats.json { };
cfg = config.services.openlinkhub;
configDir = "${config.xdg.configHome}/OpenLinkHub";
in
{
meta.maintainers = with maintainers; [ mikaeladev ];
options.services.openlinkhub = {
enable = mkEnableOption "OpenLinkHub";
package = mkPackageOption pkgs "openlinkhub" { };
allowGamepad = mkOption {
type = types.bool;
default = false;
example = true;
description = ''
Whether to allow OpenLinkHub's virtual gamepad to be enabled.
Setting this to `true` will silence the associated warning, but it will
not grant the required udev permissions. It is up to you as the user to
supply safe access.
'';
};
settings =
let
settingsModule = types.submodule {
freeformType = jsonFormat.type;
options = {
# required options #
listenAddress = mkOption {
type = types.str;
default = "127.0.0.1";
example = "127.0.0.2";
description = ''
Which local address the dashboard should listen from.
'';
};
listenPort = mkOption {
type = types.ints.between 1 65535;
default = 27003;
example = 27004;
description = ''
Which port the dashboard should listen from.
'';
};
# read-only options #
frontend = mkOption {
type = types.bool;
readOnly = true;
description = ''
Whether to enable the dashboard.
This is set by
{option}`config.services.openlinkhub.dashboard.enable`.
'';
};
memory = mkOption {
type = types.bool;
readOnly = true;
description = ''
Whether to enable the memory configuration.
This is set by
{option}`config.services.openlinkhub.memory.enable`.
'';
};
memorySku = mkOption {
type = with types; nullOr str;
readOnly = true;
description = ''
The SKU of the device.
This is set by
{option}`config.services.openlinkhub.memory.sku`.
'';
};
memorySmBus = mkOption {
type = with types; nullOr str;
readOnly = true;
description = ''
The SMBus controller for the device.
This is set by
{option}`config.services.openlinkhub.memory.smb`.
'';
};
memoryType = mkOption {
type = with types; nullOr (ints.between 4 5);
readOnly = true;
description = ''
Which generation the device belongs to.
This is set by
{option}`config.services.openlinkhub.memory.type`.
'';
};
};
};
in
mkOption {
type = settingsModule;
default = { };
example = {
debug = false;
logLevel = "info";
metrics = false;
};
description = ''
Configuration to be merged into {file}`config.json`.
This provides the default value of
{option}`config.services.openlinkhub.extraConfigs."config.json"`.
'';
};
dashboard = {
enable = mkEnableOption "the dashboard";
settings = mkOption {
inherit (jsonFormat) type;
default = { };
example = {
celsius = true;
languageCode = "en_US";
pageTitle = "OpenLinkHub WebUI";
};
description = ''
Dashboard configuration to be merged into {file}`dashboard.json`.
This provides the default value of
{option}`config.services.openlinkhub.extraConfigs."dashboard.json"`.
'';
};
};
memory = {
enable = mkEnableOption "memory configuration";
sku = mkOption {
type = with types; nullOr str;
default = null;
example = "CMT64GX5M2B5600Z40";
description = ''
The SKU of the device.
You can find this by running `sudo dmidecode -t memory` and grepping
by 'Part Number'.
See the OpenLinkHub [docs] for more information.
[docs]: https://github.com/jurkovic-nikola/OpenLinkHub/blob/main/docs/memory-configuration.md
'';
};
smb = mkOption {
type = with types; nullOr (strMatching "i2c-[[:digit:]]+");
default = null;
example = "i2c-0";
description = ''
The SMBus controller for the device.
You can find this by running `i2cdetect -l` and grepping by 'SMBus',
with it typically being the first device from the list.
See the OpenLinkHub [docs] for more information.
[docs]: https://github.com/jurkovic-nikola/OpenLinkHub/blob/main/docs/memory-configuration.md
'';
};
type = mkOption {
type = with types; nullOr (ints.between 4 5);
default = null;
example = 5;
description = ''
Which generation the device belongs to.
You can find this by running `sudo dmidecode -t memory` and grepping
by 'Type: DDR[4-5]'.
See the OpenLinkHub [docs] for a full list of supported devices and
their generations.
[docs]: https://github.com/jurkovic-nikola/OpenLinkHub/blob/main/docs/supported-devices.md
'';
};
};
extraConfigs =
let
jsonOrLines = with types; attrsOf (either jsonFormat.type lines);
extraConfigsModule = types.submodule {
freeformType = jsonOrLines;
options = {
"config.json" = mkOption {
type = jsonOrLines;
default = cfg.settings;
defaultText = "config.services.openlinkhub.settings";
description = ''
Configuration to be written to {file}`config.json`.
Setting this value directly will override all values inherited
from {option}`config.services.openlinkhub.settings`, including any
values that `settings` inherited from other options in the module.
'';
};
"dashboard.json" = mkOption {
type = types.nullOr jsonOrLines;
default = with cfg.dashboard; optionalAttrs enable settings;
defaultText = literalExpression ''
with config.services.openlinkhub.dashboard;
lib.optionalAttrs enable settings;
'';
description = ''
Configuration to be written to {file}`dashboard.json`.
Setting this value directly will override all values inherited
from {option}`config.services.openlinkhub.dashboard.settings`.
'';
};
};
};
in
mkOption {
type = extraConfigsModule;
apply = filterNullOrEmptyAttrsRecursive;
default = { };
example = literalExpression ''
{
# merges with existing `i2c0.json`
"database/profiles/i2c0.json" = {
Active = true;
Path = "/run/user/1000/OpenLinkHub/database/profiles/i2c0.json";
Product = "Memory";
Serial = "i2c0";
# ...
};
# overwrites existing `display.json`
"display.json" = '''
[
{
"Index": 1,
"Name": "card1-DP-1",
"Width": 2560,
"Height": 1440,
"Left": false,
"Top": false
}
]
''';
}
'';
description = ''
Additional files to be written to {file}`$XDG_CONFIG_HOME/OpenLinkHub`.
Content generated from an attrset will be merged, while other content,
such as lists and lines, will be written as is, overwriting any previous
data.
Null values and empty attrsets will be recursively filtered out.
'';
};
};
config = mkIf cfg.enable {
assertions = [
(hm.assertions.assertPlatform "services.openlinkhub" pkgs platforms.linux)
]
++ (
let
assertMemoryOptionNotNull = opt: {
assertion = cfg.memory.${opt} != null;
message = ''
The option `services.openlinkhub.memory.${opt}` must not be null when
`services.openlinkhub.memory.enable` is set to true.
'';
};
in
optionals (cfg.memory.enable) [
(assertMemoryOptionNotNull "sku")
(assertMemoryOptionNotNull "smb")
(assertMemoryOptionNotNull "type")
]
);
warnings = optionals (!cfg.allowGamepad && ((cfg.settings or { }).enableGamepad or false)) [
''
The option `services.openlinkhub.settings.enableGamepad` requires that
OpenLinkHub have access to uinput devices, which may compromise system security.
The Home Manager-supplied udev rule does not include this permission, and thus
it is up to you to supply safe access.
If provided, you can silence this warning by setting:
services.openlinkhub.allowGamepad = true;
''
];
services.openlinkhub.settings = {
frontend = cfg.dashboard.enable;
memory = cfg.memory.enable;
memorySku = optionalValue cfg.memory.enable cfg.memory.sku;
memorySmBus = optionalValue cfg.memory.enable cfg.memory.smb;
memoryType = optionalValue cfg.memory.enable cfg.memory.type;
};
systemd.user = {
services.OpenLinkHub = {
Unit = {
After = [ "default.target" ];
Description = cfg.package.meta.description;
StartLimitIntervalSec = 60;
StartLimitBurst = 5;
};
Service = {
ExecStart = getExe cfg.package;
ExecReload = "${coreutils}/bin/kill -s HUP $MAINPID";
WorkingDirectory = "%t/OpenLinkHub";
Restart = "always";
RestartSec = 5;
};
Install = {
WantedBy = [ "default.target" ];
};
};
tmpfiles.rules = [
"D %t/OpenLinkHub - - - - -"
"L+ %t/OpenLinkHub/static - - - - ${cfg.package}/opt/OpenLinkHub/static"
"L+ %t/OpenLinkHub/web - - - - ${cfg.package}/opt/OpenLinkHub/web"
"L+ %t/OpenLinkHub/database - - - - ${configDir}/database"
"L+ %t/OpenLinkHub/config.json - - - - ${configDir}/config.json"
"L+ %t/OpenLinkHub/dashboard.json - - - - ${configDir}/dashboard.json"
"L+ %t/OpenLinkHub/display.json - - - - ${configDir}/display.json"
];
};
home.activation = {
checkOpenLinkHubSetup =
let
udevRuleDir = "/etc/udev/rules.d";
udevRuleName = "70-openlinkhub.rules";
udevRule = runCommand udevRuleName { } ''
cp ${cfg.package}${udevRuleDir}/99-openlinkhub.rules $out
sed -i '/^KERNEL=="uinput"/d' $out
substituteInPlace $out --replace-fail \
'OWNER="openlinkhub"' 'TAG+="uaccess"'
${optionalString cfg.memory.enable ''
echo 'KERNEL=="${cfg.memory.smb}", MODE="0660", TAG+="uaccess"' \
>> $out
''}
'';
setupScript = writeShellScript "openlinkhub-setup" ''
set -euo pipefail
PATH="${makeBinPath [ systemd ]}''${PATH:+:}$PATH"
echo "Linking udev rule..."
sudo mkdir -p ${udevRuleDir}
sudo ln -fs ${udevRule} ${udevRuleDir}/${udevRuleName}
sudo ln -fs ${udevRuleDir}/${udevRuleName} \
''${NIX_STATE_DIR:-/nix/var/nix}/gcroots/${udevRuleName}
echo "Reloading udev rules..."
sudo udevadm control --reload-rules
sudo udevadm trigger
echo "Restarting OpenLinkHub..."
systemctl --user restart OpenLinkHub.service
echo "Success!"
'';
in
mkIf (config.targets.genericLinux.enable && (!cfg.memory.enable || cfg.memory.smb != null)) (
hm.dag.entryAnywhere ''
if [ ! -L ${udevRuleDir}/${udevRuleName} ]; then
warnEcho "To finish setting up OpenLinkHub, run"
warnEcho " ${setupScript}"
elif ! cmp -s ${udevRuleDir}/${udevRuleName} ${udevRule}; then
warnEcho "OpenLinkHub requires an update, run"
warnEcho " ${setupScript}"
fi
''
);
configureOpenLinkHub =
let
concatMapLines = f: list: concatLines (map f list);
withSuffix = suffix: value: if (hasSuffix suffix value) then value else (value + suffix);
generatedLines = concatMapLines (
{ name, value }:
let
writerFn = if isString value then writeText else jsonFormat.generate;
bashFn = if isAttrs value then "mergeConfig" else "writeConfig";
configPath = configDir + "/" + (withSuffix ".json" name);
configFile = writerFn (baseNameOf configPath) value;
in
"run ${bashFn} '${configFile}' '${configPath}'"
) (attrsToList cfg.extraConfigs);
in
hm.dag.entryAfter [ "writeBoundary" ] ''
writeConfig() {
if [ -e "$2" ]; then
if ! cmp -s "$1" "$2"; then
OPENLINKHUB_RELOAD=1
cat "$1" > "$2"
fi
else
OPENLINKHUB_RELOAD=1
mkdir -p "$(dirname "$2")"
cat "$1" > "$2"
fi
}
mergeConfig() {
if [ -e "$2" ]; then
local tempFile
tempFile=$(mktemp)
if ${getExe jq} -sS 'add' "$2" "$1" > "$tempFile" &&
writeConfig "$tempFile" "$2"; then
rm -f $tempFile
else
rm -f $tempFile
exit 1
fi
else
writeConfig "$1" "$2"
fi
}
seedDatabase() {
local source=${escapeShellArg cfg.package + "/opt/OpenLinkHub/database/"}
local dest=${escapeShellArg configDir + "/database"}
local output
output="$(${getExe rsync} -ai --chmod=D755,F644 --ignore-existing "$source" "$dest")"
if [ -n "$output" ]; then
OPENLINKHUB_RELOAD=1
fi
}
run mkdir -p "${configDir}/database"
run seedDatabase
${generatedLines}
unset -f writeConfig mergeConfig seedDatabase
'';
reloadOpenLinkHub =
let
systemdCfg = config.systemd.user;
ensureSystemd = ''
env XDG_RUNTIME_DIR="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" \
PATH="${dirOf systemdCfg.systemctlPath}:$PATH" \
'';
systemctl = "${ensureSystemd} systemctl";
in
mkIf systemdCfg.enable (
hm.dag.entryAfter [ "configureOpenLinkHub" "reloadSystemd" ] ''
if [[ -v OPENLINKHUB_RELOAD ]]; then
if [[ -v DRY_RUN ]]; then
echo 'systemctl --user restart OpenLinkHub.service'
else
systemdStatus=$(${systemctl} --user is-system-running 2>&1 || true)
if [[ $systemdStatus == 'running' || $systemdStatus == 'degraded' ]]; then
${systemctl} --user restart OpenLinkHub.service
else
echo "User systemd daemon not running. Skipping OpenLinkHub reload."
fi
unset -v systemdStatus
fi
unset -v OPENLINKHUB_RELOAD
fi
''
);
};
};
}