Lines
99.41%
342 / 344
Functions and Methods
91.66%
22 / 24
Classes and Traits
0.00%
0 / 1
| Name | Lines | Functions and Methods | CRAP | Classes and Traits | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| StewartConfigSchema | 99.41% | 342 / 344 | 91.66% | 22 / 24 | 27 | 0.00% | 0 / 1 | |||
| isOff | 0.00% | 0 / 1 | 0.00% | 0 / 1 | 2 | |||||
| getConfigTreeBuilder | 100.00% | 16 / 16 | 100.00% | 1 / 1 | 1 | |||||
| buildConfigTree | 0.00% | 0 / 1 | 0.00% | 0 / 1 | 2 | |||||
| addTopLevelSettings | 100.00% | 34 / 34 | 100.00% | 1 / 1 | 1 | |||||
| requireKeysMatching | 100.00% | 9 / 9 | 100.00% | 1 / 1 | 1 | |||||
| createDurationNode | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | |||||
| appendBackoffNodes | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | |||||
| createScalarNode | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | |||||
| addHomeAssistantSection | 100.00% | 24 / 24 | 100.00% | 1 / 1 | 1 | |||||
| addReconnectSection | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 1 | |||||
| addSupervisionSection | 100.00% | 22 / 22 | 100.00% | 1 / 1 | 1 | |||||
| addPersistenceSection | 100.00% | 13 / 13 | 100.00% | 1 / 1 | 1 | |||||
| addMqttSection | 100.00% | 38 / 38 | 100.00% | 1 / 1 | 1 | |||||
| addControlSection | 100.00% | 14 / 14 | 100.00% | 1 / 1 | 1 | |||||
| addHttpSection | 100.00% | 21 / 21 | 100.00% | 1 / 1 | 1 | |||||
| addCodegenSection | 100.00% | 14 / 14 | 100.00% | 1 / 1 | 1 | |||||
| addDeploySection | 100.00% | 16 / 16 | 100.00% | 1 / 1 | 1 | |||||
| addExposeSection | 100.00% | 15 / 15 | 100.00% | 1 / 1 | 1 | |||||
| createWebsocketUrlNode | 100.00% | 10 / 10 | 100.00% | 1 / 1 | 1 | |||||
| createSizeLimitNode | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | |||||
| createPatternListNode | 100.00% | 7 / 7 | 100.00% | 1 / 1 | 1 | |||||
| createDomainAttributesNode | 100.00% | 11 / 11 | 100.00% | 1 / 1 | 1 | |||||
| addServiceCallsSection | 100.00% | 23 / 23 | 100.00% | 1 / 1 | 1 | |||||
| addAppsSection | 100.00% | 37 / 37 | 100.00% | 1 / 1 | 4 | |||||
| 1 | <?php | |
| 2 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace Stewart\Runtime\Config; | |
| 6 | ||
| 7 | use Closure; | |
| 8 | use InvalidArgumentException; | |
| 9 | use Stewart\Client\Connection\HomeAssistantUrl; | |
| 10 | use Stewart\Contracts\App\AppId; | |
| 11 | use Stewart\Runtime\Logging\LogFormat; | |
| 12 | use Stewart\Runtime\Model\LogLevel; | |
| 13 | use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; | |
| 14 | use Symfony\Component\Config\Definition\Builder\IntegerNodeDefinition; | |
| 15 | use Symfony\Component\Config\Definition\Builder\NodeBuilder; | |
| 16 | use Symfony\Component\Config\Definition\Builder\ScalarNodeDefinition; | |
| 17 | use Symfony\Component\Config\Definition\Builder\TreeBuilder; | |
| 18 | use Symfony\Component\Config\Definition\ConfigurationInterface; | |
| 19 | use Symfony\Component\Config\Definition\NodeInterface; | |
| 20 | ||
| 21 | final class StewartConfigSchema implements ConfigurationInterface | |
| 22 | { | |
| 23 | public const string ROOT = 'stewart'; | |
| 24 | ||
| 25 | public const string OFF = 'off'; | |
| 26 | ||
| 27 | ||
| 28 | private const int DEFAULT_SIZE_LIMIT_BYTES = 64 * 1024 * 1024; | |
| 29 | ||
| 30 | private const string DOMAIN_KEY = '/\A[a-z0-9_]+\z/'; | |
| 31 | ||
| 32 | public static function isOff(string $value): bool | |
| 33 | { | |
| 34 | return strcasecmp(trim($value), self::OFF) === 0; | |
| 35 | } | |
| 36 | ||
| 37 | public function getConfigTreeBuilder(): TreeBuilder | |
| 38 | { | |
| 39 | $treeBuilder = new TreeBuilder(self::ROOT); | |
| 40 | $root = $treeBuilder->getRootNode(); | |
| 41 | ||
| 42 | $this->addTopLevelSettings($root); | |
| 43 | $this->addHomeAssistantSection($root); | |
| 44 | $this->addReconnectSection($root); | |
| 45 | $this->addSupervisionSection($root); | |
| 46 | $this->addServiceCallsSection($root); | |
| 47 | $this->addPersistenceSection($root); | |
| 48 | $this->addMqttSection($root); | |
| 49 | $this->addControlSection($root); | |
| 50 | $this->addHttpSection($root); | |
| 51 | $this->addCodegenSection($root); | |
| 52 | $this->addDeploySection($root); | |
| 53 | $this->addExposeSection($root); | |
| 54 | $this->addAppsSection($root); | |
| 55 | ||
| 56 | return $treeBuilder; | |
| 57 | } | |
| 58 | ||
| 59 | public function buildConfigTree(): NodeInterface | |
| 60 | { | |
| 61 | return $this->getConfigTreeBuilder()->buildTree(); | |
| 62 | } | |
| 63 | ||
| 64 | private function addTopLevelSettings(ArrayNodeDefinition $root): void | |
| 65 | { | |
| 66 | $root | |
| 67 | ->children() | |
| 68 | ->integerNode('workers') | |
| 69 | ->info('Worker processes to run. 0 means min(4, cpu count).') | |
| 70 | ->min(0) | |
| 71 | ->defaultValue(0) | |
| 72 | ->end() | |
| 73 | ->enumNode('log_level') | |
| 74 | ->info('How much the daemon says.') | |
| 75 | ->values(array_map(static fn(LogLevel $level): string => $level->value, LogLevel::configurable())) | |
| 76 | ->defaultValue(LogLevel::Info->value) | |
| 77 | ->end() | |
| 78 | ->enumNode('log_format') | |
| 79 | ->info('How log lines are written: line for a terminal, json for a log collector. --json-logs overrides it.') | |
| 80 | ->values(array_map(static fn(LogFormat $format): string => $format->value, LogFormat::cases())) | |
| 81 | ->defaultValue(LogFormat::Line->value) | |
| 82 | ->end() | |
| 83 | ->append($this->createDurationNode('shutdown_grace', 'How long a worker gets to dispose its apps before it is killed.', '5s')) | |
| 84 | ->integerNode('worker_event_buffer') | |
| 85 | ->info('Topic messages and Home Assistant events buffered per worker, together up to this many, before the oldest are dropped.') | |
| 86 | ->min(1) | |
| 87 | ->defaultValue(1000) | |
| 88 | ->end() | |
| 89 | ->integerNode('worker_state_batch') | |
| 90 | ->info('State changes sent to a worker in one message at most.') | |
| 91 | ->min(1) | |
| 92 | ->defaultValue(256) | |
| 93 | ->end() | |
| 94 | ->integerNode('subscription_buffer') | |
| 95 | ->info('Events queued per subscription while its handler is busy, before queued events are dropped, superseded state changes first. A burst of state changes from Home Assistant is always queued whole.') | |
| 96 | ->min(1) | |
| 97 | ->defaultValue(100) | |
| 98 | ->end() | |
| 99 | ->end(); | |
| 100 | } | |
| 101 | ||
| 102 | /** @param Closure(string): bool $accepts */ | |
| 103 | private function requireKeysMatching(ArrayNodeDefinition $node, Closure $accepts, string $expected): ArrayNodeDefinition | |
| 104 | { | |
| 105 | $node | |
| 106 | ->validate() | |
| 107 | ->ifTrue(static fn(array $map): bool => array_any(array_keys($map), static fn(int|string $key): bool => !$accepts((string) $key))) | |
| 108 | ->then(static function (array $map) use ($accepts, $expected): never { | |
| 109 | $bad = array_find(array_keys($map), static fn(int|string $key): bool => !$accepts((string) $key)); | |
| 110 | ||
| 111 | throw new InvalidArgumentException(\sprintf('"%s" is not %s.', $bad, $expected)); | |
| 112 | }) | |
| 113 | ->end(); | |
| 114 | ||
| 115 | return $node; | |
| 116 | } | |
| 117 | ||
| 118 | private function createDurationNode(string $name, string $info, string $default): ScalarNodeDefinition | |
| 119 | { | |
| 120 | $node = new ScalarNodeDefinition($name); | |
| 121 | $node->info($info)->defaultValue($default); | |
| 122 | ||
| 123 | return $node; | |
| 124 | } | |
| 125 | ||
| 126 | private function appendBackoffNodes(NodeBuilder $children, string $keyPrefix, string $firstAttempt, string $maxDelay): NodeBuilder | |
| 127 | { | |
| 128 | return $children | |
| 129 | ->append($this->createDurationNode($keyPrefix . 'initial_delay', \sprintf('Wait before the first %s. Doubles per attempt.', $firstAttempt), '1s')) | |
| 130 | ->append($this->createDurationNode($keyPrefix . 'max_delay', 'Ceiling for the doubling.', $maxDelay)); | |
| 131 | } | |
| 132 | ||
| 133 | private function createScalarNode(string $name, string $info, ?string $default): ScalarNodeDefinition | |
| 134 | { | |
| 135 | $node = new ScalarNodeDefinition($name); | |
| 136 | $node->info($info)->defaultValue($default); | |
| 137 | ||
| 138 | return $node; | |
| 139 | } | |
| 140 | ||
| 141 | private function addHomeAssistantSection(ArrayNodeDefinition $root): void | |
| 142 | { | |
| 143 | $root | |
| 144 | ->children() | |
| 145 | ->arrayNode('home_assistant') | |
| 146 | ->info('The instance to connect to. `run` and `generate` need it. The token is a secret: set it from the environment.') | |
| 147 | ->children() | |
| 148 | ->append($this->createWebsocketUrlNode()) | |
| 149 | ->scalarNode('token') | |
| 150 | ->info('A long-lived access token: Home Assistant, Profile -> Security.') | |
| 151 | ->isRequired() | |
| 152 | ->cannotBeEmpty() | |
| 153 | ->end() | |
| 154 | ->append($this->createDurationNode('connect_timeout', 'How long opening the websocket and authenticating may take.', '10s')) | |
| 155 | ->append($this->createDurationNode('command_timeout', 'How long Home Assistant may take to answer one command.', '30s')) | |
| 156 | ->append($this->createSizeLimitNode('message_size_limit', 'A full get_states for a large instance is several megabytes in one message.')) | |
| 157 | ->append($this->createSizeLimitNode('frame_size_limit', 'The frame limit bites before the message limit, and looks like a disconnect.')) | |
| 158 | ->append($this->createDurationNode('heartbeat_interval', 'Time between websocket pings that detect a silently dead connection. Rounded to whole seconds.', '10s')) | |
| 159 | ->integerNode('heartbeat_missed_limit') | |
| 160 | ->info('Unanswered pings before the connection is treated as lost.') | |
| 161 | ->min(1) | |
| 162 | ->defaultValue(3) | |
| 163 | ->end() | |
| 164 | ->end() | |
| 165 | ->end() | |
| 166 | ->end(); | |
| 167 | } | |
| 168 | ||
| 169 | private function addReconnectSection(ArrayNodeDefinition $root): void | |
| 170 | { | |
| 171 | $reconnect = $root | |
| 172 | ->children() | |
| 173 | ->arrayNode('reconnect') | |
| 174 | ->info('How long the broker waits between attempts to reach Home Assistant again. It never stops trying.') | |
| 175 | ->addDefaultsIfNotSet(); | |
| 176 | ||
| 177 | $this->appendBackoffNodes($reconnect->children(), '', 'retry', '60s'); | |
| 178 | } | |
| 179 | ||
| 180 | private function addSupervisionSection(ArrayNodeDefinition $root): void | |
| 181 | { | |
| 182 | $supervision = $root | |
| 183 | ->children() | |
| 184 | ->arrayNode('supervision') | |
| 185 | ->info('What the broker does about workers that die or stop responding.') | |
| 186 | ->addDefaultsIfNotSet(); | |
| 187 | ||
| 188 | $children = $supervision->children() | |
| 189 | ->integerNode('restart_attempts') | |
| 190 | ->info('Restarts allowed inside the window before a worker is quarantined and its apps stay down.') | |
| 191 | ->min(0) | |
| 192 | ->defaultValue(5) | |
| 193 | ->end() | |
| 194 | ->append($this->createDurationNode('restart_window', 'Span the restart count is measured over.', '60s')); | |
| 195 | ||
| 196 | $this->appendBackoffNodes($children, 'restart_', 'restart of a worker', '30s') | |
| 197 | ->append($this->createDurationNode('ping_interval', 'Time between liveness probes. "off" disables the watchdog.', '10s')) | |
| 198 | ->append($this->createDurationNode('lag_threshold', 'Round-trip lag before a worker is reported as falling behind.', '500ms')) | |
| 199 | ->integerNode('unresponsive_after') | |
| 200 | ->info('Missed liveness probes before a worker is killed and restarted. 0 only logs.') | |
| 201 | ->min(0) | |
| 202 | ->defaultValue(3) | |
| 203 | ->end() | |
| 204 | ->append($this->createDurationNode('initialize_timeout', 'Time an app gets in initialize() before it is dropped; apps initialize concurrently. "off" waits forever.', '60s')) | |
| 205 | ->append($this->createDurationNode('ready_timeout', 'Time a worker gets to report its apps ready before it is restarted; keep it above initialize_timeout. "off" disables.', '5m')); | |
| 206 | } | |
| 207 | ||
| 208 | private function addPersistenceSection(ArrayNodeDefinition $root): void | |
| 209 | { | |
| 210 | $root | |
| 211 | ->children() | |
| 212 | ->arrayNode('persistence') | |
| 213 | ->info('Where apps keep what must survive a restart. Without a url, an app that asks for a store is not started.') | |
| 214 | ->addDefaultsIfNotSet() | |
| 215 | ->children() | |
| 216 | ->append($this->createScalarNode('url', 'Storage URL, for example redis://valkey:6379/0. Credentials and the database index belong in it.', null)) | |
| 217 | ->append($this->createScalarNode('prefix', 'What every key is filed under, so two installations can share one server.', 'stewart')) | |
| 218 | ->append($this->createDurationNode('timeout', 'How long one storage operation may take.', '2s')) | |
| 219 | ->append($this->createDurationNode('recovery_interval', 'After the store times out or is unreachable, how long a worker fails store calls at once before it tries again.', '5s')) | |
| 220 | ->end() | |
| 221 | ->end() | |
| 222 | ->end(); | |
| 223 | } | |
| 224 | ||
| 225 | private function addMqttSection(ArrayNodeDefinition $root): void | |
| 226 | { | |
| 227 | $root | |
| 228 | ->children() | |
| 229 | ->arrayNode('mqtt') | |
| 230 | ->info('The MQTT server apps exchange messages with. Without a url, an app that asks for Mqtt is not started. Needs stewart-php/mqtt.') | |
| 231 | ->addDefaultsIfNotSet() | |
| 232 | ->children() | |
| 233 | ->append($this->createScalarNode('url', 'Server URL, mqtt://host:1883 or mqtts://host:8883 for TLS. Credentials belong in it: mqtt://user:password@host.', null)) | |
| 234 | ->append($this->createScalarNode('client_id', 'Client identifier the server knows the daemon by. Defaults to stewart-<hostname>.', null)) | |
| 235 | ->append($this->createDurationNode('keepalive', 'Longest silence before the connection is probed, and dropped when the probe goes unanswered.', '30s')) | |
| 236 | ->append($this->createDurationNode('connect_timeout', 'How long connecting and the CONNECT handshake may take.', '10s')) | |
| 237 | ->append($this->createDurationNode('reconnect_initial_delay', 'Wait before the first reconnect to the MQTT server. Doubles per attempt.', '1s')) | |
| 238 | ->append($this->createDurationNode('reconnect_max_delay', 'Ceiling for the doubling.', '60s')) | |
| 239 | ->integerNode('outbound_buffer') | |
| 240 | ->info('QoS 1 messages kept while the server is unreachable, before the oldest are dropped. QoS 0 messages are dropped at once.') | |
| 241 | ->min(0) | |
| 242 | ->defaultValue(100) | |
| 243 | ->end() | |
| 244 | ->arrayNode('will') | |
| 245 | ->info('Message the server publishes when the daemon disappears without disconnecting.') | |
| 246 | ->addDefaultsIfNotSet() | |
| 247 | ->children() | |
| 248 | ->append($this->createScalarNode('topic', 'Topic of the last will. Unset sends none.', null)) | |
| 249 | ->append($this->createScalarNode('payload', 'Payload of the last will.', '')) | |
| 250 | ->integerNode('qos') | |
| 251 | ->info('Delivery guarantee: 0 at most once, 1 at least once.') | |
| 252 | ->min(0) | |
| 253 | ->max(1) | |
| 254 | ->defaultValue(0) | |
| 255 | ->end() | |
| 256 | ->booleanNode('retain') | |
| 257 | ->info('Whether the server keeps the last will for later subscribers.') | |
| 258 | ->defaultFalse() | |
| 259 | ->end() | |
| 260 | ->end() | |
| 261 | ->end() | |
| 262 | ->end() | |
| 263 | ->end() | |
| 264 | ->end(); | |
| 265 | } | |
| 266 | ||
| 267 | private function addControlSection(ArrayNodeDefinition $root): void | |
| 268 | { | |
| 269 | $root | |
| 270 | ->children() | |
| 271 | ->arrayNode('control') | |
| 272 | ->info('The socket `stewart status`, `app:pause` and `worker:restart` connect to. The token is a secret: set it from the environment.') | |
| 273 | ->addDefaultsIfNotSet() | |
| 274 | ->children() | |
| 275 | ->append($this->createScalarNode('listen', 'unix://path, relative to the project root, or tcp://host:port. "off" disables the socket; an empty environment variable means unset, so use the word.', 'unix://' . ControlAddress::DEFAULT_SOCKET)) | |
| 276 | ->scalarNode('token') | |
| 277 | ->info('Shared secret every client presents. Required while listen is set: the daemon refuses to start without one.') | |
| 278 | ->defaultNull() | |
| 279 | ->end() | |
| 280 | ->end() | |
| 281 | ->end() | |
| 282 | ->end(); | |
| 283 | } | |
| 284 | ||
| 285 | private function addHttpSection(ArrayNodeDefinition $root): void | |
| 286 | { | |
| 287 | $root | |
| 288 | ->children() | |
| 289 | ->arrayNode('http') | |
| 290 | ->info('The read-only HTTP port that answers /healthz and /readyz, for Kubernetes probes.') | |
| 291 | ->addDefaultsIfNotSet() | |
| 292 | ->children() | |
| 293 | ->append($this->createScalarNode('listen', 'tcp://ip:port, such as tcp://0.0.0.0:8080 or tcp://[::]:8080. "off" disables the port.', self::OFF)) | |
| 294 | ->arrayNode('admin') | |
| 295 | ->info('The HTTP admin API that lists, pauses and resumes apps. The token is a secret: set it from the environment.') | |
| 296 | ->addDefaultsIfNotSet() | |
| 297 | ->children() | |
| 298 | ->append($this->createScalarNode('listen', 'tcp://ip:port, such as tcp://127.0.0.1:8081. "off" disables the API.', self::OFF)) | |
| 299 | ->scalarNode('token') | |
| 300 | ->info('Bearer token every request presents. Required while listen is set: the daemon refuses to start without one.') | |
| 301 | ->defaultNull() | |
| 302 | ->end() | |
| 303 | ->end() | |
| 304 | ->end() | |
| 305 | ->end() | |
| 306 | ->end() | |
| 307 | ->end(); | |
| 308 | } | |
| 309 | ||
| 310 | private function addCodegenSection(ArrayNodeDefinition $root): void | |
| 311 | { | |
| 312 | $root | |
| 313 | ->children() | |
| 314 | ->arrayNode('codegen') | |
| 315 | ->info('What `stewart generate` writes: typed classes over the entities and services of your own instance.') | |
| 316 | ->addDefaultsIfNotSet() | |
| 317 | ->children() | |
| 318 | ->append($this->createScalarNode('namespace', 'Namespace of the generated classes. Composer must map it to output_dir.', 'App\\Generated')) | |
| 319 | ->append($this->createScalarNode('output_dir', 'Directory the classes are written to, relative to the project root.', 'generated')) | |
| 320 | ->append($this->createPatternListNode('include', 'Entity selectors that get classes. Exact IDs or globs (* and ?).', ['*'])) | |
| 321 | ->append($this->createPatternListNode('exclude', 'Entity selectors that do not, whatever include says.', [])) | |
| 322 | ->append($this->createDomainAttributesNode()) | |
| 323 | ->end() | |
| 324 | ->end() | |
| 325 | ->end(); | |
| 326 | } | |
| 327 | ||
| 328 | private function addDeploySection(ArrayNodeDefinition $root): void | |
| 329 | { | |
| 330 | $root | |
| 331 | ->children() | |
| 332 | ->arrayNode('deploy') | |
| 333 | ->info('Deploying new commits while running; needs the runtime image cloning the project (STEWART_BOOT_GIT_URL).') | |
| 334 | ->addDefaultsIfNotSet() | |
| 335 | ->children() | |
| 336 | ->arrayNode('git') | |
| 337 | ->addDefaultsIfNotSet() | |
| 338 | ->children() | |
| 339 | ->append($this->createDurationNode('poll', 'How often to fetch the tracked ref and deploy a new commit that passes `stewart check`. "off" disables it.', self::OFF)) | |
| 340 | ->append($this->createDurationNode('prepare_timeout', 'Longest a fetch, dependency install and check of one commit may take.', '10m')) | |
| 341 | ->end() | |
| 342 | ->end() | |
| 343 | ->end() | |
| 344 | ->end() | |
| 345 | ->end(); | |
| 346 | } | |
| 347 | ||
| 348 | private function addExposeSection(ArrayNodeDefinition $root): void | |
| 349 | { | |
| 350 | $root | |
| 351 | ->children() | |
| 352 | ->arrayNode('expose') | |
| 353 | ->info('Entities apps create in Home Assistant through the stewart integration.') | |
| 354 | ->addDefaultsIfNotSet() | |
| 355 | ->children() | |
| 356 | ->append($this->createScalarNode('instance', 'Name of this Stewart in Home Assistant, part of every exposed unique_id; daemons sharing one Home Assistant need different names.', 'default')) | |
| 357 | ->append($this->createDurationNode('command_timeout', 'How long Home Assistant waits for an app to answer a command on an exposed entity; at most 300s.', '10s')) | |
| 358 | ->booleanNode('prune') | |
| 359 | ->info('Remove this daemon\'s entities that no running app exposed shortly after startup; entities of failed apps stay.') | |
| 360 | ->defaultTrue() | |
| 361 | ->end() | |
| 362 | ->end() | |
| 363 | ->end() | |
| 364 | ->end(); | |
| 365 | } | |
| 366 | ||
| 367 | private function createWebsocketUrlNode(): ScalarNodeDefinition | |
| 368 | { | |
| 369 | $node = new ScalarNodeDefinition('url'); | |
| 370 | $node | |
| 371 | ->info('http:// and https:// are accepted and rewritten; /api/websocket is appended unless the path already ends in /websocket.') | |
| 372 | ->isRequired() | |
| 373 | ->cannotBeEmpty() | |
| 374 | ->beforeNormalization() | |
| 375 | ->ifString() | |
| 376 | ->then(static fn(string $url): string => HomeAssistantUrl::tryParse($url)?->reveal() ?? $url) | |
| 377 | ->end(); | |
| 378 | ||
| 379 | return $node; | |
| 380 | } | |
| 381 | ||
| 382 | private function createSizeLimitNode(string $name, string $info): IntegerNodeDefinition | |
| 383 | { | |
| 384 | $node = new IntegerNodeDefinition($name); | |
| 385 | $node->info($info)->min(1)->defaultValue(self::DEFAULT_SIZE_LIMIT_BYTES); | |
| 386 | ||
| 387 | return $node; | |
| 388 | } | |
| 389 | ||
| 390 | /** @param list<string> $default */ | |
| 391 | private function createPatternListNode(string $name, string $info, array $default): ArrayNodeDefinition | |
| 392 | { | |
| 393 | $node = new ArrayNodeDefinition($name); | |
| 394 | $node | |
| 395 | ->info($info) | |
| 396 | ->scalarPrototype()->cannotBeEmpty()->end() | |
| 397 | ->performNoDeepMerging() | |
| 398 | ->defaultValue($default); | |
| 399 | ||
| 400 | return $node; | |
| 401 | } | |
| 402 | ||
| 403 | private function createDomainAttributesNode(): ArrayNodeDefinition | |
| 404 | { | |
| 405 | $node = $this->requireKeysMatching(new ArrayNodeDefinition('attributes'), static fn(string $domain): bool => preg_match(self::DOMAIN_KEY, $domain) === 1, 'a domain such as "sensor"'); | |
| 406 | $node | |
| 407 | ->info('Per-domain attribute keys for the state classes, keyed by domain. Known Home Assistant attributes get typed accessors; everything else stays reachable through attributes().') | |
| 408 | ->normalizeKeys(false) | |
| 409 | ->arrayPrototype() | |
| 410 | ->children() | |
| 411 | ->append($this->createPatternListNode('include', 'Keys or globs that get an accessor when seen, in addition to the known ones.', [])) | |
| 412 | ->append($this->createPatternListNode('exclude', 'Keys or globs that never get one, known or included.', [])) | |
| 413 | ->end() | |
| 414 | ->end(); | |
| 415 | ||
| 416 | return $node; | |
| 417 | } | |
| 418 | ||
| 419 | private function addServiceCallsSection(ArrayNodeDefinition $root): void | |
| 420 | { | |
| 421 | $root | |
| 422 | ->children() | |
| 423 | ->arrayNode('service_calls') | |
| 424 | ->info('Bounds on the service calls workers may have in flight through the shared connection.') | |
| 425 | ->addDefaultsIfNotSet() | |
| 426 | ->children() | |
| 427 | ->integerNode('max_in_flight_per_worker') | |
| 428 | ->info('Calls one worker may have outstanding before further ones are refused. 0 is unlimited.') | |
| 429 | ->min(0) | |
| 430 | ->defaultValue(64) | |
| 431 | ->end() | |
| 432 | ->integerNode('max_in_flight') | |
| 433 | ->info('Calls the whole broker may have outstanding. 0 is unlimited.') | |
| 434 | ->min(0) | |
| 435 | ->defaultValue(256) | |
| 436 | ->end() | |
| 437 | ->booleanNode('dry_run') | |
| 438 | ->info('Log service calls and answer them as succeeded instead of sending them, to develop against a live Home Assistant.') | |
| 439 | ->defaultFalse() | |
| 440 | ->end() | |
| 441 | ->end() | |
| 442 | ->end() | |
| 443 | ->end(); | |
| 444 | } | |
| 445 | ||
| 446 | private function addAppsSection(ArrayNodeDefinition $root): void | |
| 447 | { | |
| 448 | $root | |
| 449 | ->children() | |
| 450 | ->append($this->requireKeysMatching(new ArrayNodeDefinition('apps'), static fn(string $id): bool => AppId::tryFromString($id) !== null, 'an automation ID. IDs start with a letter and hold only lowercase letters, digits, "-" and "_"') | |
| 451 | ->info('Per-app overrides, keyed by the ID in the automation\'s #[Automation] attribute. Automations are found by scanning, not listed here.') | |
| 452 | ->normalizeKeys(false) | |
| 453 | ->arrayPrototype() | |
| 454 | // integerNode() rejects an explicit null, so `worker: ~` is removed before validation. | |
| 455 | ->beforeNormalization() | |
| 456 | ->ifTrue(static fn(mixed $app): bool => \is_array($app) | |
| 457 | && \array_key_exists('worker', $app) | |
| 458 | && $app['worker'] === null) | |
| 459 | ->then(static function (mixed $app): mixed { | |
| 460 | if (\is_array($app)) { | |
| 461 | unset($app['worker']); | |
| 462 | } | |
| 463 | ||
| 464 | return $app; | |
| 465 | }) | |
| 466 | ->end() | |
| 467 | ->children() | |
| 468 | ->booleanNode('enabled')->defaultTrue()->end() | |
| 469 | ->booleanNode('paused') | |
| 470 | ->info('Start loaded but paused: no events or schedule runs reach it until resumed.') | |
| 471 | ->defaultFalse() | |
| 472 | ->end() | |
| 473 | ->integerNode('worker') | |
| 474 | ->info('Pin to a worker index. Omit to have it distributed round-robin.') | |
| 475 | ->min(0) | |
| 476 | ->defaultNull() | |
| 477 | ->end() | |
| 478 | ->arrayNode('options') | |
| 479 | ->info('Passed to the app constructor as named arguments.') | |
| 480 | ->normalizeKeys(false) | |
| 481 | ->defaultValue([]) | |
| 482 | ->variablePrototype()->end() | |
| 483 | ->end() | |
| 484 | ->end() | |
| 485 | ->end()) | |
| 486 | ->end(); | |
| 487 | } | |
| 488 | } |