"""Step 6: the door as an OSDP peripheral at address 2, on the reader's bus.

It still opens on its own on the wired pulse.
The controller can also open it for a while, hold it open, lock it back,
light its LED and write on its screen. Every input change is reported on
the next POLL. Needs osdp.py and ssd1306.py. Run door_controller.py on the Mac.
"""

from machine import Pin, UART, I2C, unique_id
import time
from ssd1306 import SSD1306_I2C
import osdp

ADDRESS = 2
OPEN_MS = 5000
SCREEN_REFRESH_MS = 200
IDENTITY = bytes([0x00, 0x00, 0x00, 2, 1]) + unique_id()[-4:] + bytes([0, 1, 0])
CAPABILITIES = bytes([
    1, 1, 1,
    2, 1, 1,
    4, 4, 1,
    6, 1, 1,
    8, 1, 0,
    10, 128, 0,
])
BAD_LENGTH = bytes([2])
UNKNOWN_COMMAND = bytes([3])

uart = UART(1, baudrate=9600, tx=Pin(4), rx=Pin(5))
transmit_enable = Pin(2, Pin.OUT, value=0)
red = Pin(14, Pin.OUT, value=0)
green = Pin(15, Pin.OUT, value=0)
strike = Pin(25, Pin.OUT, value=0)
wired_input = Pin(21, Pin.IN, Pin.PULL_DOWN)
screen = SSD1306_I2C(128, 64, I2C(1, sda=Pin(26), scl=Pin(27)))
screen.write_cmd(0xA0)
screen.write_cmd(0xC0)


class Door:
    """Closed, open for a while, or held open. The strike follows it."""

    def __init__(self):
        self.held = False
        self.open_until = time.ticks_ms()
        self.reason = ""

    def open_for(self, milliseconds, reason):
        self.open_until = time.ticks_add(time.ticks_ms(), milliseconds)
        self.reason = reason

    def hold(self, held, reason):
        self.held = held
        self.open_until = time.ticks_ms()
        self.reason = reason

    def remaining_ms(self):
        return max(time.ticks_diff(self.open_until, time.ticks_ms()), 0)

    def is_open(self):
        return self.held or self.remaining_ms() > 0

    def state(self):
        if self.held:
            return "HELD OPEN"
        if self.remaining_ms() > 0:
            return "OPEN {} s".format((self.remaining_ms() + 999) // 1000)
        return "CLOSED"


class Led:
    """The door's colour, unless the controller asked for another one for a while."""

    def __init__(self):
        self.temporary = osdp.OFF
        self.temporary_until = time.ticks_ms()

    def command(self, data):
        if data[2] == 2:
            self.temporary = data[5]
            self.temporary_until = time.ticks_add(time.ticks_ms(), (data[7] | (data[8] << 8)) * 100)
        if data[2] == 1:
            self.temporary_until = time.ticks_ms()

    def refresh(self, door_color):
        temporary_active = time.ticks_diff(self.temporary_until, time.ticks_ms()) > 0
        color = self.temporary if temporary_active else door_color
        red.value(color in (osdp.RED, osdp.AMBER))
        green.value(color in (osdp.GREEN, osdp.AMBER))


def send(frame):
    transmit_enable.value(1)
    uart.write(frame)
    uart.flush()
    transmit_enable.value(0)


def draw(door, message):
    screen.fill(0)
    screen.text("DOOR 2", 0, 0)
    screen.text(door.state(), 0, 16)
    screen.text(door.reason[:16], 0, 32)
    screen.text(message[:16], 0, 48)
    screen.show()


door = Door()
led = Led()
message = ""
inputs_changed = False
output_changed = False
last_sequence = None
last_reply = b""


def input_states():
    return bytes([wired_input.value()])


def output_states():
    return bytes([1 if door.is_open() else 0])


def obey_output(data):
    number, control, timer = data[0], data[1], data[2] | (data[3] << 8)
    if number == 0 and control == osdp.OUTPUT_PULSE:
        door.open_for(timer * 100, "controller")
    if number == 0 and control in (osdp.OUTPUT_ON, osdp.OUTPUT_OFF):
        door.hold(control == osdp.OUTPUT_ON, "controller")


def show_text(data):
    global message
    message = data[6 : 6 + data[5]].decode()


def obey(action, data, smallest):
    if len(data) < smallest:
        return osdp.NAK, BAD_LENGTH
    action(data)
    return osdp.ACK, b""


def answer(command):
    global inputs_changed, output_changed
    code, data = command.code, command.data
    if code == osdp.POLL and inputs_changed:
        inputs_changed = False
        return osdp.ISTATR, input_states()
    if code == osdp.POLL and output_changed:
        output_changed = False
        return osdp.OSTATR, output_states()
    if code == osdp.POLL:
        return osdp.ACK, b""
    if code == osdp.ID:
        return osdp.PDID, IDENTITY
    if code == osdp.CAP:
        return osdp.PDCAP, CAPABILITIES
    if code == osdp.ISTAT:
        return osdp.ISTATR, input_states()
    if code == osdp.OSTAT:
        return osdp.OSTATR, output_states()
    if code == osdp.OUT:
        return obey(obey_output, data, 4)
    if code == osdp.LED:
        return obey(led.command, data, 14)
    if code == osdp.TEXT:
        return obey(show_text, data, 6)
    return osdp.NAK, UNKNOWN_COMMAND


def handle(command):
    global last_sequence, last_reply
    repeated = command.sequence == last_sequence and command.sequence != 0
    if not repeated:
        code, data = answer(command)
        last_reply = osdp.reply(ADDRESS, command.sequence, code, data)
    last_sequence = command.sequence
    send(last_reply)


buffer = b""
previous_inputs = input_states()
previous_open = door.is_open()
last_screen = ""
last_draw = time.ticks_ms()

while True:
    if uart.any():
        buffer += uart.read()
    command, buffer = osdp.parse(buffer)
    if command is not None and command.address == ADDRESS:
        handle(command)

    inputs = input_states()
    if inputs != previous_inputs:
        inputs_changed = True
        if inputs[0] and not previous_inputs[0]:
            door.open_for(OPEN_MS, "wired")
    previous_inputs = inputs

    if door.is_open() != previous_open:
        output_changed = True
    previous_open = door.is_open()

    strike.value(door.is_open())
    led.refresh(osdp.GREEN if door.is_open() else osdp.RED)

    shown = door.state() + door.reason + message
    if shown != last_screen and time.ticks_diff(time.ticks_ms(), last_draw) >= SCREEN_REFRESH_MS:
        draw(door, message)
        last_screen = shown
        last_draw = time.ticks_ms()

    time.sleep_ms(2)
