########################################################################################
# raquetteMach1 est un script python indépendant (sans Kstars/Ekos ou autre) pour piloter
# une monture Mach1 AP connectée à une raspberryPi 5 avec un cable USB-Série. Il ne 
# remplace pas, loin s'en faut Kstar/Ekos ou la Raquette AP mais il s'avère utile pour piloter 
# la monture si la raquette fait défaut et/où pour du visuel, ou encore avec un autre 
# logiciel d'acquisition
# Ce script s'appuie sur INDI : indiserver et indi_lx200ap_v2. 
# La monture mach1 doit disposer d'un chipset récent.
# Développement : Jean Moucaud ... et Claude AI. 
# V 1.00 novembre 2026
######################################################################################

import sys
import glob
import time
import traceback
from datetime import datetime, timezone

from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
                             QHBoxLayout, QPushButton, QComboBox, QLabel,
                             QTextEdit, QGroupBox, QGridLayout, QDoubleSpinBox,
                             QDateTimeEdit, QCheckBox, QMessageBox)
from PyQt6.QtCore import (QProcess, QTimer, QObject, QSettings, QDateTime,
                          QDate, QTime, pyqtSignal)
import PyIndi


def excepthook(exc_type, exc, tb):
    """Évite que PyQt6 ne fasse un 'Aborted' sur une exception non rattrapée."""
    traceback.print_exception(exc_type, exc, tb)


sys.excepthook = excepthook


# ======================================================================
#  Client INDI
# ======================================================================
class IndiClient(PyIndi.BaseClient):
    """Client INDI : les callbacks arrivent dans un thread non-Qt,
    on les relaie donc à l'interface par des signaux Qt."""

    def __init__(self, bridge):
        super().__init__()
        self.bridge = bridge

    def newDevice(self, d):
        self.bridge.device_found.emit(d.getDeviceName())

    def newProperty(self, p):
        self.bridge.property_found.emit(p.getDeviceName(), p.getName())

    def serverConnected(self):
        self.bridge.message.emit("Connecté au serveur INDI.")

    def serverDisconnected(self, code):
        self.bridge.message.emit(f"Déconnecté du serveur INDI (code {code}).")

    def newMessage(self, d, m):
        pass

    # Callbacks à définir pour les anciennes versions de pyindi-client
    def removeDevice(self, d): pass
    def removeProperty(self, p): pass
    def updateProperty(self, p): pass
    def newBLOB(self, bp): pass
    def newSwitch(self, svp): pass
    def newNumber(self, nvp): pass
    def newText(self, tvp): pass
    def newLight(self, lvp): pass


class Bridge(QObject):
    device_found = pyqtSignal(str)
    property_found = pyqtSignal(str, str)
    message = pyqtSignal(str)


# ======================================================================
#  Helpers de compatibilité PyIndi (anciennes / nouvelles versions)
# ======================================================================
def elem_name(e):
    return e.getName() if hasattr(e, "getName") else e.name


def find_elem(prop, name):
    for e in prop:
        if elem_name(e) == name:
            return e
    return None


def set_state(sw, on):
    state = PyIndi.ISS_ON if on else PyIndi.ISS_OFF
    if hasattr(sw, "setState"):
        sw.setState(state)
    else:
        sw.s = state


def is_on(sw):
    st = sw.getState() if hasattr(sw, "getState") else sw.s
    return st == PyIndi.ISS_ON


def set_text(tw, value):
    if hasattr(tw, "setText"):
        tw.setText(value)
    else:
        tw.text = value


def num_value(e):
    return e.getValue() if hasattr(e, "getValue") else e.value


def set_number(e, value):
    if hasattr(e, "setValue"):
        e.setValue(value)
    else:
        e.value = value


def prop_state(p):
    return p.getState() if hasattr(p, "getState") else p.s


# ======================================================================
#  Formatage des coordonnées
# ======================================================================
def fmt_ra(hours):
    """Ascension droite en heures décimales -> 'HHh MMm SS.Ss'."""
    tenths = int(round((hours % 24) * 36000)) % (24 * 36000)
    hh = tenths // 36000
    rem = tenths % 36000
    mm = rem // 600
    ss = (rem % 600) / 10.0
    return f"{hh:02d}h {mm:02d}m {ss:04.1f}s"


def fmt_dec(deg):
    """Déclinaison en degrés décimaux -> '+DD° MM' SS"'."""
    sign = "-" if deg < 0 else "+"
    total = int(round(abs(deg) * 3600))
    d = total // 3600
    m = (total % 3600) // 60
    s = total % 60
    return f"{sign}{d:02d}\u00b0 {m:02d}' {s:02d}\""


