1{ lib, config, pkgs, ... }:
2
3with lib;
4let
5 cfg = config.modules.automation;
6
7 format = pkgs.formats.json { };
8 configFile = format.generate "zigbee2mqtt.yaml" cfg.homebridge.settings;
9
10 packageJson = builtins.fromJSON (builtins.readFile ./package.json);
11 pluginsDefault = lists.remove "homebridge" (attrNames packageJson.dependencies);
12
13 homebridge-distribution = pkgs.stdenvNoCC.mkDerivation rec {
14 name = "homebridge-distribution";
15 src = ./.;
16 offlineCache = pkgs.fetchYarnDeps {
17 yarnLock = src + "/yarn.lock";
18 hash = "sha256-a4zkEr/v6ZBTXS6Q5oij5G0zsGh1QCGa8/5Do0C/inM=";
19 };
20 strictDeps = true;
21 nativeBuildInputs = with pkgs; [
22 yarnConfigHook
23 yarnInstallHook
24 nodejs
25 ];
26 };
27
28 defaultConfigPlatform = {
29 platform = "config";
30 name = "Config";
31 auth = "none";
32 port = cfg.homebridge.frontend.port;
33 disableServerMetricsMonitoring = true;
34 homebridgePackagePath = "${cfg.homebridge.userStoragePath}/node_modules/homebridge";
35 standalone = true;
36 sudo = false;
37 log = {
38 method = "systemd";
39 service = "homebridge";
40 };
41 };
42
43 defaultServiceConfig = {
44 Type = "simple";
45 User = "homebridge";
46 Group = "homebridge";
47 PermissionsStartOnly = true;
48 WorkingDirectory = cfg.homebridge.userStoragePath;
49 Restart = "always";
50 RestartSec = 3;
51 KillMode = "process";
52 CapabilityBoundingSet = [ "CAP_IPC_LOCK" "CAP_NET_ADMIN" "CAP_NET_BIND_SERVICE" "CAP_NET_RAW" "CAP_SETGID" "CAP_SETUID" "CAP_SYS_CHROOT" "CAP_CHOWN" "CAP_FOWNER" "CAP_DAC_OVERRIDE" "CAP_AUDIT_WRITE" "CAP_SYS_ADMIN" ];
53 AmbientCapabilities = [ "CAP_NET_RAW" "CAP_NET_BIND_SERVICE" ];
54 };
55
56 defaultArgs = [
57 "-U ${cfg.homebridge.userStoragePath}"
58 "-P ${cfg.homebridge.pluginPath}"
59 "--strict-plugin-resolution"
60 ] ++ optionals cfg.homebridge.frontend.enable ["-I"];
61
62 frontendType = types.submodule {
63 options = {
64 enable = mkOption {
65 default = false;
66 example = true;
67 description = "Whether to enable Homebridge's frontend.";
68 type = types.bool;
69 };
70 port = mkOption {
71 default = 8125;
72 example = 8125;
73 description = "The port to use for Homebridge's frontend.";
74 type = types.port;
75 };
76 };
77 };
78
79 bridgeType = types.submodule {
80 options = {
81 name = mkOption {
82 default = "Homebridge";
83 example = "Homebridge name";
84 type = types.str;
85 };
86 username = mkOption {
87 default = "CC:22:3D:E3:CE:30";
88 type = types.str;
89 };
90 pin = mkOption {
91 default = "031-45-154";
92 type = types.str;
93 };
94 port = mkOption {
95 default = 51826;
96 type = types.port;
97 };
98 advertiser = mkOption {
99 default = if config.services.resolved.enable then "resolved" else "ciao";
100 type = types.str;
101 };
102 bind = mkOption {
103 default = lists.remove "lo" config.networking.firewall.trustedInterfaces;
104 type = types.listOf types.str;
105 };
106 };
107 };
108in {
109 options.modules.automation.homebridge = {
110 enable = mkOption {
111 default = false;
112 example = true;
113 description = "Whether to enable Homebridge service.";
114 type = types.bool;
115 };
116
117 plugins = mkOption {
118 default = pluginsDefault;
119 description = "Names of package installed in the homebridge-distribution.";
120 type = types.listOf types.str;
121 };
122
123 frontend = mkOption {
124 default = {};
125 type = frontendType;
126 };
127
128 bridge = mkOption {
129 default = {};
130 type = bridgeType;
131 };
132
133 settings = mkOption {
134 type = format.type;
135 default = { };
136 };
137
138 userStoragePath = mkOption {
139 default = "/var/lib/homebridge";
140 description = "Path to store homebridge user files (needs to be writeable).";
141 type = types.str;
142 };
143
144 pluginPath = mkOption {
145 default = "${cfg.homebridge.userStoragePath}/node_modules";
146 type = types.str;
147 };
148 };
149
150 config = mkIf (cfg.enable && cfg.homebridge.enable) {
151 modules.automation.homebridge.settings = {
152 description = mkDefault "Homebridge";
153 bridge = mkForce cfg.homebridge.bridge;
154 platforms = [defaultConfigPlatform]
155 ++ optionals (cfg.zigbee.enable && cfg.mqtt.enable) [
156 {
157 platform = "zigbee2mqtt";
158 mqtt = {
159 base_topic = "zigbee2mqtt";
160 server = "mqtts://localhost:${toString cfg.mqtt.port}";
161 disable_qos = true;
162 reject_unauthorized = false;
163 ca = cfg.mqtt.cafile;
164 key = cfg.mqtt.keyfile;
165 cert = cfg.mqtt.certfile;
166 };
167 defaults.exclude = false;
168 exclude_grouped_devices = false;
169 }
170 ];
171 };
172
173 systemd.services.homebridge = {
174 description = "Homebridge";
175 after = [ "syslog.target" "network.target" ]
176 ++ optionals cfg.mqtt.enable [config.systemd.services.mosquitto.name];
177 wants = optionals cfg.mqtt.enable [config.systemd.services.mosquitto.name];
178 wantedBy = [ "multi-user.target" ];
179
180 serviceConfig = let
181 args = concatStringsSep " " defaultArgs;
182 in defaultServiceConfig // {
183 ExecStart = "${homebridge-distribution}/bin/homebridge ${args}";
184 };
185
186 preStart = let
187 inherit (cfg.homebridge) pluginPath userStoragePath plugins;
188 lnPlugins = concatStringsSep "\n" (map
189 (name: ''
190 ln -fns "${homebridge-distribution}/lib/node_modules/homebridge-distribution/node_modules/${name}" "${pluginPath}/${name}"
191 '')
192 plugins);
193 in ''
194 mkdir -p ${pluginPath}
195 ${lnPlugins}
196 cp --no-preserve=mode ${configFile} "${userStoragePath}/config.json"
197 chown homebridge "${userStoragePath}/config.json" "${pluginPath}"
198 chgrp homebridge "${userStoragePath}/config.json" "${pluginPath}"
199 '';
200 };
201
202 systemd.services.homebridge-frontend = mkIf cfg.homebridge.frontend.enable {
203 description = "Homebridge Frontend";
204 after = [ "syslog.target" "network.target" config.systemd.services.homebridge.name ];
205 requires = [ config.systemd.services.homebridge.name ];
206 wantedBy = [ "multi-user.target" ];
207 path = with pkgs; [
208 nodejs_20
209 nettools
210 gcc
211 gnumake
212 systemd
213 "/run/wrappers"
214 ];
215
216 environment = {
217 HOMEBRIDGE_CONFIG_UI_TERMINAL = "1";
218 DISABLE_OPENCOLLECTIVE = "true";
219 UIX_STRICT_PLUGIN_RESOLUTION = "1";
220 };
221
222 serviceConfig = let
223 args = concatStringsSep " " defaultArgs;
224 in defaultServiceConfig // {
225 ExecStart = "${homebridge-distribution}/bin/homebridge-config-ui ${args}";
226 };
227 };
228
229 users = {
230 groups.homebridge = {};
231 users.homebridge = {
232 home = cfg.homebridge.userStoragePath;
233 createHome = true;
234 group = "homebridge";
235 extraGroups = [ "systemd-journal" ] ++ optionals cfg.mqtt.enable [config.users.users.mosquitto.name];
236 isSystemUser = true;
237 };
238 };
239
240 security.polkit.extraConfig = optionalString (cfg.homebridge.bridge.advertiser == "resolved") ''
241 // kitten/system: Allow homebridge to register systemd-resolved services
242 // This was enabled via modules.automation.homebridge
243 polkit.addRule(function(action, subject) {
244 if ((action.id == "org.freedesktop.resolve1.register-service" ||
245 action.id == "org.freedesktop.resolve1.unregister-service") &&
246 subject.user == "${config.users.users.homebridge.name}") {
247 return polkit.Result.YES;
248 }
249 });
250 '';
251 };
252}