#!/home/sam/dev/perso/flsun/klippy-env/bin/python
"""Calibration delta etendue (longueur des bras) — saisie guidee des mesures.

    ./analyze-delta.py

LA PIECE (calibrate_size.stl) : un pilier central, six piliers autour a 65 mm
d'entraxe. Chaque pilier est un cylindre de 10 mm avec SIX MEPLATS fraises,
un par rayon, 0.5 mm de profondeur : ce sont les FACES DE MESURE.
  - de meplat a meplat, le long d'un rayon : 9.0 mm nominal (pas 10 : si tu
    lis ~10.3, tu es sur les arrondis entre deux meplats, pas sur les meplats)
  - ecart ENTRE deux piliers, becs interieurs sur leurs meplats en vis-a-vis :
    56.0 mm nominal
  - distance PAR L'EXTERIEUR, becs exterieurs sur les meplats opposes des deux
    piliers : 74.0 mm nominal — c'est LA convention de Klipper
    (delta_calibrate.py : distance mesuree - largeur du pilier = 65).
Le script accepte les deux conventions pour les distances et convertit :
    exterieur = interieur + largeur(pilier 1) + largeur(pilier 2)
(les largeurs etant celles de meplat a meplat, le long de la meme ligne).

Trois piliers portent A, B, C et pointent vers leur tour ; les trois autres
sont "en face de" une lettre. Ordre : A, puis ANTI-horaire.

Saisies MEMORISEES dans mesures-delta.json (Ctrl-D puis relancer = rien de
perdu). A chaque invite : <nombre> (virgule ou point), Entree = garder la
valeur proposee, r = revenir a la precedente. Recapitulatif numerote a la fin
(n=valeur pour corriger). Rien n'est envoye a Klipper avant le 'o' final.
"""
import importlib.util
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
FICHIER = os.path.join(HERE, "mesures-delta.json")
spec = importlib.util.spec_from_file_location("kc", os.path.join(HERE, "klippy-client.py"))
kc = importlib.util.module_from_spec(spec)
spec.loader.exec_module(kc)

NOM_INT, NOM_EXT, NOM_LARG = 56.0, 74.0, 9.0

# (parametre, titre, [(libelle, cle_nominal, bornes de plausibilite)])
ETAPES = [
    ("CENTER_DISTS", "Pilier CENTRAL <-> chaque pilier (piece sur le plateau)", [
        "centre <-> pilier A",
        "centre <-> pilier EN FACE de C",
        "centre <-> pilier B",
        "centre <-> pilier EN FACE de A",
        "centre <-> pilier C",
        "centre <-> pilier EN FACE de B",
    ]),
    ("OUTER_DISTS", "Piliers voisins, anti-horaire depuis A", [
        "A <-> en face de C",
        "en face de C <-> B",
        "B <-> en face de A",
        "en face de A <-> C",
        "C <-> en face de B",
        "en face de B <-> A",
    ]),
    ("CENTER_PILLAR_WIDTHS", "Pilier CENTRAL, de MEPLAT a MEPLAT, becs alignes sur le rayon vers...", [
        "centre, meplat a meplat, le long du rayon vers A",
        "centre, meplat a meplat, le long du rayon vers B",
        "centre, meplat a meplat, le long du rayon vers C",
    ]),
    ("OUTER_PILLAR_WIDTHS", "Chaque pilier exterieur, de MEPLAT a MEPLAT, becs alignes vers son voisin anti-horaire", [
        "pilier A, meplats, ligne vers 'en face de C'",
        "pilier 'en face de C', meplats, ligne vers B",
        "pilier B, meplats, ligne vers 'en face de A'",
        "pilier 'en face de A', meplats, ligne vers C",
        "pilier C, meplats, ligne vers 'en face de B'",
        "pilier 'en face de B', meplats, ligne vers A",
    ]),
]
TOUTES = [(p, lib) for p, _, libs in ETAPES for lib in libs]   # 21 entrees


def nominal(i, mode):
    if i < 12:
        return NOM_INT if mode == "int" else NOM_EXT
    return NOM_LARG


def bornes(i, mode):
    n = nominal(i, mode)
    if i < 12:
        return n * 0.95, n * 1.05
    return 8.3, 9.8      # au-dela de ~9.8 on est sur les arrondis, pas les meplats


def charger():
    try:
        d = json.load(open(FICHIER))
    except (OSError, ValueError):
        d = {}
    vals = [d.get(str(i)) for i in range(len(TOUTES))]
    return vals, d.get("mode")


def sauver(vals, mode):
    d = {str(i): v for i, v in enumerate(vals) if v is not None}
    d["mode"] = mode
    json.dump(d, open(FICHIER, "w"), indent=1)


def parse(s):
    v = float(s.replace(",", "."))
    if v <= 0:
        raise ValueError
    return v


def choisir_mode(mode):
    print("\nTes 12 DISTANCES sont mesurees comment ?")
    print("  i  par l'INTERIEUR : becs entre les deux piliers, sur leurs meplats en vis-a-vis (~56)")
    print("  e  par l'EXTERIEUR : becs refermes sur les meplats opposes des deux piliers (~74)")
    while True:
        s = input("  mode [%s] : " % ({"int": "i", "ext": "e"}.get(mode, "i"))).strip().lower()
        if s == "" and mode:
            return mode
        if s in ("i", "e"):
            return "int" if s == "i" else "ext"


