#!/usr/bin/env bash
#
# The television, woken with the machine and put back to sleep with it.
#
# This appliance has no remote worth hunting for: it is a mini PC under a
# television, switched on at the wall, and the first thing anybody does today
# is find the television's own remote and guess which HDMI input the box is
# on. HDMI-CEC is the cable saying it: the same wire that carries the picture
# carries "wake up" and "the picture is mine now".
#
#   archstar-tv on        wake the television and take its input
#   archstar-tv standby   put it back to sleep - only while the machine is
#                         itself going down, see below
#
# Run by archstar-tv.service, never by hand and never by the page directly:
# the `tv-power` machine action enables or disables the unit, and this script
# is what the unit runs at both ends of the machine's life.
#
# **Nothing here is a failure.** Most of what this project runs on has no CEC
# adapter at all - a desktop with a graphics card that never wired it up, a
# monitor rather than a television, a machine on a bench with nothing plugged
# in. Every one of those ends the same way: the reason goes into
# /run/archstar/tv.json, the page repeats it, and the unit exits cleanly. A
# television is not something to retry into existence, and a kiosk that would
# not boot because a television did not answer would be a worse machine than
# one that boots with a black screen somebody presses a button on.
#
# **Standby happens when the *machine* goes down, not when this unit stops.**
# Those are different, and reading them as the same thing is the mistake that
# would make this feature hated: somebody who sets `tv-power` to `off` because
# the CEC is misbehaving is asking the machine to stop touching their
# television, and blanking it on the way out is exactly what they were asking
# it to stop doing. So the stop half asks systemd whether the system is
# `stopping` and does nothing otherwise.
#
# Environment - the suite sets these; the machine uses the defaults:
#   ARCHSTAR_CEC_CLIENT   the cec-client to drive (default: cec-client)
#   ARCHSTAR_TV_STATUS    (default: /run/archstar/tv.json)
#   ARCHSTAR_TV_TIMEOUT   seconds one conversation may take (default: 10)
#   ARCHSTAR_SYSTEMCTL    how "is the machine going down" is asked
#                         (default: systemctl)

set -euo pipefail

CEC_CLIENT="${ARCHSTAR_CEC_CLIENT:-cec-client}"
STATUS_FILE="${ARCHSTAR_TV_STATUS:-/run/archstar/tv.json}"
SYSTEMCTL="${ARCHSTAR_SYSTEMCTL:-systemctl}"

# How long one conversation with the television may take. It is a bound and
# not a guess at how long CEC needs: this runs at boot and, more to the point,
# at *shutdown*, where an adapter that stopped answering would otherwise hold
# the machine on the way down for as long as systemd's own patience lasts.
TIMEOUT="${ARCHSTAR_TV_TIMEOUT:-10}"

# The television is logical address 0 in every CEC installation there is. Not
# a guess and not configurable: the specification assigns it.
TV_ADDRESS='0'

# What this machine says it is on the bus. `p` is a playback device - a box
# that shows pictures - which is what lets `as` mean "the picture is mine".
# The library's own default is a recording device, which is a thing that asks
# a television for a tuner, and is not what is under this television.
DEVICE_TYPE='p'

die() {
    printf 'error: %s\n' "$*" >&2
    exit 1
}

# What the page reads. Written whole and renamed into place, like every file
# something else polls: a reader either sees the last state or this one, never
# half of either.
#
# `available` is the question the page actually asks - can this machine drive
# a television at all - and `reason` is what to say when it cannot. Both are
# written by this script alone, from the fixed sentences below, so plain
# printf is JSON enough.
publish_status() {
    local available="$1" awake="$2" adapter="$3" reason="$4"
    install -d -m 0755 "$(dirname "${STATUS_FILE}")"
    {
        printf '{"available": %s, "awake": %s' "${available}" "${awake}"
        if [[ -n "${adapter}" ]]; then
            printf ', "adapter": "%s"' "${adapter}"
        fi
        if [[ -n "${reason}" ]]; then
            printf ', "reason": "%s"' "${reason}"
        fi
        printf '}\n'
    } >"${STATUS_FILE}.new"
    chmod 0644 "${STATUS_FILE}.new"
    mv -f "${STATUS_FILE}.new" "${STATUS_FILE}"
}

