1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 json = pkgs.formats.json { };
9 transitionType = lib.types.submodule {
10 freeformType = json.type;
11 options.type = lib.mkOption {
12 type = lib.types.enum [
13 "delay"
14 "event"
15 "exec"
16 ];
17 description = ''
18 Type of transition. Determines how bonsaid interprets the other options in this transition.
19 '';
20 };
21 options.command = lib.mkOption {
22 type = lib.types.nullOr (lib.types.listOf lib.types.str);
23 default = null;
24 description = ''
25 Command to run when this transition is taken.
26 This is executed inline by `bonsaid` and blocks handling of any other events until completion.
27 To perform the command asynchronously, specify it like `[ "setsid" "-f" "my-command" ]`.
28
29 Only effects transitions with `type = "exec"`.
30 '';
31 };
32 options.delay_duration = lib.mkOption {
33 type = lib.types.nullOr lib.types.int;
34 default = null;
35 description = ''
36 Nanoseconds to wait after the previous state change before performing this transition.
37 This can be placed at the same level as a `type = "event"` transition to achieve a
38 timeout mechanism.
39
40 Only effects transitions with `type = "delay"`.
41 '';
42 };
43 options.event_name = lib.mkOption {
44 type = lib.types.nullOr lib.types.str;
45 default = null;
46 description = ''
47 Name of the event which should trigger this transition when received by `bonsaid`.
48 Events are sent to `bonsaid` by running `bonsaictl -e <event_name>`.
49
50 Only effects transitions with `type = "event"`.
51 '';
52 };
53 options.transitions = lib.mkOption {
54 type = lib.types.listOf transitionType;
55 default = [ ];
56 description = ''
57 List of transitions out of this state.
58 If left empty, then this state is considered a terminal state and entering it will
59 trigger an immediate transition back to the root state (after processing side effects).
60 '';
61 visible = "shallow";
62 };
63 };
64 cfg = config.services.bonsaid;
65in
66{
67 meta.maintainers = [ lib.maintainers.colinsane ];
68
69 options.services.bonsaid = {
70 enable = lib.mkEnableOption "bonsaid";
71 package = lib.mkPackageOption pkgs "bonsai" { };
72 extraFlags = lib.mkOption {
73 type = lib.types.listOf lib.types.str;
74 default = [ ];
75 description = ''
76 Extra flags to pass to `bonsaid`, such as `[ "-v" ]` to enable verbose logging.
77 '';
78 };
79 settings = lib.mkOption {
80 type = lib.types.listOf transitionType;
81 description = ''
82 State transition definitions. See the upstream [README](https://git.sr.ht/~stacyharper/bonsai)
83 for extended documentation and a more complete example.
84 '';
85 example = [
86 {
87 type = "event";
88 event_name = "power_button_pressed";
89 transitions = [
90 {
91 # Hold power button for 600ms to trigger a command
92 type = "delay";
93 delay_duration = 600000000;
94 transitions = [
95 {
96 type = "exec";
97 command = [
98 "swaymsg"
99 "--"
100 "output"
101 "*"
102 "power"
103 "off"
104 ];
105 # `transitions = []` marks this as a terminal state,
106 # so bonsai will return to the root state immediately after executing the above command.
107 transitions = [ ];
108 }
109 ];
110 }
111 {
112 # If the power button is released before the 600ms elapses, return to the root state.
113 type = "event";
114 event_name = "power_button_released";
115 transitions = [ ];
116 }
117 ];
118 }
119 ];
120 };
121 configFile = lib.mkOption {
122 type = lib.types.path;
123 description = ''
124 Path to a .json file specifying the state transitions.
125 You don't need to set this unless you prefer to provide the json file
126 yourself instead of using the `settings` option.
127 '';
128 };
129 };
130
131 config = lib.mkIf cfg.enable {
132 services.bonsaid.configFile =
133 let
134 filterNulls =
135 v:
136 if lib.isAttrs v then
137 lib.mapAttrs (_: filterNulls) (lib.filterAttrs (_: a: a != null) v)
138 else if lib.isList v then
139 lib.map filterNulls (lib.filter (a: a != null) v)
140 else
141 v;
142 in
143 lib.mkDefault (json.generate "bonsai_tree.json" (filterNulls cfg.settings));
144
145 # bonsaid is controlled by bonsaictl, so place the latter in the environment by default.
146 # bonsaictl is typically invoked by scripts or a DE so this isn't strictly necessary,
147 # but it's helpful while administering the service generally.
148 environment.systemPackages = [ cfg.package ];
149
150 systemd.user.services.bonsaid = {
151 description = "Bonsai Finite State Machine daemon";
152 documentation = [ "https://git.sr.ht/~stacyharper/bonsai" ];
153 wantedBy = [ "multi-user.target" ];
154 serviceConfig = {
155 ExecStart = lib.escapeShellArgs (
156 [
157 (lib.getExe' cfg.package "bonsaid")
158 "-t"
159 cfg.configFile
160 ]
161 ++ cfg.extraFlags
162 );
163 Restart = "on-failure";
164 RestartSec = "5s";
165 };
166 };
167 };
168}