def saisir(vals, mode):
    i = 0
    dernier_param = None
    while i < len(TOUTES):
        param, lib = TOUTES[i]
        if param != dernier_param:
            titre = next(t for p, t, _ in ETAPES if p == param)
            print("\n=== %s ===" % titre)
            dernier_param = param
        defaut = vals[i]
        invite = "  %2d. %s [~%g]%s : " % (i + 1, lib, nominal(i, mode),
                                           ("  (Entree = %g)" % defaut) if defaut is not None else "")
        try:
            s = input(invite).strip()
        except EOFError:
            print("\n(memorise dans mesures-delta.json — relancer pour reprendre)")
            sys.exit(0)
        if s == "" and defaut is not None:
            i += 1
            continue
        if s.lower() == "r":
            i = max(0, i - 1)
            dernier_param = None
            continue
        try:
            v = parse(s)
        except ValueError:
            print("    -> un nombre en mm, Entree pour garder, r pour revenir")
            continue
        lo, hi = bornes(i, mode)
        if not (lo <= v <= hi) and vals[i] != v:
            if i >= 12 and v > hi:
                print("    -> %g : tu es probablement sur les ARRONDIS. De meplat a meplat c'est ~9. Retape pour forcer." % v)
            else:
                print("    -> %g semble loin de ~%g (attendu %.1f..%.1f). Retape la meme valeur pour forcer." % (v, nominal(i, mode), lo, hi))
            vals[i] = v
            sauver(vals, mode)
            continue
        vals[i] = v
        sauver(vals, mode)
        i += 1
    return vals


def recap(vals, mode):
    while True:
        print("\n=== recapitulatif (distances : %s) ===" % ("interieur ~56" if mode == "int" else "exterieur ~74"))
        for i, ((param, lib), v) in enumerate(zip(TOUTES, vals), start=1):
            print("  %2d. %-46s %7.2f   (%s)" % (i, lib, v, param))
        s = input("\nEntree = tout est bon, 'n=valeur' pour corriger, 'mode' pour changer la convention : ").strip()
        if s == "":
            return vals, mode
        if s.lower() == "mode":
            mode = choisir_mode(mode)
            sauver(vals, mode)
            continue
        try:
            n, v = s.split("=")
            vals[int(n) - 1] = parse(v)
            sauver(vals, mode)
        except (ValueError, IndexError):
            print("  -> format : numero=valeur")


def commandes_klipper(vals, mode):
    cd, od = vals[0:6], vals[6:12]
    cw = vals[12:15]                       # centre vers A, B, C
    ow = vals[15:21]                       # A, farC, B, farA, C, farB
    if mode == "int":
        # largeur du centre le long de chaque rayon : A, farC(=vers C? non : farC est
        # l'oppose de C, meme axe que C), B, farA (axe A), C, farB (axe B)
        centre_sur_axe = [cw[0], cw[2], cw[1], cw[0], cw[2], cw[1]]
        cd = [d + c + o for d, c, o in zip(cd, centre_sur_axe, ow)]
        # voisins : pilier i et son voisin anti-horaire (i+1)
        od = [d + ow[i] + ow[(i + 1) % 6] for i, d in enumerate(od)]
    fmt = lambda xs: ",".join("%.3f" % x for x in xs)
    return [
        "DELTA_ANALYZE CENTER_DISTS=%s" % fmt(cd),
        "DELTA_ANALYZE OUTER_DISTS=%s" % fmt(od),
        "DELTA_ANALYZE CENTER_PILLAR_WIDTHS=%s" % fmt(cw),
        "DELTA_ANALYZE OUTER_PILLAR_WIDTHS=%s" % fmt(ow),
        "DELTA_ANALYZE SCALE=1.0",
    ]


def main():
    print(__doc__)
    vals, mode = charger()
    if any(v is not None for v in vals):
        print("(%d mesure(s) deja memorisee(s) — Entree pour les garder)" % sum(v is not None for v in vals))
    mode = choisir_mode(mode)
    sauver(vals, mode)
    vals = saisir(vals, mode)
    vals, mode = recap(vals, mode)

    commandes = commandes_klipper(vals, mode)
    print("\nCommandes a envoyer (distances converties en convention Klipper, ~74) :")
    for c in commandes:
        print("  " + c)
    if input("\nEnvoyer et lancer le calcul (o/N) ? ").strip().lower() != "o":
        sys.exit("abandon, rien envoye (les mesures restent memorisees)")

    k = kc.Klippy()
    for c in commandes:
        if not k.run(c):
            sys.exit("erreur sur : " + c)
    print("\nCalcul en cours (peut prendre plusieurs minutes)...")
    if not k.run("DELTA_ANALYZE CALIBRATE=extended"):
        sys.exit("le calcul a echoue")
    print("\nNouvelles valeurs ci-dessus (rayon, angles, butees, BRAS).")
    if input("SAVE_CONFIG maintenant ? Klipper redemarre. (o/N) ").strip().lower() == "o":
        k.run("SAVE_CONFIG")
        print("sauvegarde envoyee - attendre ~15 s, puis homing.")
    else:
        print("non sauvegarde. Pour appliquer plus tard : ./send-gcode.sh SAVE_CONFIG")
    k.close()


if __name__ == "__main__":
    main()