# ======================================================================
#  Application
# ======================================================================
class Mach1ControlApp(QMainWindow):
    WIDTH_SIMPLE = 560
    WIDTH_ADVANCED = 1000

    # ------------------------------------------------------- Styles Jour / Nuit
    DAY_STYLE = """
        QPushButton {
            background-color: #3498db;
            color: white;
            border-radius: 8px;
            border: 2px solid #2980b9;
            font-size: 14px;
            font-weight: bold;
            padding: 10px;
        }
        QPushButton:hover {
            background-color: #2980b9;
            border-color: #1c5980;
        }
        QPushButton:pressed {
            background-color: #1f618d;
        }
    """

    NIGHT_STYLE = """
        QMainWindow, QWidget {
            background-color: #ff9a9a;          /* fond rouge clair */
            color: #3a0000;
        }
        QGroupBox {
            border: 1px solid #7a0000;
            border-radius: 6px;
            margin-top: 8px;
            font-weight: bold;
        }
        QGroupBox::title {
            subcontrol-origin: margin;
            left: 10px;
            padding: 0 4px;
        }
        QPushButton {
            background-color: #7a0000;          /* boutons rouge foncé */
            color: #ffb0b0;
            border-radius: 8px;
            border: 2px solid #4a0000;
            font-size: 14px;
            font-weight: bold;
            padding: 10px;
        }
        QPushButton:hover {
            background-color: #630000;
            border-color: #330000;
        }
        QPushButton:pressed {
            background-color: #400000;
        }
        QPushButton:checked {
            background-color: #4a0000;
        }
        QPushButton:disabled {
            background-color: #b06060;
            color: #d89090;
            border-color: #a05050;
        }
        QComboBox, QDoubleSpinBox, QDateTimeEdit, QTextEdit {
            background-color: #ffc4c4;
            color: #3a0000;
            border: 1px solid #7a0000;
            selection-background-color: #7a0000;
            selection-color: #ffb0b0;
        }
        QComboBox QAbstractItemView {
            background-color: #ffc4c4;
            color: #3a0000;
            selection-background-color: #7a0000;
            selection-color: #ffb0b0;
        }
        QCheckBox { color: #3a0000; }
    """

    def __init__(self):
        super().__init__()
        self.setWindowTitle("Contrôleur Mach1 - RPI5 V1.00 (pour INDI) JMoucaud Nov 2026")
        self.resize(self.WIDTH_SIMPLE, 700)

        self.settings = QSettings("MonAstroPy", "Mach1")
        self.night_mode = self.settings.value("night_mode", False, type=bool)

        self.indi_server_process = QProcess()
        self.indi_server_process.setProcessChannelMode(
            QProcess.ProcessChannelMode.MergedChannels)
        self.indi_server_process.readyReadStandardOutput.connect(self.on_server_output)
        self.indi_server_process.errorOccurred.connect(self.on_server_error)
        self.indi_server_process.finished.connect(self.on_server_finished)

        self.device_name = None
        self.mount_connected = False
        self.is_parked = None
        self._last_poll_error = None

        self.bridge = Bridge()
        self.bridge.device_found.connect(self.on_device_found)
        self.bridge.property_found.connect(self.on_property_found)
        self.bridge.message.connect(self.log)

        self.indi_client = IndiClient(self.bridge)
        self.indi_client.setServer("localhost", 7624)

        self.init_ui()
        self.refresh_ports()
        self.reset_time_to_now()
        self.reset_mount_state()

        # Rafraîchissement de l'état de la monture (1 fois par seconde)
        self.timer = QTimer(self)
        self.timer.timeout.connect(self.tick)
        self.timer.start(1000)

    # ------------------------------------------------------------------ UI
    def init_ui(self):
        main_widget = QWidget()
        self.setCentralWidget(main_widget)
        root = QVBoxLayout(main_widget)

        columns = QHBoxLayout()
        left = QVBoxLayout()
        # La colonne de droite est dans un widget, masqué par défaut
        self.right_panel = QWidget()
        right = QVBoxLayout(self.right_panel)
        right.setContentsMargins(0, 0, 0, 0)
        columns.addLayout(left, 1)
        columns.addWidget(self.right_panel, 1)
        self.right_panel.setVisible(False)
        root.addLayout(columns)

        # ---------------------------------------------------- Colonne gauche
        # Serveur
        server_group = QGroupBox("Gestion du Serveur INDI")
        server_layout = QHBoxLayout()
        self.btn_start_server = QPushButton("▶ Démarrer indi_lx200ap_v2")
        self.btn_start_server.clicked.connect(self.start_indi_server)
        self.btn_stop_server = QPushButton("■ Arrêter")
        self.btn_stop_server.clicked.connect(self.stop_indi_server)
        self.btn_stop_server.setEnabled(False)
        self.lbl_server = QLabel("● Arrêté")
        self.lbl_server.setStyleSheet("color: gray; font-weight: bold;")
        server_layout.addWidget(self.btn_start_server)
        server_layout.addWidget(self.btn_stop_server)
        server_layout.addWidget(self.lbl_server)
        server_group.setLayout(server_layout)
        left.addWidget(server_group)

        # Port série
        port_group = QGroupBox("Port série de la monture")
        port_layout = QHBoxLayout()
        port_layout.addWidget(QLabel("Port :"))
        self.combo_port = QComboBox()
        self.combo_port.setEditable(True)
        self.combo_port.setMinimumWidth(200)
        port_layout.addWidget(self.combo_port, 1)
        self.btn_refresh_ports = QPushButton("⟳")
        self.btn_refresh_ports.setToolTip("Rechercher les ports série")
        self.btn_refresh_ports.setMaximumWidth(40)
        self.btn_refresh_ports.clicked.connect(self.refresh_ports)
        port_layout.addWidget(self.btn_refresh_ports)
        port_group.setLayout(port_layout)
        left.addWidget(port_group)

        # Connexion
        conn_group = QGroupBox("Connexion Monture")
        conn_layout = QHBoxLayout()
        self.btn_connect_pilot = QPushButton("Se connecter au Pilote LX200AP")
        self.btn_connect_pilot.clicked.connect(self.connect_to_pilot)
        self.lbl_status = QLabel("Statut : Déconnecté")
        conn_layout.addWidget(self.btn_connect_pilot)
        conn_layout.addWidget(self.lbl_status)
        conn_group.setLayout(conn_layout)
        left.addWidget(conn_group)

        # Vitesse
        speed_group = QGroupBox("Configuration de la Vitesse")
        speed_layout = QHBoxLayout()
        speed_layout.addWidget(QLabel("Vitesse :"))
        self.combo_speed = QComboBox()
        self.combo_speed.addItems(["Guide (0.5x)", "Centrage (8x)",
                                   "Recherche (32x)", "Maximum (Slew)"])
        self.combo_speed.activated.connect(self.change_speed)
        speed_layout.addWidget(self.combo_speed, 1)
        speed_group.setLayout(speed_layout)
        left.addWidget(speed_group)

        # Raquette
        pad_group = QGroupBox("Contrôle Manuel (Flèches)")
        pad_layout = QGridLayout()
        self.btn_up = QPushButton("▲ N")
        self.btn_down = QPushButton("▼ S")
        self.btn_left = QPushButton("◀ W")
        self.btn_right = QPushButton("▶ E")
        self.btn_stop = QPushButton("■ STOP")
        for b in (self.btn_up, self.btn_down, self.btn_left, self.btn_right,
                  self.btn_stop):
            b.setMinimumHeight(45)

        for btn, d in [(self.btn_up, "North"), (self.btn_down, "South"),
                       (self.btn_left, "West"), (self.btn_right, "East")]:
            btn.pressed.connect(lambda d=d: self.move_mount(d))
            btn.released.connect(self.stop_mount)
        self.btn_stop.clicked.connect(self.emergency_stop)

        pad_layout.addWidget(self.btn_up, 0, 1)
        pad_layout.addWidget(self.btn_left, 1, 0)
        pad_layout.addWidget(self.btn_stop, 1, 1)
        pad_layout.addWidget(self.btn_right, 1, 2)
        pad_layout.addWidget(self.btn_down, 2, 1)
        pad_group.setLayout(pad_layout)
        left.addWidget(pad_group)
        left.addStretch(1)

        # Bascule Jour / Nuit
        self.btn_night = QPushButton("🌙 Mode NUIT")
        self.btn_night.setCheckable(True)
        self.btn_night.setChecked(self.night_mode)
        self.btn_night.setToolTip(
            "Bascule entre l'affichage jour et l'affichage nuit (rouge)")
        self.btn_night.toggled.connect(self.toggle_night)
        left.addWidget(self.btn_night)

        self.btn_advanced = QPushButton("Mode avancé ▶")
        self.btn_advanced.setCheckable(True)
        self.btn_advanced.setToolTip("Afficher position RA/DEC, parking, heure et lieu")
        self.btn_advanced.toggled.connect(self.toggle_advanced)
        left.addWidget(self.btn_advanced)

        # --------------------------------------------------- Colonne droite
        # Position en temps réel
        pos_group = QGroupBox("Position de la monture (temps réel)")
        pos_layout = QVBoxLayout()
        mono = "font-family: monospace; font-size: 20px; font-weight: bold;"
        self.lbl_ra = QLabel()
        self.lbl_ra.setStyleSheet(mono)
        self.lbl_dec = QLabel()
        self.lbl_dec.setStyleSheet(mono)
        self.lbl_motion = QLabel()
        pos_layout.addWidget(self.lbl_ra)
        pos_layout.addWidget(self.lbl_dec)
        pos_layout.addWidget(self.lbl_motion)
        pos_group.setLayout(pos_layout)
        right.addWidget(pos_group)

        # Park / Unpark
        park_group = QGroupBox("Parking")
        park_layout = QGridLayout()
        self.btn_park = QPushButton("🅿 Park")
        self.btn_park.clicked.connect(self.park_mount)
        self.btn_unpark = QPushButton("Unpark")
        self.btn_unpark.clicked.connect(self.unpark_mount)
        self.lbl_park = QLabel("État : inconnu")
        park_layout.addWidget(self.btn_park, 0, 0)
        park_layout.addWidget(self.btn_unpark, 0, 1)
        park_layout.addWidget(self.lbl_park, 1, 0, 1, 2)
        park_group.setLayout(park_layout)
        right.addWidget(park_group)

        # Heure
        time_group = QGroupBox("Heure")
        time_layout = QGridLayout()
        self.lbl_sysclock = QLabel("--")
        time_layout.addWidget(QLabel("Horloge du RPi (UTC) :"), 0, 0)
        time_layout.addWidget(self.lbl_sysclock, 0, 1, 1, 2)

        self.dt_utc = QDateTimeEdit()
        self.dt_utc.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
        self.dt_utc.setCalendarPopup(True)
        self.btn_now = QPushButton("Maintenant")
        self.btn_now.setToolTip("Reprendre l'heure de l'horloge du RPi")
        self.btn_now.clicked.connect(self.reset_time_to_now)
        time_layout.addWidget(QLabel("Heure à envoyer (UTC) :"), 1, 0)
        time_layout.addWidget(self.dt_utc, 1, 1)
        time_layout.addWidget(self.btn_now, 1, 2)

        self.spin_offset = QDoubleSpinBox()
        self.spin_offset.setRange(-12.0, 14.0)
        self.spin_offset.setDecimals(2)
        self.spin_offset.setSingleStep(0.5)
        self.spin_offset.setSuffix(" h")
        saved_offset = self.settings.value("utc_offset", None)
        if saved_offset is None:
            saved_offset = time.localtime().tm_gmtoff / 3600.0
        self.spin_offset.setValue(float(saved_offset))
        time_layout.addWidget(QLabel("Décalage local (+ = est) :"), 2, 0)
        time_layout.addWidget(self.spin_offset, 2, 1)

        self.btn_send_time = QPushButton("Envoyer l'heure à la monture")
        self.btn_send_time.clicked.connect(self.send_time)
        time_layout.addWidget(self.btn_send_time, 3, 0, 1, 3)
        time_group.setLayout(time_layout)
        right.addWidget(time_group)

        # Lieu
        loc_group = QGroupBox("Lieu d'observation")
        loc_layout = QGridLayout()
        self.spin_lat = QDoubleSpinBox()
        self.spin_lat.setRange(-90.0, 90.0)
        self.spin_lat.setDecimals(5)
        self.spin_lat.setSingleStep(0.01)
        self.spin_lat.setSuffix(" °")
        self.spin_lon = QDoubleSpinBox()
        self.spin_lon.setRange(-180.0, 180.0)
        self.spin_lon.setDecimals(5)
        self.spin_lon.setSingleStep(0.01)
        self.spin_lon.setSuffix(" °")
        self.spin_elev = QDoubleSpinBox()
        self.spin_elev.setRange(-500.0, 9000.0)
        self.spin_elev.setDecimals(0)
        self.spin_elev.setSuffix(" m")
        self.spin_lat.setValue(float(self.settings.value("lat", 0.0)))
        self.spin_lon.setValue(float(self.settings.value("lon", 0.0)))
        self.spin_elev.setValue(float(self.settings.value("elev", 0.0)))

        loc_layout.addWidget(QLabel("Latitude (N + / S −) :"), 0, 0)
        loc_layout.addWidget(self.spin_lat, 0, 1)
        loc_layout.addWidget(QLabel("Longitude (E + / O −) :"), 1, 0)
        loc_layout.addWidget(self.spin_lon, 1, 1)
        loc_layout.addWidget(QLabel("Altitude :"), 2, 0)
        loc_layout.addWidget(self.spin_elev, 2, 1)

        self.btn_send_loc = QPushButton("Envoyer le lieu")
        self.btn_send_loc.clicked.connect(self.send_location)
        self.btn_read_loc = QPushButton("Lire depuis la monture")
        self.btn_read_loc.clicked.connect(self.read_location)
        loc_layout.addWidget(self.btn_send_loc, 3, 0)
        loc_layout.addWidget(self.btn_read_loc, 3, 1)

        self.chk_auto = QCheckBox("Envoyer heure + lieu automatiquement à la connexion")
        self.chk_auto.setChecked(self.settings.value("auto_send", True, type=bool))
        loc_layout.addWidget(self.chk_auto, 4, 0, 1, 2)
        loc_group.setLayout(loc_layout)
        right.addWidget(loc_group)
        right.addStretch(1)

        # ------------------------------------------------------------ Logs
        self.log_box = QTextEdit()
        self.log_box.setReadOnly(True)
        self.log_box.setMinimumHeight(140)
        root.addWidget(self.log_box)

        # Thème appliqué en dernier, quand tous les widgets existent
        self.apply_theme()

    def log(self, message):
        self.log_box.append(f"[{time.strftime('%H:%M:%S')}] {message}")

    def toggle_advanced(self, checked):
        """Affiche / masque la colonne de droite (mode avancé)."""
        self.right_panel.setVisible(checked)
        self.btn_advanced.setText("◀ Mode simple" if checked else "Mode avancé ▶")
        # On force Qt à recalculer la taille minimale AVANT de redimensionner,
        # sinon l'ancienne largeur minimale empêche la fenêtre de rétrécir.
        self.centralWidget().layout().activate()
        self.setMinimumWidth(0)
        width = self.WIDTH_ADVANCED if checked else self.WIDTH_SIMPLE
        self.resize(width, self.height())
        # Deuxième passage une fois la mise en page terminée (par sécurité)
        QTimer.singleShot(0, lambda: self.resize(width, self.height()))

    # ------------------------------------------------------- Jour / Nuit
    def toggle_night(self, checked):
        self.night_mode = checked
        self.settings.setValue("night_mode", checked)
        self.apply_theme()

    def apply_theme(self):
        night = self.night_mode
        self.setStyleSheet(self.NIGHT_STYLE if night else self.DAY_STYLE)
        self.btn_night.setText("☀ Mode JOUR" if night else "🌙 Mode NUIT")
        # Le voyant du serveur utilise un style local : on le rafraîchit
        running = (self.indi_server_process.state()
                   != QProcess.ProcessState.NotRunning)
        self.set_server_state(running)

    # ------------------------------------------------------------ Port série
    def refresh_ports(self):
        current = self.combo_port.currentText().strip()
        saved = self.settings.value("serial_port", "/dev/ttyUSB0")

        ports = []
        for pattern in ("/dev/serial/by-id/*", "/dev/ttyUSB*",
                        "/dev/ttyACM*", "/dev/ttyAMA*", "/dev/ttyS[0-9]*"):
            ports += sorted(glob.glob(pattern))

        self.combo_port.clear()
        self.combo_port.addItems(ports)

        wanted = current or saved
        idx = self.combo_port.findText(wanted)
        if idx >= 0:
            self.combo_port.setCurrentIndex(idx)
        else:
            self.combo_port.setEditText(wanted)

        self.log(f"{len(ports)} port(s) série détecté(s)." if ports
                 else "Aucun port série détecté (saisissez-le à la main).")

    def apply_port(self):
        port = self.combo_port.currentText().strip()
        if not port:
            self.log("Aucun port série renseigné.")
            return False

        dev = self.get_device()
        if dev is None:
            return False
        tp = dev.getText("DEVICE_PORT")
        if not tp:
            self.log("Propriété DEVICE_PORT introuvable (pilote pas prêt ?).")
            return False

        set_text(tp[0], port)
        self.send_property(tp, "text")
        self.settings.setValue("serial_port", port)
        self.log(f"Port série réglé sur {port}")
        return True

    # ----------------------------------------------------- Callbacks INDI
    def on_device_found(self, name):
        self.device_name = name
        self.log(f"Périphérique détecté : '{name}'")

    def on_property_found(self, dev, prop):
        if prop == "CONNECTION":
            self.log("Propriété CONNECTION disponible, vous pouvez vous connecter.")

    # ------------------------------------------------------ Helpers INDI
    def _dev(self):
        """Accès silencieux au périphérique (pour le rafraîchissement)."""
        if not self.device_name or not self.indi_client.isServerConnected():
            return None
        return self.indi_client.getDevice(self.device_name)

    def get_device(self):
        if not self.device_name:
            self.log("Aucun périphérique INDI détecté pour l'instant.")
            return None
        return self.indi_client.getDevice(self.device_name)

    def get_switch(self, prop_name):
        dev = self.get_device()
        if dev is None:
            return None
        prop = dev.getSwitch(prop_name)
        if not prop:
            self.log(f"Propriété '{prop_name}' introuvable.")
            return None
        return prop

    def send_property(self, prop, kind="switch"):
        try:
            self.indi_client.sendNewProperty(prop)
        except AttributeError:
            # Anciennes versions de pyindi-client
            {"switch": self.indi_client.sendNewSwitch,
             "text": self.indi_client.sendNewText,
             "number": self.indi_client.sendNewNumber}[kind](prop)

    def set_switch(self, prop_name, on_names):
        """Met à ON les éléments listés (par nom), tous les autres à OFF."""
        prop = self.get_switch(prop_name)
        if prop is None:
            return False
        for sw in prop:
            set_state(sw, elem_name(sw) in on_names)
        self.send_property(prop, "switch")
        return True

    # ----------------------------------------------------------- Serveur
    def set_server_state(self, running):
        self.btn_start_server.setEnabled(not running)
        self.btn_stop_server.setEnabled(running)
        if running:
            self.lbl_server.setText("● EN SERVICE")
            color = "#4a0000" if self.night_mode else "green"
        else:
            self.lbl_server.setText("● Arrêté")
            color = "#7a3030" if self.night_mode else "gray"
        self.lbl_server.setStyleSheet(f"color: {color}; font-weight: bold;")

    def start_indi_server(self):
        if self.indi_server_process.state() != QProcess.ProcessState.NotRunning:
            self.log("Le serveur INDI est déjà lancé.")
            return
        self.log("Démarrage : indiserver -v indi_lx200ap_v2")
        self.indi_server_process.start("indiserver", ["-v", "indi_lx200ap_v2"])
        if not self.indi_server_process.waitForStarted(3000):
            return  # l'erreur est affichée par on_server_error
        self.set_server_state(True)
        self.log("Serveur INDI lancé (port 7624).")

    def stop_indi_server(self):
        if self.indi_server_process.state() == QProcess.ProcessState.NotRunning:
            self.set_server_state(False)
            return
        self.log("Arrêt du serveur INDI...")
        if self.indi_client.isServerConnected():
            self.indi_client.disconnectServer()
        self.indi_server_process.terminate()
        if not self.indi_server_process.waitForFinished(3000):
            self.indi_server_process.kill()
        self.device_name = None
        self.reset_mount_state()

    def on_server_output(self):
        data = bytes(self.indi_server_process.readAllStandardOutput()).decode(
            errors="replace")
        for line in data.splitlines():
            if line.strip():
                self.log(f"[indiserver] {line.strip()}")

    def on_server_error(self, error):
        if error == QProcess.ProcessError.FailedToStart:
            self.log("ERREUR : impossible de lancer 'indiserver'. "
                     "Est-il installé ? (sudo apt install indi-bin)")
        else:
            self.log(f"Erreur du serveur INDI : {error.name}")
        self.set_server_state(False)

    def on_server_finished(self, code, status):
        self.log(f"Serveur INDI terminé (code {code}).")
        self.device_name = None
        self.set_server_state(False)
        self.reset_mount_state()

    # ------------------------------------------------------------- Pilote
    def connect_to_pilot(self):
        if self.indi_server_process.state() != QProcess.ProcessState.Running:
            self.log("Démarrez d'abord le serveur INDI.")
            return
        if not self.indi_client.isServerConnected():
            self.log("Connexion au serveur INDI...")
            if not self.indi_client.connectServer():
                self.log("Échec de connexion au serveur INDI.")
                return
            QTimer.singleShot(1500, self.connect_to_pilot)
            return

        # 1) port série, 2) demande de connexion
        if not self.apply_port():
            return
        QTimer.singleShot(500, self.send_connect)

    def send_connect(self):
        if self.set_switch("CONNECTION", {"CONNECT"}):
            self.lbl_status.setText("Statut : Connexion demandée...")
            self.log("Demande de connexion à la monture envoyée.")

    def change_speed(self, index):
        prop = self.get_switch("TELESCOPE_SLEW_RATE")
        if prop is None:
            return
        n = len(prop)
        names = [elem_name(sw) for sw in prop]
        self.log(f"Vitesses du pilote : {names}")
        if index >= n:
            self.log(f"Le pilote n'expose que {n} vitesses.")
            return
        for i, sw in enumerate(prop):
            set_state(sw, i == index)
        self.send_property(prop, "switch")
        self.log(f"Vitesse modifiée : {self.combo_speed.currentText()}")

    # ----------------------------------------------------------- Mouvement
    def move_mount(self, direction):
        if self.is_parked:
            self.log("Monture parquée : faites « Unpark » avant de la déplacer.")
            return
        self.log(f"Mouvement vers : {direction}")
        if direction in ("North", "South"):
            self.set_switch("TELESCOPE_MOTION_NS", {f"MOTION_{direction.upper()}"})
        else:
            self.set_switch("TELESCOPE_MOTION_WE", {f"MOTION_{direction.upper()}"})

    def stop_mount(self):
        for name in ("TELESCOPE_MOTION_NS", "TELESCOPE_MOTION_WE"):
            self.set_switch(name, set())
        self.log("Arrêt du mouvement.")

    def emergency_stop(self):
        """STOP : arrête les mouvements manuels ET interrompt un GoTo / un park."""
        self.stop_mount()
        self.set_switch("TELESCOPE_ABORT_MOTION", {"ABORT"})
        self.log("STOP : abandon de tout mouvement demandé.")

    # ---------------------------------------------------------------- Park
    def park_mount(self):
        rep = QMessageBox.question(
            self, "Park",
            "Envoyer la monture à sa position de park ?\n"
            "Assurez-vous que rien ne gêne son déplacement.")
        if rep != QMessageBox.StandardButton.Yes:
            return
        if self.set_switch("TELESCOPE_PARK", {"PARK"}):
            self.log("Park demandé.")

    def unpark_mount(self):
        if self.set_switch("TELESCOPE_PARK", {"UNPARK"}):
            self.log("Unpark demandé.")

    # ------------------------------------------------- Heure / lieu : envoi
    def reset_time_to_now(self):
        now = datetime.now(timezone.utc)
        self.dt_utc.setDateTime(QDateTime(
            QDate(now.year, now.month, now.day),
            QTime(now.hour, now.minute, now.second)))

    def send_time(self):
        dev = self.get_device()
        if dev is None:
            return False
        tp = dev.getText("TIME_UTC")
        if not tp:
            self.log("Propriété TIME_UTC introuvable (monture connectée ?).")
            return False

        utc = self.dt_utc.dateTime().toString("yyyy-MM-dd'T'HH:mm:ss")
        offset = f"{self.spin_offset.value():.2f}"
        for e in tp:
            if elem_name(e) == "UTC":
                set_text(e, utc)
            elif elem_name(e) == "OFFSET":
                set_text(e, offset)
        self.send_property(tp, "text")
        self.settings.setValue("utc_offset", self.spin_offset.value())
        self.log(f"Heure envoyée : {utc} UTC (décalage {offset} h).")
        return True

    def send_location(self):
        dev = self.get_device()
        if dev is None:
            return False
        gp = dev.getNumber("GEOGRAPHIC_COORD")
        if not gp:
            self.log("Propriété GEOGRAPHIC_COORD introuvable (monture connectée ?).")
            return False

        lat = self.spin_lat.value()
        lon = self.spin_lon.value()
        elev = self.spin_elev.value()
        lon_indi = lon % 360.0  # INDI attend une longitude 0..360 vers l'est
        for e in gp:
            n = elem_name(e)
            if n == "LAT":
                set_number(e, lat)
            elif n == "LONG":
                set_number(e, lon_indi)
            elif n == "ELEV":
                set_number(e, elev)
        self.send_property(gp, "number")
        self.save_location_settings()
        self.log(f"Lieu envoyé : lat {lat:.5f}°, long {lon:.5f}°, alt {elev:.0f} m.")
        return True

    def read_location(self):
        dev = self.get_device()
        if dev is None:
            return
        gp = dev.getNumber("GEOGRAPHIC_COORD")
        if not gp:
            self.log("Propriété GEOGRAPHIC_COORD introuvable (monture connectée ?).")
            return
        for e in gp:
            n = elem_name(e)
            v = num_value(e)
            if n == "LAT":
                self.spin_lat.setValue(v)
            elif n == "LONG":
                self.spin_lon.setValue(v - 360.0 if v > 180.0 else v)
            elif n == "ELEV":
                self.spin_elev.setValue(v)
        self.log("Lieu lu depuis la monture.")

    def save_location_settings(self):
        self.settings.setValue("lat", self.spin_lat.value())
        self.settings.setValue("lon", self.spin_lon.value())
        self.settings.setValue("elev", self.spin_elev.value())

    def auto_send_time_location(self):
        """Envoi automatique après connexion : heure du RPi + lieu saisi."""
        self.reset_time_to_now()
        self.send_time()
        if abs(self.spin_lat.value()) < 1e-9 and abs(self.spin_lon.value()) < 1e-9:
            self.log("Lieu non renseigné (0, 0) : position non envoyée.")
        else:
            QTimer.singleShot(500, self.send_location)

    # ------------------------------------------- Rafraîchissement de l'état
    def tick(self):
        now = datetime.now(timezone.utc)
        self.lbl_sysclock.setText(now.strftime("%Y-%m-%d %H:%M:%S"))
        self.poll_mount()

    def poll_mount(self):
        dev = self._dev()
        if not dev:
            return
        try:
            connected = bool(dev.isConnected())
            if connected != self.mount_connected:
                self.mount_connected = connected
                self.on_connection_changed(connected)
            if not connected:
                return
            self.update_coords(dev)
            self.update_park(dev)
            self._last_poll_error = None
        except Exception as e:
            msg = f"{type(e).__name__}: {e}"
            if msg != self._last_poll_error:
                self._last_poll_error = msg
                self.log(f"Lecture de l'état impossible : {msg}")

    def on_connection_changed(self, connected):
        if connected:
            self.lbl_status.setText("Statut : Connecté à la Mach1")
            self.log("Monture connectée.")
            if self.chk_auto.isChecked():
                QTimer.singleShot(2000, self.auto_send_time_location)
        else:
            self.reset_mount_state()
            self.log("Monture déconnectée.")

    def update_coords(self, dev):
        n = dev.getNumber("EQUATORIAL_EOD_COORD")
        if not n:
            return
        ra = find_elem(n, "RA")
        dec = find_elem(n, "DEC")
        if ra is None or dec is None:
            return
        self.lbl_ra.setText(f"RA   {fmt_ra(num_value(ra))}")
        self.lbl_dec.setText(f"DEC  {fmt_dec(num_value(dec))}")
        if prop_state(n) == PyIndi.IPS_BUSY:
            self.lbl_motion.setText("⏳ Déplacement (GoTo) en cours")
        else:
            self.lbl_motion.setText("● Position stable / suivi")

    def update_park(self, dev):
        p = dev.getSwitch("TELESCOPE_PARK")
        if not p:
            self.lbl_park.setText("État : non géré par le pilote")
            self.is_parked = None
            return
        park = find_elem(p, "PARK")
        parked = park is not None and is_on(park)
        self.is_parked = parked
        if prop_state(p) == PyIndi.IPS_BUSY:
            self.lbl_park.setText("État : ⏳ déplacement vers la position de park...")
        elif parked:
            self.lbl_park.setText("État : 🅿 parquée")
        else:
            self.lbl_park.setText("État : ✔ déparquée")

    def reset_mount_state(self):
        self.mount_connected = False
        self.is_parked = None
        self.lbl_status.setText("Statut : Déconnecté")
        self.lbl_ra.setText("RA   --h --m --.-s")
        self.lbl_dec.setText("DEC  --\u00b0 --' --\"")
        self.lbl_motion.setText("")
        self.lbl_park.setText("État : inconnu")

    # ------------------------------------------------------------ Fermeture
    def closeEvent(self, event):
        self.settings.setValue("serial_port", self.combo_port.currentText().strip())
        self.settings.setValue("utc_offset", self.spin_offset.value())
        self.settings.setValue("auto_send", self.chk_auto.isChecked())
        self.settings.setValue("night_mode", self.night_mode)
        self.save_location_settings()
        self.timer.stop()
        self.stop_indi_server()
        event.accept()


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = Mach1ControlApp()
    window.show()
    sys.exit(app.exec())
