| 30 | | final class RunCommand extends StewartCommand |
| 31 | | { |
| 32 | | public const string NAME = 'run'; |
| 33 | | |
| 34 | | |
| 35 | | public const int RESTART_EXIT_CODE = 75; |
| 36 | | |
| 37 | | public function __construct( |
| 38 | | private readonly ConfigLoader $config, |
| 39 | | private readonly AppDiscovery $discovery, |
| 40 | | private readonly AppCatalogResolver $catalogs, |
| 41 | | private readonly BrokerKernel $brokers, |
| 42 | | private readonly LoggerFactory $loggers, |
| 43 | | ) { |
| 44 | | parent::__construct(); |
| 45 | | } |
| 46 | | |
| 47 | | protected function configure(): void |
| 48 | | { |
| 49 | | parent::configure(); |
| 50 | | $this |
| 51 | | ->addOption('log-level', 'l', InputOption::VALUE_REQUIRED, 'debug|info|notice|warning|error') |
| 52 | | ->addOption('json-logs', null, InputOption::VALUE_NONE, 'Emit logs as JSON') |
| 53 | | ->addOption('only', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run only this automation, even if disabled; repeat for more'); |
| 54 | | } |
| 55 | | |
| 56 | | protected function execute(InputInterface $input, OutputInterface $output): int |
| 57 | | { |
| 58 | | try { |
| 59 | | $daemon = $this->prepareDaemon($input); |
| 60 | | } catch (StewartException $e) { |
| 61 | | $output->writeln('<error>' . $e->getMessage() . '</error>'); |
| 62 | | |
| 63 | | return Command::FAILURE; |
| 64 | | } |
| 65 | | |
| 66 | | try { |
| 67 | | $outcome = $daemon->broker->run(); |
| 68 | | } catch (Throwable $e) { |
| 69 | | $daemon->logger->error('Stewart stopped', ['exception' => $e]); |
| 70 | | |
| 71 | | return Command::FAILURE; |
| 72 | | } |
| 73 | | |
| 74 | | return match ($outcome) { |
| 75 | | BrokerStopOutcome::Stopped => Command::SUCCESS, |
| 76 | | BrokerStopOutcome::RestartRequested => self::RESTART_EXIT_CODE, |
| 77 | | }; |
| 78 | | } |
| 79 | | |
| 80 | | |
| 81 | | private function prepareDaemon(InputInterface $input): PreparedDaemon |
| 82 | | { |
| 83 | | $configOption = $this->findConfigOption($input); |
| 84 | | $config = $this->config->loadConfig($configOption, $this->configOverridesFromOptions($input)); |
| 85 | | |
| 86 | | $config->requireHomeAssistant(); |
| 87 | | $discovered = $this->discovery->discover(); |
| 88 | | $selection = $this->selectAppsFromOptions($input); |
| 89 | | $apps = $this->catalogs->resolveCatalog($discovered, $config->apps, $config->workers, $selection); |
| 90 | | $logger = $this->loggers->createStdoutLogger($config->logLevel, $this->logFormatFromOptions($input, $config->logFormat)); |
| 91 | | |
| 92 | | $this->noteMissingConfigFile($this->config->findMissingDefaultFile($configOption), $logger); |
| 93 | | $this->warnAboutDiscovery($discovered, $apps, $this->config->locateConfigFile($configOption), $logger); |
| 94 | | |
| 95 | | if ($config->serviceCalls->dryRun) { |
| 96 | | $logger->warning('service_calls.dry_run is on: service calls are logged, not sent to Home Assistant.'); |
| 97 | | } |
| 98 | | |
| 99 | | if ($selection->onlyIds !== null) { |
| 100 | | $logger->notice('Running only the automations named by --only.', ['apps' => $selection->onlyIds->toStrings()]); |
| 101 | | } |
| 102 | | |
| 103 | | return new PreparedDaemon($this->brokers->createBroker($config, $apps, $logger, $configOption), $logger); |
| 104 | | } |
| 105 | | |
| 106 | | private function noteMissingConfigFile(?string $missingConfigFile, LoggerInterface $logger): void |
| 107 | | { |
| 108 | | if ($missingConfigFile !== null) { |
| 109 | | $logger->info('No config file found; using the environment only.', ['config' => $missingConfigFile]); |
| 110 | | } |
| 111 | | } |
| 112 | | |
| 113 | | private function warnAboutDiscovery(DiscoveryResult $discovered, AppCatalog $apps, string $configPath, LoggerInterface $logger): void |
| 114 | | { |
| 115 | | foreach ($discovered->unmarked as $unmarked) { |
| 116 | | $logger->warning('A class in apps/ implements App but is not marked #[Automation], so it will not run.', [ |
| 117 | | 'class' => $unmarked->class, |
| 118 | | ]); |
| 119 | | } |
| 120 | | |
| 121 | | foreach ($discovered->unloadable as $file) { |
| 122 | | $logger->error('A file in apps/ failed to load, so the automation it declares will not run.', [ |
| 123 | | 'file' => $file->path, |
| 124 | | 'exception' => $file->cause, |
| 125 | | ]); |
| 126 | | } |
| 127 | | |
| 128 | | if (!$apps->unmatchedOverrideIds->isEmpty()) { |
| 129 | | $logger->warning('stewart.yaml configures automations that were not found, possibly because their file failed to load.', [ |
| 130 | | 'apps' => $apps->unmatchedOverrideIds->toStrings(), |
| 131 | | 'files' => $discovered->unloadable->listPaths(), |
| 132 | | ]); |
| 133 | | } |
| 134 | | |
| 135 | | if ($apps->enabled->isEmpty()) { |
| 136 | | $logger->warning('No automations to run; Stewart will connect and do nothing.', [ |
| 137 | | 'config' => $configPath, |
| 138 | | 'found' => $discovered->apps->count(), |
| 139 | | ]); |
| 140 | | } |
| 141 | | } |
| 142 | | |
| 143 | | |
| 144 | | private function configOverridesFromOptions(InputInterface $input): array |
| 145 | | { |
| 146 | | $level = $this->nonEmptyStringOption($input, 'log-level'); |
| 147 | | |
| 148 | | return $level === null ? [] : ['log_level' => $level]; |
| 149 | | } |
| 150 | | |
| 151 | | |
| 152 | | private function selectAppsFromOptions(InputInterface $input): AppSelection |
| 153 | | { |
| 154 | | $only = $input->getOption('only'); |
| 155 | | |
| 156 | | if (!\is_array($only) || $only === []) { |
| 157 | | return new AppSelection(); |
| 158 | | } |
| 159 | | |
| 160 | | return new AppSelection(AppIdCollection::fromList(array_map(static fn(string $id): AppId => new AppId($id), array_values(array_filter($only, \is_string(...)))))); |
| 161 | | } |
| 162 | | |
| 163 | | private function logFormatFromOptions(InputInterface $input, LogFormat $configured): LogFormat |
| 164 | | { |
| 165 | | return $input->getOption('json-logs') === true ? LogFormat::Json : $configured; |
| 166 | | } |
| 167 | | } |