Christopher Hotchkiss Christopher Hotchkiss
Login

Code

remindwall / RWPureSwift / Sources / ScreenOffMonitor / ScreenOffMonitor.swift

Back to remindwall — Current RemindWall

Download this file

import AppTypes
import ComposableArchitecture
import Dao
import Dependencies
import Foundation
import os
import ScreenControl

private let logger = Logger(subsystem: "RemindWall", category: "ScreenOffMonitor")

/// Write-only display control. The kiosk's LG returns 100% corrupted DDC reads
/// (probe 2026-07-03: 26/26 garbage, values like -121...120 varying per
/// second), so state NEVER derives from reading the panel: `isDimmed` flips
/// only when a write sequence CONFIRMS, ticks are idempotent drivers that
/// re-issue the failed direction until it lands, and the restore level is a
/// known value — the level saved at dim time where reads work (iOS), a fixed
/// full-bright fallback where they don't (the Mac).
@Reducer
public struct ScreenOffMonitorFeature: Sendable {
    @Dependency(\.continuousClock) var clock
    @Dependency(\.screenControl) var screenControl
    @Dependency(\.date.now) var now
    @Dependency(\.calendar) var calendar

    static let checkInterval = Duration.seconds(30)
    /// Restore target when no brightness could be saved at dim time.
    /// A kiosk wants full bright; make this a setting if that ever changes.
    static let fallbackRestoreLevel: CGFloat = 1.0

    @ObservableState
    public struct State: Equatable {
        @Shared(.syncedSetting(SCREEN_OFF_SETTING_KEY)) var schedule: ScreenOffSchedule?

        /// Confirmed-dimmed: set only after the dim writes succeeded.
        public var isDimmed: Bool = false
        /// Brightness read at dim time, when readable (iOS). nil on the Mac —
        /// its DDC reads are garbage and must never become a restore target.
        public var savedBrightness: CGFloat?
        public var isMonitoring: Bool = false
        public var isSlideshowPlaying: Bool = false
        public var hasLateReminders: Bool = false

        public init() {}
    }

    public enum Action: Equatable {
        case startMonitoring
        case stopMonitoring
        case tick
        case _dim
        case _restore
        case _dimConfirmed(savedBrightness: CGFloat?)
        case _restoreConfirmed
    }

    enum CancelID { case monitorLoop, work }

    public init() {}

    public var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .startMonitoring:
                guard !state.isMonitoring else { return .none }
                state.isMonitoring = true
                return .run { send in
                    await send(.tick)
                    for await _ in self.clock.timer(interval: Self.checkInterval) {
                        await send(.tick)
                    }
                }
                .cancellable(id: CancelID.monitorLoop, cancelInFlight: true)

            case .stopMonitoring:
                state.isMonitoring = false
                let wasDimmed = state.isDimmed
                let restoreLevel = state.savedBrightness ?? Self.fallbackRestoreLevel
                state.isDimmed = false
                state.savedBrightness = nil
                return .merge(
                    .cancel(id: CancelID.monitorLoop),
                    .cancel(id: CancelID.work),
                    wasDimmed
                        ? .run { [screenControl] _ in
                            // Ceding control: best effort, nothing retries after this.
                            try? await screenControl.setDisplayPower(true)
                            try await screenControl.setBrightness(restoreLevel)
                        } catch: { error, _ in
                            logger.warning("restore on stop failed: \(error.localizedDescription, privacy: .public)")
                        }
                        : .none
                )

            case .tick:
                // Pure decision - no hardware reads. The tick just re-issues
                // whichever direction the confirmed state disagrees with.
                let shouldDim: Bool
                if let schedule = state.schedule, state.isSlideshowPlaying, !state.hasLateReminders {
                    let hour = calendar.component(.hour, from: now)
                    let minute = calendar.component(.minute, from: now)
                    shouldDim = schedule.isInOffWindow(currentTotalMinutes: hour * 60 + minute)
                } else {
                    shouldDim = false
                }

                if shouldDim && !state.isDimmed {
                    return .send(._dim)
                }
                if !shouldDim && state.isDimmed {
                    return .send(._restore)
                }
                return .none

            case ._dim:
                return .run { [screenControl] send in
                    // Save the current level where reads work (iOS). On the Mac
                    // this throws (corrupted reads are rejected downstream) and
                    // nil selects the fallback at restore time - a garbage read
                    // must never become the restore target.
                    let saved = try? await screenControl.getBrightness()
                    // Brightness first (an awake-panel op), then true power-off.
                    try await screenControl.setBrightness(0.0)
                    try await screenControl.setDisplayPower(false)
                    await send(._dimConfirmed(savedBrightness: saved))
                } catch: { error, _ in
                    // Not confirmed - the next tick re-issues ._dim.
                    logger.warning("dim failed, will retry next tick: \(error.localizedDescription, privacy: .public)")
                }
                .cancellable(id: CancelID.work, cancelInFlight: true)

            case let ._dimConfirmed(savedBrightness):
                state.isDimmed = true
                state.savedBrightness = savedBrightness
                return .none

            case ._restore:
                let restoreLevel = state.savedBrightness ?? Self.fallbackRestoreLevel
                return .run { [screenControl] send in
                    // Wake first, then set - the reverse of the dim order.
                    try await screenControl.setDisplayPower(true)
                    try await screenControl.setBrightness(restoreLevel)
                    await send(._restoreConfirmed)
                } catch: { error, _ in
                    // Not confirmed - the next tick re-issues ._restore. On the
                    // late-reminder force-on path this retry is what guarantees
                    // the screen eventually comes back.
                    logger.warning("restore failed, will retry next tick: \(error.localizedDescription, privacy: .public)")
                }
                .cancellable(id: CancelID.work, cancelInFlight: true)

            case ._restoreConfirmed:
                state.isDimmed = false
                state.savedBrightness = nil
                return .none
            }
        }
    }
}