Lines 100.00% 40 / 40
Methods 100.00% 11 / 11
Classes 100.00% 1 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 none 100.00% 1 / 1 100.00% 1 / 1 1
 forDailyTime 100.00% 1 / 1 100.00% 1 / 1 1
 forMoment 100.00% 1 / 1 100.00% 1 / 1 1
 requireSupportedEntity 100.00% 2 / 2 100.00% 1 / 1 3
 fromEntityState 100.00% 6 / 6 100.00% 1 / 1 6
 equals 100.00% 7 / 7 100.00% 1 / 1 6
 fromInputDatetime 100.00% 8 / 8 100.00% 1 / 1 7
 fromLocalDateAndTime 100.00% 6 / 6 100.00% 1 / 1 5
 tryParseTimeOfDay 100.00% 3 / 3 100.00% 1 / 1 2
 fromTimestampSensor 100.00% 4 / 4 100.00% 1 / 1 3
17final readonly class EntityTime
18{
19    private const string INPUT_DATETIME_DOMAIN = 'input_datetime';
20
21    private const string SENSOR_DOMAIN = 'sensor';
22
23    private const string TIMESTAMP_DEVICE_CLASS = 'timestamp';
24
25    private const string DATE_AND_TIME_PATTERN = '/\A(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})\z/';
26
27    private const string TIME_ONLY_PATTERN = '/\A\d{2}:\d{2}:\d{2}\z/';
28
29    private function __construct(
30        public ?TimeOfDay $dailyAt = null,
31        public ?Instant $moment = null,
32    ) {}
33
34    public static function none(): self
35    {
36        return new self();
37    }
38
39    public static function forDailyTime(TimeOfDay $time): self
40    {
41        return new self(dailyAt: $time);
42    }
43
44    public static function forMoment(Instant $moment): self
45    {
46        return new self(moment: $moment);
47    }
48
49    /** @throws ScheduleException */
50    public static function requireSupportedEntity(EntityId $entityId): void
51    {
52        if ($entityId->domain !== self::INPUT_DATETIME_DOMAIN && $entityId->domain !== self::SENSOR_DOMAIN) {
53            throw ScheduleException::createForReason(ScheduleError::EntityTimeDomainUnsupported, entityId: $entityId->value);
54        }
55    }
56
57    public static function fromEntityState(?EntityState $state, DateTimeZone $zone): self
58    {
59        if ($state === null || $state->isUnavailable()) {
60            return self::none();
61        }
62
63        return match ($state->getDomain()) {
64            self::INPUT_DATETIME_DOMAIN => self::fromInputDatetime($state, $zone),
65            self::SENSOR_DOMAIN => self::fromTimestampSensor($state),
66            default => self::none(),
67        };
68    }
69
70    public function equals(self $other): bool
71    {
72        $sameDailyAt = $this->dailyAt === null || $other->dailyAt === null
73            ? $this->dailyAt === $other->dailyAt
74            : $this->dailyAt->equals($other->dailyAt);
75        $sameMoment = $this->moment === null || $other->moment === null
76            ? $this->moment === $other->moment
77            : $this->moment->equals($other->moment);
78
79        return $sameDailyAt && $sameMoment;
80    }
81
82    private static function fromInputDatetime(EntityState $state, DateTimeZone $zone): self
83    {
84        $hasDate = $state->getBoolAttribute('has_date');
85        $hasTime = $state->getBoolAttribute('has_time');
86
87        if ($hasDate !== false && preg_match(self::DATE_AND_TIME_PATTERN, $state->state, $matches) === 1) {
88            return self::fromLocalDateAndTime($matches[1], $matches[2], $zone);
89        }
90
91        if ($hasDate !== true && $hasTime !== false && preg_match(self::TIME_ONLY_PATTERN, $state->state) === 1) {
92            $time = self::tryParseTimeOfDay($state->state);
93
94            return $time === null ? self::none() : self::forDailyTime($time);
95        }
96
97        return self::none();
98    }
99
100    private static function fromLocalDateAndTime(string $date, string $time, DateTimeZone $zone): self
101    {
102        $day = DateTimeImmutable::createFromFormat('!Y-m-d', $date, $zone);
103        $timeOfDay = self::tryParseTimeOfDay($time);
104
105        if ($day === false || $day->format('Y-m-d') !== $date || $timeOfDay === null) {
106            return self::none();
107        }
108
109        $moment = $timeOfDay->resolveOnDay($day, $zone);
110
111        return $moment === null ? self::none() : self::forMoment(Instant::fromDateTime($moment));
112    }
113
114    private static function tryParseTimeOfDay(string $time): ?TimeOfDay
115    {
116        try {
117            return TimeOfDay::parse($time);
118        } catch (ScheduleException) {
119            return null;
120        }
121    }
122
123    private static function fromTimestampSensor(EntityState $state): self
124    {
125        if ($state->getStringAttribute('device_class') !== self::TIMESTAMP_DEVICE_CLASS) {
126            return self::none();
127        }
128
129        $moment = Instant::tryFromIso($state->state);
130
131        return $moment === null ? self::none() : self::forMoment($moment);
132    }
133}