Lines 98.11% 52 / 53
Functions and Methods 85.71% 6 / 7
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
ComponentLink 98.11% 52 / 53 85.71% 6 / 7 18 0.00% 0 / 1
 __construct 100.00% 2 / 2 100.00% 1 / 1 1
 onCommand 100.00% 1 / 1 100.00% 1 / 1 1
 onSessionEnded 100.00% 1 / 1 100.00% 1 / 1 1
 establishLink 100.00% 30 / 30 100.00% 1 / 1 8
 markLinkLost 100.00% 2 / 2 100.00% 1 / 1 1
 recordDetectedState 100.00% 3 / 3 100.00% 1 / 1 1
 handleSessionEvent 92.85% 13 / 14 0.00% 0 / 1 5.01
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Runtime\Broker\Component;
6
7use Closure;
8use Psr\Log\LoggerInterface;
9use Stewart\Client\Component\ComponentCommand;
10use Stewart\Client\Component\ComponentProtocol;
11use Stewart\Client\Component\ComponentSessionEvent;
12use Stewart\Client\Component\ComponentSessionRequest;
13use Stewart\Client\Component\ComponentVersion;
14use Stewart\Client\Component\SessionEnded;
15use Stewart\Client\Component\SessionReplaced;
16use Stewart\Client\Exception\HaClientException;
17use Stewart\Client\HaClient;
18use Stewart\Runtime\Config\ExposeConfig;
19use Stewart\Runtime\Lifecycle\ComponentState;
20use Stewart\Runtime\Model\StewartVersion;
21
22// Session subscriptions live as long as one connection, so every connect detects the component again.
23final class ComponentLink
24{
25    private int $connectionGeneration = 0;
26
27    /** @var Closure(ComponentCommand): void */
28    private Closure $onCommand;
29
30    /** @var Closure(): void */
31    private Closure $onSessionEnded;
32
33    public function __construct(
34        private readonly HaClient $client,
35        private readonly LoggerInterface $logger,
36        private readonly ComponentTracker $tracker,
37        private readonly ExposeConfig $expose,
38        private readonly StewartVersion $stewartVersion,
39    ) {
40        $this->onCommand = static function (): void {};
41        $this->onSessionEnded = static function (): void {};
42    }
43
44    /** @param Closure(ComponentCommand): void $handler */
45    public function onCommand(Closure $handler): void
46    {
47        $this->onCommand = $handler;
48    }
49
50    /** @param Closure(): void $handler */
51    public function onSessionEnded(Closure $handler): void
52    {
53        $this->onSessionEnded = $handler;
54    }
55
56    /** @throws HaClientException */
57    public function establishLink(): void
58    {
59        $generation = ++$this->connectionGeneration;
60        $version = null;
61
62        try {
63            $version = $this->client->findComponentVersion();
64
65            if ($version === null) {
66                if ($this->recordDetectedState(ComponentState::Missing, null)) {
67                    $this->logger->info('The stewart Home Assistant integration is not installed, so apps cannot expose entities');
68                }
69
70                return;
71            }
72
73            if (!$version->isCompatible()) {
74                if ($this->recordDetectedState(ComponentState::ProtocolMismatch, $version)) {
75                    $this->logger->warning('The stewart Home Assistant integration speaks another protocol; update it or Stewart so they match', [
76                        'component_version' => $version->componentVersion,
77                        'component_protocol' => $version->protocol,
78                        'stewart_protocol' => ComponentProtocol::VERSION,
79                    ]);
80                }
81
82                return;
83            }
84
85            $this->client->subscribeComponentSession(
86                new ComponentSessionRequest($this->expose->instance, $this->stewartVersion->value, $this->expose->commandTimeout),
87                fn(ComponentSessionEvent $event) => $this->handleSessionEvent($event, $generation),
88            );
89        } catch (HaClientException $e) {
90            if (!$e->reason->isCommandRefusal()) {
91                throw $e;
92            }
93
94            if ($this->recordDetectedState(ComponentState::Refused, $version)) {
95                $this->logger->warning('Home Assistant refused the stewart integration session', ['exception' => $e]);
96            }
97
98            return;
99        }
100
101        $this->recordDetectedState(ComponentState::Active, $version);
102        $this->logger->info('Opened the stewart Home Assistant integration session', [
103            'component_version' => $version->componentVersion,
104            'instance' => (string) $this->expose->instance,
105        ]);
106    }
107
108    public function markLinkLost(): void
109    {
110        ++$this->connectionGeneration;
111        $this->tracker->recordStateKeepingVersion(ComponentState::Unchecked);
112    }
113
114    // A relink retries while the component is missing; only the first of those attempts logs.
115    private function recordDetectedState(ComponentState $state, ?ComponentVersion $version): bool
116    {
117        $changed = $this->tracker->detection->state !== $state;
118        $this->tracker->recordState($state, $version);
119
120        return $changed;
121    }
122
123    private function handleSessionEvent(ComponentSessionEvent $event, int $generation): void
124    {
125        if ($generation !== $this->connectionGeneration) {
126            return;
127        }
128
129        if ($event instanceof ComponentCommand) {
130            ($this->onCommand)($event);
131
132            return;
133        }
134
135        if ($event instanceof SessionEnded) {
136            ($this->onSessionEnded)();
137
138            return;
139        }
140
141        if (!$event instanceof SessionReplaced) {
142            return;
143        }
144
145        $this->logger->warning('Another Stewart took over the stewart integration session; give each daemon its own expose.instance', [
146            'instance' => (string) $this->expose->instance,
147        ]);
148        $this->tracker->recordStateKeepingVersion(ComponentState::Replaced);
149    }
150}