# Home Assistant dishwasher: status, time left, and a pulse when the dishes are done.
#
# Keys pushed by Home Assistant (POST /api/device/<id>/data):
#   dishwasher             the state: "running", "finished", "idle", or what your
#                          integration reports ("on"/"off", Home Connect's "run",
#                          "ready", "pause", "delayed_start", ...)
#   dishwasher_remaining   (optional) minutes left, e.g. 42
#
# Example payload:
#   {"dishwasher": "running", "dishwasher_remaining": 42}
#
# The app reads them as config.get("ha_dishwasher") and
# config.get("ha_dishwasher_remaining"). Unknown states are shown as they are.

load("render.star", "render")

AMBER = "#f59e0b"
WHITE = "#f8f8f0"
DIM = "#70707a"
QUIET = "#8a8a94"
GREEN = "#5ee08a"
RED = "#ff5040"
BLUE = "#58b4f0"
BODY = "#a8b0bc"
BODY_OFF = "#4a4a52"

# State word (lower case) -> (display word, colour, mode)
STATES = {
    "run": ("RUNNING", AMBER, "run"),
    "running": ("RUNNING", AMBER, "run"),
    "on": ("RUNNING", AMBER, "run"),
    "active": ("RUNNING", AMBER, "run"),
    "washing": ("RUNNING", AMBER, "run"),
    "drying": ("DRYING", AMBER, "run"),
    "delayed_start": ("DELAYED", AMBER, "wait"),
    "delayed": ("DELAYED", AMBER, "wait"),
    "pause": ("PAUSED", AMBER, "wait"),
    "paused": ("PAUSED", AMBER, "wait"),
    "finished": ("DONE", GREEN, "done"),
    "finish": ("DONE", GREEN, "done"),
    "done": ("DONE", GREEN, "done"),
    "end": ("DONE", GREEN, "done"),
    "complete": ("DONE", GREEN, "done"),
    "idle": ("IDLE", QUIET, "idle"),
    "off": ("IDLE", QUIET, "idle"),
    "ready": ("IDLE", QUIET, "idle"),
    "inactive": ("IDLE", QUIET, "idle"),
    "standby": ("IDLE", QUIET, "idle"),
    "error": ("CHECK", RED, "alert"),
    "action_required": ("CHECK", RED, "alert"),
    "aborting": ("CHECK", RED, "alert"),
    "unavailable": ("--", DIM, "none"),
    "unknown": ("--", DIM, "none"),
    "": ("--", DIM, "none"),
}

# Dishwasher front, 14x24: "#" body, "d" panel display, "o" status LED.
ICON = [
    "##############",
    "#............#",
    "#.ddd.....o..#",
    "#............#",
    "##############",
    "#............#",
    "#...######...#",
] + ["#............#"] * 15 + [
    "##############",
    ".##........##.",
]
ICON_X = 2
ICON_Y = 4
INSIDE_TOP = 8  # rows 8..21 of the icon are the door, where the drops fall
INSIDE_ROWS = 14

PULSE = [1.0, 0.94, 0.84, 0.72, 0.62, 0.55, 0.52, 0.55, 0.62, 0.72, 0.84, 0.94]

def num(s, default):
    # Starlark has no try/except: only accept digits, one dot and a leading minus.
    s = s.strip()
    neg = s.startswith("-")
    body = s[1:] if neg else s
    if body == "" or body == "." or body.count(".") > 1:
        return default
    for ch in body.elems():
        if ch not in "0123456789.":
            return default
    if body.startswith("."):
        body = "0" + body
    if body.endswith("."):
        body = body + "0"
    return float(("-" if neg else "") + body)

def pixels(rows, x, y, color, ch = "#"):
    # One box per horizontal run of lit pixels.
    out = []
    for r, line in enumerate(rows):
        start = -1
        for c in range(len(line) + 1):
            on = c < len(line) and line[c] == ch
            if on and start < 0:
                start = c
            elif not on and start >= 0:
                out.append(render.Padding(pad = (x + start, y + r, 0, 0), child = render.Box(width = c - start, height = 1, color = color)))
                start = -1
    return out

def at(x, y, child):
    return render.Padding(pad = (x, y, 0, 0), child = child)