# The adapter libcec found, or nothing.
#
# `cec-client -l` prints a paragraph per adapter and `Found devices: NONE`
# when there are none; the line worth having is `com port: /dev/cec0`. Read
# rather than trusted: the exit code of that listing is not a reliable answer
# on every version, and the presence of a port is.
find_adapter() {
    local listing port
    listing="$(timeout "${TIMEOUT}" "${CEC_CLIENT}" -l 2>/dev/null || true)"
    port="$(printf '%s\n' "${listing}" | sed -n 's/^com port:[[:space:]]*//p' | head -n1)"
    port="${port%"${port##*[![:space:]]}"}"
    [[ -n "${port}" ]] || return 1
    printf '%s' "${port}"
}

# One conversation with the television: the commands, in order, down a single
# connection. `-s` is cec-client's own "say this and stop", which is the whole
# shape of what this script does - there is no daemon here, nothing stays
# open, and the adapter is free again the moment it returns.
speak() {
    printf '%s\n' "$@" | timeout "${TIMEOUT}" \
        "${CEC_CLIENT}" -s -d 1 -t "${DEVICE_TYPE}" >/dev/null 2>&1
}

# Whether this machine can talk to a television at all. Sets ADAPTER on the
# way through, and publishes the reason it cannot when it cannot.
the_machine_can_talk() {
    if ! command -v "${CEC_CLIENT}" >/dev/null 2>&1; then
        publish_status false false '' 'libcec is not installed on this machine'
        printf 'no cec-client here; the television is left alone\n'
        return 1
    fi
    if ! ADAPTER="$(find_adapter)"; then
        publish_status false false '' 'no HDMI-CEC adapter on this machine'
        printf 'no CEC adapter here; the television is left alone\n'
        return 1
    fi
    return 0
}

wake() {
    local ADAPTER=''
    the_machine_can_talk || exit 0

    # Wake first, take the input second, in one connection: a television that
    # was asleep is not listening for "the picture is mine" until it is awake,
    # and the two in one conversation is what gives it the moment in between.
    if ! speak "on ${TV_ADDRESS}" 'as'; then
        # An adapter that is there and a television that will not answer: a
        # set switched off at the wall, a cable in the wrong socket, a
        # television whose CEC somebody turned off in a menu. Said, not
        # retried - the machine has a party to run.
        publish_status true false "${ADAPTER}" \
            'the television did not answer; is it switched on at the wall, and is CEC enabled in its menu?'
        printf 'the television did not answer on %s\n' "${ADAPTER}"
        exit 0
    fi
    publish_status true true "${ADAPTER}" ''
    printf 'the television is awake and showing this machine (%s)\n' "${ADAPTER}"
}

sleep_it() {
    local ADAPTER='' state=''

    # The whole of the difference between "this unit stopped" and "the machine
    # is going down". Stopping the unit is what `tv-power off` does, and it
    # means *stop touching my television*, which is not a thing to do by
    # switching it off.
    state="$("${SYSTEMCTL}" is-system-running 2>/dev/null || true)"
    if [[ "${state}" != 'stopping' ]]; then
        printf 'the machine is not going down (%s); the television is left as it is\n' \
            "${state:-unknown}"
        exit 0
    fi

    the_machine_can_talk || exit 0

    if ! speak "standby ${TV_ADDRESS}"; then
        publish_status true false "${ADAPTER}" 'the television did not answer'
        printf 'the television did not answer on %s\n' "${ADAPTER}"
        exit 0
    fi
    publish_status true false "${ADAPTER}" ''
    printf 'the television was put to sleep (%s)\n' "${ADAPTER}"
}

case "${1:-on}" in
    on) wake ;;
    standby) sleep_it ;;
    *) die "unknown verb: $1 (on, standby)" ;;
esac