def dot(x, y, color, w = 1, h = 1):
    return at(ICON_X + x, ICON_Y + y, render.Box(width = w, height = h, color = color))

def dim(color, f):
    # Scale a "#rrggbb" colour by f (0..1).
    out = "#"
    for i in [1, 3, 5]:
        v = int(int(color[i:i + 2], 16) * f)
        out += "0123456789abcdef"[v // 16] + "0123456789abcdef"[v % 16]
    return out

def state_of(raw):
    s = raw.strip().lower()
    if "." in s:
        s = s.split(".")[-1]  # "BSH.Common.EnumType.OperationState.Run" -> "run"
    s = s.replace(" ", "_")
    if s in STATES:
        return STATES[s]
    return (raw.strip().upper()[:30], WHITE, "other")

def time_left(raw):
    n = num(raw, None)
    if n == None or n < 0 or n >= 1440:
        return None
    n = int(n + 0.5)
    if n < 60:
        return [str(n), " MIN LEFT"]
    return [str(n // 60) + "H" + ("0" + str(n % 60))[-2:], " LEFT"]

def bottom_line(mode, left):
    if left != None and mode in ["run", "wait"]:
        return [render.Text(left[0], font = "tom-thumb", color = WHITE), render.Text(left[1], font = "tom-thumb", color = DIM)]
    text = {"run": "WASHING", "done": "UNLOAD ME", "alert": "SEE MACHINE"}.get(mode)
    if text == None:
        return []
    return [render.Text(text, font = "tom-thumb", color = GREEN if mode == "done" else DIM)]

def frame(word, color, mode, left, f):
    body = BODY_OFF if mode in ["idle", "none"] else BODY
    children = [render.Box(width = 64, height = 32)]
    children.extend(pixels(ICON, ICON_X, ICON_Y, body))
    children.extend(pixels(ICON, ICON_X, ICON_Y, AMBER if mode == "run" else body, "d"))
    children.extend(pixels(ICON, ICON_X, ICON_Y, color if mode != "none" else BODY_OFF, "o"))

    if mode == "run":
        # Water drops falling behind the door.
        for i, col in enumerate([3, 6, 9]):
            y = (f + i * 5) % INSIDE_ROWS
            h = 2 if y < INSIDE_ROWS - 1 else 1
            children.append(dot(col, INSIDE_TOP + y, BLUE, 1, h))

    if mode == "done":
        # Three sparkles that take turns twinkling.
        for i, (sx, sy) in enumerate([(4, 10), (9, 14), (5, 18)]):
            on = (f + 4 * i) % 12 < 4
            c = WHITE if on else dim(GREEN, 0.4)
            children.append(dot(sx, sy, c))
            if on:
                children.append(dot(sx - 1, sy, dim(AMBER, 0.8)))
                children.append(dot(sx + 1, sy, dim(AMBER, 0.8)))
                children.append(dot(sx, sy - 1, dim(AMBER, 0.8)))
                children.append(dot(sx, sy + 1, dim(AMBER, 0.8)))

    children.append(at(19, 2, render.Text("DISHWASHER", font = "tom-thumb", color = DIM)))

    word_color = dim(color, PULSE[f % len(PULSE)]) if mode == "done" else color
    if mode == "other" and len(word) > 7:
        status = render.Marquee(width = 45, child = render.Text(word, font = "6x13", color = word_color))
    else:
        status = render.Text(word, font = "6x13", color = word_color)
    children.append(at(19, 9, status))

    line = bottom_line(mode, left)
    if line:
        children.append(at(19, 25, render.Row(children = line)))

    return render.Box(width = 64, height = 32, color = "#000000", child = render.Stack(children = children))

def main(config):
    word, color, mode = state_of(config.get("ha_dishwasher", "running"))
    left = time_left(config.get("ha_dishwasher_remaining", "42"))

    if mode == "run":
        count, delay = INSIDE_ROWS, 90
    elif mode == "done":
        count, delay = len(PULSE), 100
    else:
        return render.Root(child = frame(word, color, mode, left, 0))

    return render.Root(delay = delay, child = render.Animation(children = [frame(word, color, mode, left, f) for f in range(count)]))
