#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# SkillFishOS Fan Control — la curva della ventola in una finestra.
#
# COSA FA E COSA NON FA QUESTA FINESTRA
# Non muove niente. La ventola la muove skillfish-fand, che gira sempre; qui si
# disegna la curva, si scelgono i sensori e si guarda cosa sta succedendo. E'
# una divisione voluta: il raffreddamento non puo' dipendere da una finestra
# aperta, e una finestra non ha bisogno di essere root per mostrare un grafico.
#
# PERCHE' NON COOLERCONTROL
# CoolerControl e' un ottimo programma e fa molto piu' di questo. Ma elenca
# tutto quello che il chip dichiara, e sulle nostre due macchine la maggior
# parte di quei canali e' finta: sulla BC-250 il chip dichiara otto ventole e ne
# esiste una, dichiara quattordici tensioni e ne legge tre, e le due che
# contano davvero — +12V e +5V — danno zero fisso. Sul Fujitsu di sviluppo un
# ingresso dichiara 127,5 gradi mentre la CPU ne fa 47.
# Il punto non e' l'ordine: se qualcuno aggancia una curva a un canale che e'
# una costante, la ventola non salira' mai. Qui si mostra solo cio' che la
# macchina legge davvero, e cio' che e' sospetto viene detto (vedi hwmon.py).
#
# ⚠️ LE ETICHETTE LE SCRIVE CHI USA IL PROGRAMMA. Sul Fujitsu le tensioni non
# hanno nome nemmeno per il driver: si chiamano in0, in2, in6, perche' il
# driver non sa come sono cablate su quella scheda. Nessun programma puo'
# saperlo. Chi ha la macchina davanti si'.
import io
import json
import os
import subprocess
import sys

from PyQt6.QtCore import Qt, QTimer, QRect, QPointF, QThread, pyqtSignal
from PyQt6.QtGui import (QIcon, QPainter, QColor, QPen, QBrush, QFont,
                         QPainterPath, QLinearGradient)
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
                             QHBoxLayout, QLabel, QGroupBox, QPushButton,
                             QRadioButton, QButtonGroup, QMessageBox,
                             QScrollArea, QCheckBox, QSpinBox, QDoubleSpinBox,
                             QToolButton, QComboBox, QInputDialog, QDialog,
                             QSizePolicy, QFrame)

# ⚠️ Il nome dell'icona deve esistere ESATTAMENTE cosi' nel tema, anche in png:
# se manca, KDE taglia dopo l'ultimo trattino e ripiega su «skillfish», che nel
# tema c'e', e sullo schermo esce il pesce di sistema senza un errore da
# nessuna parte. Successo col Tuner.
ICON = "/usr/share/icons/hicolor/256x256/apps/skillfish-fan.png"
HELPER = "/usr/local/bin/skillfish-fan-helper"
CONF = "/etc/skillfish/ventola.json"
STATO = "/run/skillfish/ventola.json"
ETICHETTE = "/etc/skillfish/sensori.json"
PROVA = "/etc/skillfish/ventola-prova.json"

# Quanto e' larga la colonna dei comandi. Fissa e non elastica: sotto i
# quattrocento pixel i cursori e le caselle si schiacciano, e sopra i
# cinquecento la colonna comincia a rubare spazio al grafico, che e' la
# cosa che si guarda.
LARGHEZZA_COLONNA = 250
# La colonna delle tessere a destra del grafico.
LARGHEZZA_TESSERE = 168


# ---------- i18n ----------
def _detect_lang():
    # Ordine standard dei locale: LC_ALL batte LC_MESSAGES batte LANG.
    val = (os.environ.get("LC_ALL") or os.environ.get("LC_MESSAGES")
           or os.environ.get("LANG") or os.environ.get("LANGUAGE") or "")
    v = val.lower()
    if v.startswith("it"):
        return "it"
    # "uk" e' il codice della lingua ucraina; "ua" e' quello del PAESE e non
    # dovrebbe comparire, ma nei locale scritti a mano capita.
    if v.startswith("ua"):
        return "uk"
    codice = v.replace("-", "_").split(".")[0].split("@")[0].split("_")[0].strip()
    if len(codice) == 2 and codice.isalpha():
        return codice
    return "en"


LANG = _detect_lang()

# ⚠️ Il dizionario condiviso non deve mai impedire alla finestra di aprirsi.
try:
    sys.path.insert(0, "/usr/share/skillfish")
    from i18n import traduttore as _traduttore
    _TR = _traduttore(LANG)
except Exception:
    def _TR(en):
        return en


def L(it, en):
    u"""La stringa in italiano o quella tradotta dal dizionario condiviso.

    ⚠️ TUTTO CIO' CHE CHIAMA L() DEVE STARE SOTTO QUESTA RIGA. Nel pannello AI
    una costante che chiamava L() era finita venti righe piu' in su della sua
    definizione: il corpo del modulo si esegue dall'alto in basso, quindi la
    chiamata avveniva prima che la funzione esistesse e la finestra non si
    apriva affatto. py_compile passava, perche' e' un errore di esecuzione, non
    di sintassi."""
    return it if LANG == "it" else _TR(en)


# Il pallino con il punto interrogativo, condiviso con le altre applicazioni.
# ⚠️ L'import sta dentro a un try: base e le app sono pacchetti diversi, e su
# una macchina aggiornata a meta' un import secco chiuderebbe la finestra
# invece di togliere un bottoncino.
try:
    from aiuto import Aiuto
except Exception:
    class Aiuto(QToolButton):
        def __init__(self, testo, titolo="", parent=None):
            super().__init__(parent)
            self.setText("?")
            self.setCursor(Qt.CursorShape.WhatsThisCursor)
            self._t, self._ti = testo, titolo
            self.clicked.connect(self._mostra)

        def _mostra(self):
            QMessageBox.information(self.window(), self._ti or L("Aiuto", "Help"), self._t)


# ---------- i preset ----------
# ⚠️ I numeri non sono inventati: vengono dalla risposta MISURATA della ventola
# della BC-250, che a PWM 20 fa 567 giri, a 90 ne fa 1244 e a 255 ne fa 3100,
# in modo lineare, e che non si e' mai fermata nemmeno all'8%.
PRESET = {
    "silenzioso": [[45, 20], [60, 30], [70, 45], [80, 70], [88, 100]],
    "equilibrato": [[40, 30], [55, 40], [65, 60], [75, 85], [85, 100]],
    "prestazioni": [[35, 40], [50, 60], [60, 80], [70, 100], [85, 100]],
    "massimo": [[0, 100], [100, 100]],
}


def nome_preset(chiave):
    return {
        "silenzioso": L("Silenzioso", "Silent"),
        "equilibrato": L("Equilibrato", "Balanced"),
        "prestazioni": L("Prestazioni", "Performance"),
        "massimo": L("Massimo", "Full"),
        "personalizzata": L("Personalizzata", "Custom"),
    }.get(chiave, chiave)


def aiuto_preset(chiave):
    return {
        "silenzioso": L(
            "La ventola resta bassa finche' si puo'. Sotto i 45 gradi va al minimo, "
            "e comincia a salire sul serio solo oltre i 70.\n\n"
            "Per chi guarda film, naviga o gioca a cose leggere. Sotto carico pesante "
            "la scheda arrivera' piu' in alto di temperatura: non e' un guasto, e' il "
            "prezzo del silenzio.",
            "The fan stays low as long as it can. Below 45 C it sits at the minimum "
            "and only climbs in earnest past 70.\n\n"
            "For films, browsing and light games. Under heavy load the board will run "
            "hotter: that is not a fault, it is what quiet costs."),
        "equilibrato": L(
            "Il compromesso, ed e' quello che consigliamo.\n\n"
            "La ventola sale gradualmente dai 40 gradi in su e arriva al massimo a 85. "
            "Nella maggior parte dei giochi si sente appena e la scheda sta larga.",
            "The compromise, and the one we suggest.\n\n"
            "The fan climbs gradually from 40 C and reaches full speed at 85. In most "
            "games you barely hear it and the board has room to spare."),
        "prestazioni": L(
            "Priorita' al fresco. La ventola parte gia' al 40% da subito e arriva al "
            "massimo a 70 gradi.\n\n"
            "Serve se stai in overclock o se giochi a cose pesanti per ore. Si sente, "
            "ma la scheda resta lontana dal punto in cui comincia a rallentare da sola.",
            "Cool first. The fan starts at 40% straight away and is at full speed by "
            "70 C.\n\n"
            "For overclocking, or long sessions on heavy games. You will hear it, but "
            "the board stays well away from the point where it starts throttling."),
        "massimo": L(
            "Sempre al 100%, qualunque sia la temperatura.\n\n"
            "Non e' un preset da usare tutti i giorni: serve per una prova, o mentre si "
            "cerca un problema termico. E' rumoroso.",
            "Always 100%, whatever the temperature.\n\n"
            "Not an everyday setting: it is for a test, or while chasing a thermal "
            "problem. It is loud."),
    }.get(chiave, "")


AIUTO = {
    "curva": (L("La curva", "The curve"), L(
        "Ogni punto dice: «a questa temperatura, la ventola gira cosi'». Fra un punto e "
        "l'altro la velocita' si calcola per interpolazione, quindi la ventola non fa "
        "scatti.\n\n"
        "• trascina un punto per spostarlo\n"
        "• doppio clic sul grafico per aggiungerne uno\n"
        "• tasto destro su un punto per toglierlo\n\n"
        "Il pallino chiaro che si muove da solo e' la macchina adesso: temperatura "
        "vera e velocita' vera.\n\n"
        "La curva non puo' scendere mentre la temperatura sale: se trascini un punto "
        "sotto quello precedente viene rialzato. Raffreddare di meno mentre ci si "
        "scalda non e' una scelta, e' un errore di trascinamento.",
        "Each point says: \"at this temperature, run the fan this fast\". Between two "
        "points the speed is interpolated, so the fan does not jump.\n\n"
        "• drag a point to move it\n"
        "• double-click the graph to add one\n"
        "• right-click a point to remove it\n\n"
        "The pale dot that moves on its own is the machine right now: real temperature, "
        "real speed.\n\n"
        "The curve cannot go down as temperature goes up: a point dragged below the "
        "previous one is raised back. Cooling less while getting hotter is not a "
        "choice, it is a slip of the mouse.")),

    "vista": (L("Le due letture dello stesso grafico",
                "Two readings of the same chart"), L(
        "GRADI e' il piano della curva: ogni campione finisce dove si trovava la "
        "temperatura in quel momento. La domanda a cui risponde e' quella che ti stai "
        "ponendo mentre sposti un punto — a settanta gradi questa macchina cosa fa? "
        "Quanti watt tira, quanto girava la ventola davvero. La nuvola della ventola si "
        "posa sulla curva, ed e' la conferma a colpo d'occhio che il controllo sta "
        "obbedendo.\n\n"
        "⚠️ A macchina ferma la temperatura non si muove, quindi tutto si accumula in "
        "una striscia sola. Non e' un guasto: e' che non sta succedendo niente.\n\n"
        "TEMPO e' il piano del monitoraggio: gli ultimi cinque minuti che scorrono. Si "
        "legge sempre, anche a riposo, ed e' il modo giusto per guardare una salita "
        "mentre sta succedendo. Li' la curva non si puo' disegnare — e' una funzione "
        "della temperatura, non del tempo — quindi i punti si trascinano solo nella "
        "vista in gradi.",
        "DEGREES is the curve's own plane: every sample lands where the temperature "
        "was at that moment. It answers the question you are actually asking while you "
        "drag a point — at seventy degrees, what does this machine do? How many watts, "
        "how fast was the fan really turning. The fan's cloud settles onto the curve, "
        "which is the at-a-glance proof that the controller is obeying.\n\n"
        "⚠️ On an idle machine the temperature does not move, so everything piles into "
        "a single strip. That is not a fault: it is nothing happening.\n\n"
        "TIME is the monitoring plane: the last five minutes scrolling past. It reads "
        "at any moment, idle included, and it is the right way to watch a rise as it "
        "happens. The curve cannot be drawn there — it is a function of temperature, "
        "not of time — so points are draggable only in the degrees view.")),

    "controllo": (L("Chi comanda la ventola", "Who is in charge of the fan"), L(
        "Decide se la ventola e' nostra o del firmware. Ha effetto quando premi Applica.\n\n"
        "ACCESA: il controllo si prende l'uscita — prima si salva il valore e il modo che "
        "c'erano — e da li' in poi applica la curva una volta al secondo, con l'anticipo e "
        "l'emergenza.\n\n"
        "SPENTA: la ventola viene restituita esattamente com'era, valore e modo, e il "
        "controllo smette di scrivere. La curva resta disegnata ma non comanda niente.\n\n"
        "Il programma continua a girare in tutti e due i casi, quindi il grafico e i valori "
        "si aggiornano lo stesso: spegnere il controllo non ti lascia cieco.\n\n"
        "⚠️ A controllo spento NON C'E' NEMMENO L'EMERGENZA. Se l'uscita non e' in mano "
        "nostra non possiamo alzare la ventola, e sopra i gradi di emergenza non succede "
        "niente: resta quello che fa il firmware. Su questa scheda il firmware tiene un "
        "valore fisso, non una curva.",
        "Decides whether the fan is ours or the firmware's. It takes effect when you press "
        "Apply.\n\n"
        "ON: the controller takes the output — saving the value and mode that were there "
        "first — and from then on applies the curve once a second, with the lead and the "
        "emergency.\n\n"
        "OFF: the fan is handed back exactly as it was, value and mode, and the controller "
        "stops writing. The curve stays drawn but commands nothing.\n\n"
        "The program keeps running either way, so the chart and the readings still update: "
        "turning control off does not leave you blind.\n\n"
        "⚠️ WITH CONTROL OFF THERE IS NO EMERGENCY EITHER. If we do not hold the output we "
        "cannot raise the fan, and past the emergency temperature nothing happens: what the "
        "firmware does is what you get. On this board the firmware holds a fixed value, not "
        "a curve.")),

    "grafico": (L("Il grafico", "The chart"), L(
        "Un grafico solo, con dentro tutto: le temperature, la ventola, i giri, i watt e "
        "le tensioni degli ultimi cinque minuti. Uno solo apposta — con due o tre "
        "bisognerebbe passare da uno all'altro di continuo, e le cose che vuoi "
        "confrontare finirebbero in due schermate diverse.\n\n"
        "Ogni tessera e' una linea: cliccala per accenderla o spegnerla, doppio clic per "
        "riscriverne il nome. Il nome resta anche dopo un riavvio, ed e' utile piu' di "
        "quanto sembri: su molte schede il driver non sa come sono cablati gli ingressi "
        "e li chiama in0, in2, in6.\n\n"
        "⚠️ CI SONO CINQUE UNITA' DI MISURA INSIEME — gradi, percento, giri, watt, volt. "
        "Un asse verticale solo non puo' andare bene per tutte, quindi ogni linea e' "
        "portata su una scala sua e il numero vero si legge nella tessera. Le tensioni "
        "in particolare sono disegnate attorno al loro valore di riposo, non da zero: "
        "altrimenti un calo del cinque per cento — che e' il sintomo che interessa — "
        "sarebbe una linea piatta.\n\n"
        "L'aggiornamento si puo' rallentare: il grafico costa da ridisegnare, e mentre "
        "giochi quel costo lo paghi sui fotogrammi.",
        "One chart with everything in it: temperatures, the fan, its speed, the watts "
        "and the voltages of the last five minutes. One on purpose — with two or three "
        "you would be switching between them constantly, and the things you want to "
        "compare would end up on different screens.\n\n"
        "Every tile is a line: click it to show or hide it, double-click to rename it. "
        "The name survives a reboot, and it matters more than it looks: on many boards "
        "the driver does not know how the inputs are wired and calls them in0, in2, in6.\n\n"
        "⚠️ FIVE UNITS SHARE THIS CHART — degrees, percent, rpm, watts, volts. No single "
        "vertical axis can suit them all, so each line is mapped to its own scale and "
        "the real number is read off the tile. Voltages in particular are drawn around "
        "their resting value, not from zero: otherwise a five percent sag — which is the "
        "symptom that matters — would be a flat line.\n\n"
        "The refresh can be slowed down: redrawing the chart costs, and while you are "
        "playing you pay that cost in frames.")),

    "sorgente": (L("Quale temperatura comanda", "Which temperature drives it"), L(
        "La curva si legge su UNA temperatura. Se ne scegli piu' d'una, comanda la piu' "
        "alta fra quelle: e' la scelta prudente, perche' la media nasconde il punto "
        "caldo.\n\n"
        "Qui sotto compaiono solo i sensori che la macchina legge davvero. Quelli che il "
        "chip dichiara ma non legge — su questa scheda sono la maggioranza — sono "
        "nascosti apposta.\n\n"
        "⚠️ Se un sensore e' segnato come sospetto, non usarlo: un canale che non cambia "
        "mai comanderebbe una ventola che non sale mai.",
        "The curve is read against ONE temperature. If you pick more than one, the "
        "hottest of them wins: that is the prudent choice, because an average hides the "
        "hot spot.\n\n"
        "Only sensors the machine actually reads are listed. The ones the chip declares "
        "but does not read — on this board, most of them — are hidden on purpose.\n\n"
        "⚠️ Do not use a sensor marked suspicious: a channel that never changes would "
        "drive a fan that never rises.")),

    "anticipo": (L("Salire prima del calore", "Getting ahead of the heat"), L(
        "Una ventola impiega un paio di secondi a prendere giri, e l'APU si scalda in "
        "pochi secondi: chi aspetta che il termometro salga arriva sempre tardi. Qui si "
        "guarda quanto sta salendo e si legge la curva un po' piu' avanti.\n\n"
        "• GUADAGNO: quanti gradi di anticipo per ogni grado al secondo di salita. Piu' "
        "alto vuol dire piu' pronto e piu' rumoroso.\n"
        "• FINESTRA: su quanti secondi si misura la salita. Corta = nervosa, lunga = "
        "lenta a reagire.\n"
        "• WATT: sulla BC-250 si leggono i watt veri dell'APU. I watt salgono PRIMA "
        "della temperatura, perche' sono la causa: e' il segnale che arriva per primo.\n\n"
        "L'anticipo puo' solo alzare la ventola, mai abbassarla.",
        "A fan takes a couple of seconds to spin up and the APU heats in a few: waiting "
        "for the thermometer always arrives late. This watches how fast it is rising and "
        "reads the curve a little further along.\n\n"
        "• GAIN: degrees of lead per degree-per-second of rise. Higher is more eager and "
        "louder.\n"
        "• WINDOW: how many seconds the rise is measured over. Short is twitchy, long is "
        "slow to react.\n"
        "• WATTS: on the BC-250 the APU's real wattage is readable. Watts rise BEFORE "
        "temperature, because they are the cause: it is the earliest signal there is.\n\n"
        "Lead can only raise the fan, never lower it.")),

    "gioco": (L("Il gioco che parte", "When a game starts"), L(
        "Il segnale che arriva prima di tutti e' l'avvio del programma: un gioco pesante "
        "carica per una ventina di secondi prima di scaldare, e quel tempo e' regalato.\n\n"
        "Un titolo gia' visto viene trattato secondo quanto ha DAVVERO consumato l'ultima "
        "volta, misurato su questa macchina. E' cosi' che si distingue Cyberpunk da "
        "Roblox senza tenere l'elenco di tutti i giochi del mondo.\n\n"
        "Un titolo mai visto viene trattato come pesante, per prudenza, e alla fine della "
        "partita si segna quanto ha consumato. Quindi un gioco leggero pre-raffredda una "
        "volta sola, la prima, e mai piu'.",
        "The earliest signal of all is the program starting: a heavy game loads for "
        "twenty seconds before it heats anything, and that time is free.\n\n"
        "A title seen before is treated according to what it ACTUALLY drew last time, "
        "measured on this machine. That is how Cyberpunk is told apart from Roblox "
        "without keeping a list of every game in the world.\n\n"
        "A title never seen is treated as heavy, to be safe, and what it drew is recorded "
        "when you quit. So a light game pre-cools once, the first time, and never again.")),

    "prova": (L("Test PWM", "PWM test"), L(
        "Scrivere su un'uscita PWM non vuol dire che qualcosa si muova. Su molte "
        "schede, e soprattutto su server e portatili, il chip accetta il valore e le "
        "ventole se le governa il firmware: da fuori si vede un pannello che funziona "
        "e una ventola che non ha mai ricevuto un ordine.\n\n"
        "Verificato sulla macchina di sviluppo, un Fujitsu PRIMERGY: si scrive 255, si "
        "scrive 60, e i giri non si muovono su nessuna delle due uscite.\n\n"
        "Questa prova ferma per un momento il controllo, manda la ventola al massimo, "
        "poi al 35%, poi di nuovo al massimo, e guarda se il contagiri segue. Dura una "
        "trentina di secondi, si sente, e alla fine rimette tutto com'era.",
        "Writing to a PWM output does not mean anything moves. On many boards, servers "
        "and laptops especially, the chip accepts the value and the firmware keeps the "
        "fans to itself: from outside you see a panel that works and a fan that never "
        "got an order.\n\n"
        "Verified on the development machine, a Fujitsu PRIMERGY: write 255, write 60, "
        "and the speed does not budge on either output.\n\n"
        "This test pauses the controller for a moment, takes the fan to full, then to "
        "35%, then back to full, and watches whether the tachometer follows. It takes "
        "about thirty seconds, you will hear it, and it puts everything back "
        "afterwards.")),

    "sicurezza": (L("I freni", "The brakes"), L(
        "• MINIMO: la ventola non scende mai sotto questo. Molte ventole a tre fili sotto "
        "una certa soglia si fermano e non ripartono da sole finche' il segnale non "
        "risale parecchio, quindi lo zero non e' fra le scelte.\n"
        "• ISTERESI: di quanto deve scendere la temperatura prima che la ventola cali. "
        "Senza, mezzo grado di tremolio del sensore la farebbe salire e scendere di "
        "continuo.\n"
        "• EMERGENZA: sopra questa temperatura si va al 100% senza passare dalla curva. "
        "E' la strada piu' corta fra il termometro e la ventola, e non la si puo' "
        "disattivare.\n\n"
        "Se un sensore sparisce o dice un numero impossibile, la ventola va al massimo. "
        "Una ventola rumorosa e' un fastidio, una ventola ferma e' un danno.",
        "• MINIMUM: the fan never goes below this. Many 3-wire fans stop below a "
        "threshold and do not restart until the signal climbs well past it, so zero is "
        "not on the menu.\n"
        "• HYSTERESIS: how far the temperature must fall before the fan eases off. "
        "Without it, half a degree of sensor jitter would have it rising and falling "
        "constantly.\n"
        "• EMERGENCY: above this temperature it goes to 100% without consulting the "
        "curve. It is the shortest path from thermometer to fan, and it cannot be turned "
        "off.\n\n"
        "If a sensor disappears or reports something impossible, the fan goes to full "
        "speed. A loud fan is an annoyance; a stopped fan is damage.")),

    "tensioni": (L("Le tensioni", "The voltages"), L(
        "Una tensione che si allontana dal suo valore di riposo sotto carico e' il "
        "sintomo classico di un alimentatore troppo debole o di scarsa qualita'.\n\n"
        "⚠️ SU QUESTA SCHEDA LE LINEE DELL'ALIMENTATORE NON SI LEGGONO. Il chip dichiara "
        "+12V e +5V ma restituisce zero fisso: non sono cablate. Non e' un guasto e non "
        "c'e' niente da riparare, semplicemente da li' non si possono sorvegliare.\n\n"
        "Quello che si puo' guardare e' il lato APU: i watt assorbiti e le tensioni "
        "interne della GPU. Un alimentatore che non regge si vede da li' — i watt "
        "chiedono, la tensione cala e le frequenze crollano a temperatura costante.\n\n"
        "Per le tensioni senza nome l'allarme si basa sul valore di riposo imparato "
        "guardando: se si sposta di piu' della tolleranza, viene segnalato.",
        "A voltage drifting from its resting value under load is the classic symptom of "
        "a power supply that is too weak or poorly made.\n\n"
        "⚠️ ON THIS BOARD THE SUPPLY RAILS CANNOT BE READ. The chip declares +12V and "
        "+5V but returns a fixed zero: they are not wired. Nothing is broken and there "
        "is nothing to repair — they simply cannot be watched from there.\n\n"
        "What can be watched is the APU side: wattage drawn and the GPU's internal "
        "voltages. A supply that cannot keep up shows there — watts demand, voltage "
        "sags, and clocks collapse at constant temperature.\n\n"
        "For unnamed voltages the alarm uses the resting value learned by watching: if "
        "it moves more than the tolerance, it is flagged.")),

    "etichette": (L("I nomi dei sensori", "Sensor names"), L(
        "Puoi riscrivere il nome di qualunque sensore, e resta anche dopo un riavvio.\n\n"
        "Serve davvero: su molte schede il driver non sa come sono cablati gli ingressi e "
        "li chiama in0, in2, in6. Nessun programma puo' saperlo — chi ha la macchina "
        "davanti si'.\n\n"
        "Il nome resta legato al chip e al canale, non al numero di hwmon, che cambia a "
        "seconda dell'ordine in cui si caricano i driver.",
        "You can rename any sensor, and the name survives a reboot.\n\n"
        "This matters: on many boards the driver does not know how the inputs are wired "
        "and calls them in0, in2, in6. No program can know — the person with the machine "
        "in front of them can.\n\n"
        "The name is tied to the chip and channel, not to the hwmon number, which changes "
        "with driver load order.")),
}


def _aiuto(chiave, parent=None):
    titolo, corpo = AIUTO.get(chiave, (L("Aiuto", "Help"), ""))
    return Aiuto(corpo, titolo, parent)


# ---------- il grafico ----------
OTTONE = QColor("#d8a849")
OTTONE_SCURO = QColor("#8a6a2a")
FONDO = QColor("#1b1713")
GRIGLIA = QColor("#3a322a")
VIVO = QColor("#7fd4ff")


class Grafico(QWidget):
    u"""L'unico grafico: gli ultimi minuti che scorrono.

    Orizzontale = il tempo. Verticale = 0-100. Ci finisce dentro tutto quello
    che la macchina legge davvero: temperature, la ventola in percento, i giri,
    i watt, le tensioni.

    ⚠️ UN GRAFICO SOLO, ed e' il motivo per cui questa applicazione esiste: con
    uno solo si tiene d'occhio tutto insieme; con due o tre si passa dall'uno
    all'altro di continuo e le cose da confrontare finiscono su schermate
    diverse.

    ⚠️ CI SONO CINQUE UNITA' DI MISURA MESSE INSIEME. Non esiste un asse
    verticale solo che vada bene per gradi, percento, giri, watt e volt, quindi
    ogni serie viene portata su una scala 0-100 sua. Il valore vero si legge in
    tre modi: nella tessera, passandoci sopra col puntatore, e aprendo la serie
    da sola — che e' l'unico posto dove gli assi portano i numeri veri.
    """

    FINESTRA = 300            # secondi di storia tenuti

    COLORI_TEMP = ["#e8703a", "#d85a5a", "#c78be0", "#7fd4ff",
                   "#8fbf6a", "#e0d05a", "#b06a3a", "#5a8fd8"]
    COL_DUTY = "#d8a849"
    COL_RPM = "#5ad0c0"
    COL_WATT = "#b98cff"
    COL_VOLT = "#6a9fd8"

    def __init__(self, parent=None):
        super().__init__(parent)
        self.serie = {}
        self.ordine = []
        self.nuova_serie = None
        self.apri_serie = None     # richiamo: apri questa serie da sola
        self.cursore = None        # posizione del puntatore
        self.sotto = None          # la linea che ha sotto: e' l'unica che legge
        self._inizio = self._fine = 0.0
        # ⚠️ LA CURVA STA DENTRO AL GRAFICO, nell'angolo in basso a sinistra.
        # Copre un pezzo — non c'e' modo di evitarlo — ma quell'angolo e' il
        # meno frequentato: sotto al venti per cento ci passano solo le linee di
        # una macchina che sta ferma davvero. Si richiude col triangolino.
        self.inserto = CurvaEditor(self)
        self.bottone_inserto = QToolButton(self)
        self.bottone_inserto.setText("▾")
        self.bottone_inserto.setAutoRaise(True)
        self.bottone_inserto.clicked.connect(self._apri_chiudi)
        self._inserto_aperto = True
        self._inserto_pos = None      # None = appoggiata all'origine
        self._inserto_dim = None      # None = la misura di partenza
        self.inserto.spostata = self._sposta_inserto
        self.inserto.ridimensionata = self._ridimensiona_inserto
        self.setMinimumHeight(240)
        self.setMinimumWidth(360)
        self.setMouseTracking(True)
        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)

    # Quanto e' grande l'inserto della curva quando non lo si e' ancora toccato,
    # e sotto quale misura non lo si puo' stringere: piu' piccolo di cosi' i
    # punti si sovrappongono e trascinarne uno diventa un terno al lotto.
    INS_L, INS_H = 340, 224
    INS_MIN_L, INS_MIN_H = 230, 150

    def _apri_chiudi(self):
        self._inserto_aperto = not self._inserto_aperto
        self.inserto.setVisible(self._inserto_aperto)
        self.bottone_inserto.setText("▾" if self._inserto_aperto else "▸")
        self.bottone_inserto.setToolTip(
            L("Nascondi la curva", "Hide the curve") if self._inserto_aperto
            else L("Mostra la curva", "Show the curve"))
        self.update()

    def resizeEvent(self, ev):
        super().resizeEvent(ev)
        self._posiziona_inserto()

    def _posiziona_inserto(self):
        u"""Dove sta il riquadro della curva.

        Di serie l'angolo in basso a sinistra coincide con l'ORIGINE DEGLI ASSI
        del grafico: appoggiato esattamente dove si incontrano lo zero per cento
        e il bordo sinistro, non a caso dentro all'area.

        Se lo si e' spostato si tiene la sua posizione, ma sempre riportata
        dentro ai bordi: rimpicciolendo la finestra, un riquadro lasciato in
        basso a destra finirebbe fuori e non lo si riprenderebbe piu'."""
        a = self._area()
        # la misura: quella di partenza, o quella a cui e' stata tirata, e mai
        # piu' grande dell'area del grafico
        l, h = self._inserto_dim or (self.INS_L, self.INS_H)
        l = max(self.INS_MIN_L, min(a.width(), l))
        h = max(self.INS_MIN_H, min(a.height(), h))
        if self._inserto_pos is None:
            x, y = a.left(), a.bottom() - h
        else:
            x, y = self._inserto_pos
        x = max(a.left(), min(a.right() - l, x))
        y = max(a.top(), min(a.bottom() - h, y))
        self.inserto.setGeometry(QRect(int(x), int(y), int(l), int(h)))
        self.bottone_inserto.setGeometry(
            QRect(int(x) + int(l) - 21, int(y) + 1, 19, 15))

    def _ridimensiona_inserto(self, l, h):
        u"""⚠️ Tirando l'angolo si sposta il bordo in basso a destra, e quello in
        alto a sinistra resta fermo: se la posizione non e' ancora stata scelta
        a mano bisogna fissarla adesso, o il riquadro «appoggiato all'origine»
        scivolerebbe verso l'alto man mano che lo si allunga."""
        if self._inserto_pos is None:
            g = self.inserto.geometry()
            self._inserto_pos = (g.x(), g.y())
        self._inserto_dim = (l, h)
        self._posiziona_inserto()

    def _sposta_inserto(self, punto):
        p = self.mapFromGlobal(punto)
        self._inserto_pos = (p.x(), p.y())
        self._posiziona_inserto()

    def _bordo(self):
        return 42, 10, 12, 24

    def _area(self):
        s, sup, d, inf = self._bordo()
        return QRect(s, sup, max(1, self.width() - s - d),
                     max(1, self.height() - sup - inf))

    def _y(self, q):
        a = self._area()
        return a.bottom() - max(0.0, min(1.0, q)) * a.height()

    def _colore(self, unita):
        if unita == "C":
            n = len([k for k in self.ordine if self.serie[k]["unita"] == "C"])
            return QColor(self.COLORI_TEMP[n % len(self.COLORI_TEMP)])
        return QColor({"%": self.COL_DUTY, "rpm": self.COL_RPM,
                       "W": self.COL_WATT, "V": self.COL_VOLT}.get(unita, "#999999"))

    def carica(self, storia, meta):
        u"""I campioni li ha presi il demone: qui si disegnano e basta.

        ⚠️ Prima la finestra si costruiva la sua storia accodando un punto a
        ogni aggiornamento. Reggeva finche' si aggiornava ogni secondo, ma
        bastava rallentare per avere un grafico piu' povero — un grafico che
        peggiora perche' lo si guarda meno spesso, che e' il contrario di quello
        che deve succedere. E riaprendo la finestra si ripartiva da zero.

        `meta` da' nome e unita' di ogni chiave; la storia da' i punti.
        """
        nuove = False
        for chiave, punti in (storia or {}).items():
            etichetta, unita = meta.get(chiave, (chiave, ""))
            s = self.serie.get(chiave)
            if s is None:
                s = {"etichetta": etichetta, "unita": unita, "punti": [],
                     # ⚠️ Di serie si accendono temperature, ventola, giri e watt
                     # — i quattro numeri della colonna piu' le temperature.
                     # Accendere anche le tensioni farebbe partire il grafico
                     # con dodici linee sovrapposte.
                     "visibile": unita in ("C", "%", "rpm", "W"), "colore": None}
                self.serie[chiave] = s
                self.ordine.append(chiave)
                s["colore"] = self._colore(unita)
                nuove = True
            s["etichetta"] = etichetta
            # {"t0": primo secondo, "v": [valori]}: il tempo si ricava
            # dall'indice, e i buchi (null) si saltano.
            t0 = punti.get("t0") or 0
            s["punti"] = [(t0 + i, v) for i, v in enumerate(punti.get("v") or [])
                          if v is not None]
        if nuove and self.nuova_serie:
            self.nuova_serie()
        self.update()

    @staticmethod
    def _scala(unita, valori):
        u"""⚠️ Le tensioni si disegnano attorno alla LORO mediana con un piu' o
        meno dieci per cento: su una scala 0-14 volt starebbero tutte
        appiccicate e un calo del cinque per cento — che e' il sintomo che
        interessa — sarebbe invisibile."""
        if unita == "C":
            return 20.0, 100.0
        if unita == "%":
            return 0.0, 100.0
        if unita == "rpm":
            m = max(valori) if valori else 1
            return 0.0, max(500.0, (int(m / 500) + 1) * 500.0)
        if unita == "W":
            m = max(valori) if valori else 1
            return 0.0, max(10.0, (int(m / 10) + 1) * 10.0)
        if unita == "V":
            o = sorted(valori)
            med = o[len(o) // 2] if o else 1.0
            return (0.0, 1.0) if med <= 0 else (med * 0.9, med * 1.1)
        return (min(valori) if valori else 0.0), (max(valori) if valori else 1.0)

    def elenco(self):
        return [(k, self.serie[k]) for k in self.ordine if k in self.serie]

    def _visibili(self):
        return [(k, s) for k, s in self.elenco() if s["visibile"] and s["punti"]]

    @staticmethod
    def valore_a(s, quando):
        u"""Il valore della serie al momento indicato: il campione piu' vicino.

        Non si interpola fra due campioni: a un secondo di distanza
        l'interpolazione inventerebbe un numero che la macchina non ha mai
        letto, e questo e' il posto dove si va a leggere il valore vero."""
        if not s["punti"]:
            return None
        migliore, dist = None, None
        for tempo, v in s["punti"]:
            d = abs(tempo - quando)
            if dist is None or d < dist:
                migliore, dist = v, d
        return migliore

    # ------------------------------------------------------------- disegno
    def paintEvent(self, ev):
        p = QPainter(self)
        p.setRenderHint(QPainter.RenderHint.Antialiasing)
        p.fillRect(self.rect(), FONDO)
        a = self._area()
        p.setFont(QFont(self.font().family(), 8))
        for q in range(0, 101, 20):
            y = self._y(q / 100.0)
            p.setPen(QPen(GRIGLIA, 1))
            p.drawLine(a.left(), int(y), a.right(), int(y))
            p.setPen(QPen(OTTONE_SCURO, 1))
            p.drawText(8, int(y) + 4, "%d%%" % q)
        for i in range(0, 6):
            x = a.left() + a.width() * i / 5.0
            p.setPen(QPen(GRIGLIA, 1))
            p.drawLine(int(x), a.top(), int(x), a.bottom())
            p.setPen(QPen(OTTONE_SCURO, 1))
            sec = int(self.FINESTRA * (5 - i) / 5.0)
            p.drawText(int(x) - (16 if sec else 22), a.bottom() + 16,
                       L("adesso", "now") if sec == 0 else "-%d s" % sec)

        serie = self._visibili()
        if not serie:
            p.setPen(QPen(OTTONE_SCURO, 1))
            p.drawText(a.left() + 12, a.center().y(),
                       L("niente da mostrare", "nothing to show"))
            return
        self._fine = max(s["punti"][-1][0] for _, s in serie)
        self._inizio = self._fine - self.FINESTRA
        for chiave, s in serie:
            valori = [v for _, v in s["punti"]]
            basso, alto = self._scala(s["unita"], valori)
            campo = (alto - basso) or 1.0
            cam = QPainterPath()
            primo = True
            for tempo, v in s["punti"]:
                if tempo < self._inizio:
                    continue
                x = a.left() + (tempo - self._inizio) / float(self.FINESTRA) * a.width()
                y = self._y((v - basso) / campo)
                if primo:
                    cam.moveTo(x, y)
                    primo = False
                else:
                    cam.lineTo(x, y)
            if primo:
                continue
            p.setBrush(Qt.BrushStyle.NoBrush)
            p.setPen(QPen(s["colore"], 2.4 if s["unita"] == "%" else 1.5))
            p.drawPath(cam)
        self._disegna_cursore(p, serie)

    def _disegna_cursore(self, p, serie):
        u"""Il puntatore legge UNA linea: quella che ha sotto.

        ⚠️ Prima il riquadro elencava tutte le serie accese. Con dodici linee
        diventava un muro di righe piccole proprio sopra al grafico, e per
        leggere il numero che interessava bisognava cercarlo nell'elenco. Chi
        avvicina il puntatore a una linea sta chiedendo quella: si risponde con
        quella, grande, e basta.
        """
        if self.cursore is None or self.sotto is None:
            return
        s = self.serie.get(self.sotto)
        if not s or not s["punti"]:
            return
        a = self._area()
        x = max(a.left(), min(a.right(), self.cursore.x()))
        quando = self._inizio + (x - a.left()) / float(max(1, a.width())) * self.FINESTRA
        v = self.valore_a(s, quando)
        if v is None:
            return
        basso, alto = self._scala(s["unita"], [y for _, y in s["punti"]])
        y = self._y((v - basso) / ((alto - basso) or 1.0))

        p.setPen(QPen(QColor(216, 168, 73, 130), 1, Qt.PenStyle.DashLine))
        p.drawLine(int(x), a.top(), int(x), a.bottom())
        p.setBrush(QBrush(s["colore"]))
        p.setPen(QPen(QColor("#15110d"), 2))
        p.drawEllipse(QPointF(x, y), 5.5, 5.5)

        dec = {"C": 1, "%": 0, "rpm": 0, "W": 1, "V": 3}.get(s["unita"], 1)
        grosso = ("%." + str(dec) + "f %s") % (v, s["unita"])
        sec = int(round(self._fine - quando))
        piccolo = "%s  ·  %s" % (s["etichetta"],
                                 L("adesso", "now") if sec <= 0 else "-%d s" % sec)
        fg = QFont(self.font().family(), 20, QFont.Weight.Bold)
        fp = QFont(self.font().family(), 9)
        p.setFont(fg)
        fmg = p.fontMetrics()
        lg = fmg.horizontalAdvance(grosso)
        p.setFont(fp)
        fmp = p.fontMetrics()
        lp = fmp.horizontalAdvance(piccolo)
        larg = max(lg, lp) + 22
        alt = fmg.height() + fmp.height() + 18
        # il riquadro sta dall'altra parte quando starebbe per uscire: verso il
        # bordo destro c'e' proprio il pezzo che interessa di piu'.
        bx = x + 16 if x + 16 + larg < a.right() else x - 16 - larg
        by = max(a.top() + 2, min(a.bottom() - alt - 2, y - alt / 2.0))
        p.setBrush(QBrush(QColor(21, 17, 13, 240)))
        p.setPen(QPen(s["colore"], 1))
        p.drawRoundedRect(QRect(int(bx), int(by), int(larg), int(alt)), 5, 5)
        p.setPen(QPen(QColor("#9a8a70"), 1))
        p.setFont(fp)
        p.drawText(int(bx) + 11, int(by) + 6 + fmp.ascent(), piccolo)
        p.setPen(QPen(s["colore"], 1))
        p.setFont(fg)
        p.drawText(int(bx) + 11, int(by) + 10 + fmp.height() + fmg.ascent(), grosso)

    # --------------------------------------------------------- interazione
    def mouseMoveEvent(self, ev):
        self.cursore = ev.position()
        self.sotto = self._serie_vicina(self.cursore)
        self.setCursor(Qt.CursorShape.PointingHandCursor if self.sotto
                       else Qt.CursorShape.ArrowCursor)
        self.update()

    def leaveEvent(self, ev):
        self.cursore = None
        self.sotto = None
        self.update()

    def _serie_vicina(self, pos):
        u"""Quale linea e' stata cliccata: la piu' vicina al punto, in pixel."""
        a = self._area()
        if not a.contains(pos.toPoint()):
            return None
        quando = (self._inizio + (pos.x() - a.left())
                  / float(max(1, a.width())) * self.FINESTRA)
        migliore, dist = None, None
        for chiave, s in self._visibili():
            v = self.valore_a(s, quando)
            if v is None:
                continue
            basso, alto = self._scala(s["unita"], [x for _, x in s["punti"]])
            y = self._y((v - basso) / ((alto - basso) or 1.0))
            d = abs(y - pos.y())
            if dist is None or d < dist:
                migliore, dist = chiave, d
        return migliore if dist is not None and dist < 40 else None

    def mousePressEvent(self, ev):
        chiave = self._serie_vicina(ev.position())
        if chiave and self.apri_serie:
            self.apri_serie(chiave)


class GraficoSingolo(QWidget):
    u"""Una serie sola, con i numeri veri sugli assi.

    Nel grafico grande convivono cinque unita' di misura e nessuna puo' avere
    l'asse verticale con i suoi numeri: si legge tutto in proporzione. Qui la
    serie e' una sola, quindi l'asse verticale porta i VOLT, i WATT o i GRADI
    che sono, e l'orizzontale i secondi.

    La scala verticale si prende dai dati con un margine del dieci per cento
    sopra e sotto — qui e' giusto che insegua, perche' e' il posto dove si va a
    guardare una cosa da vicino.
    """

    def __init__(self, grafico, chiave, parent=None):
        super().__init__(parent)
        self.grafico = grafico
        self.chiave = chiave
        self.setMinimumSize(560, 320)
        self.setMouseTracking(True)
        self.cursore = None

    def _area(self):
        return QRect(66, 12, max(1, self.width() - 80), max(1, self.height() - 42))

    def paintEvent(self, ev):
        p = QPainter(self)
        p.setRenderHint(QPainter.RenderHint.Antialiasing)
        p.fillRect(self.rect(), FONDO)
        a = self._area()
        s = self.grafico.serie.get(self.chiave)
        p.setFont(QFont(self.font().family(), 8))
        if not s or not s["punti"]:
            p.setPen(QPen(OTTONE_SCURO, 1))
            p.drawText(a.left() + 10, a.center().y(),
                       L("ancora nessuna lettura", "no readings yet"))
            return
        valori = [v for _, v in s["punti"]]
        basso, alto = min(valori), max(valori)
        if alto - basso < 1e-9:
            basso, alto = basso - 1.0, alto + 1.0
        margine = (alto - basso) * 0.1
        basso, alto = basso - margine, alto + margine
        campo = alto - basso
        dec = {"C": 1, "%": 0, "rpm": 0, "W": 1, "V": 3}.get(s["unita"], 1)

        for i in range(0, 5):
            y = a.bottom() - a.height() * i / 4.0
            p.setPen(QPen(GRIGLIA, 1))
            p.drawLine(a.left(), int(y), a.right(), int(y))
            p.setPen(QPen(OTTONE_SCURO, 1))
            p.drawText(4, int(y) + 4, ("%." + str(dec) + "f") % (basso + campo * i / 4.0))
        fine = s["punti"][-1][0]
        inizio = fine - self.grafico.FINESTRA
        for i in range(0, 6):
            x = a.left() + a.width() * i / 5.0
            p.setPen(QPen(GRIGLIA, 1))
            p.drawLine(int(x), a.top(), int(x), a.bottom())
            p.setPen(QPen(OTTONE_SCURO, 1))
            sec = int(self.grafico.FINESTRA * (5 - i) / 5.0)
            p.drawText(int(x) - (16 if sec else 22), a.bottom() + 18,
                       L("adesso", "now") if sec == 0 else "-%d s" % sec)
        # ⚠️ L'unita' sta in basso a sinistra, sotto all'angolo degli assi, e
        # grande: e' l'informazione che dice cosa sono tutti gli altri numeri
        # del riquadro, e in piccolo in un angolo in alto non la leggeva nessuno.
        p.setPen(QPen(OTTONE, 1))
        p.setFont(QFont(self.font().family(), 13, QFont.Weight.Bold))
        p.drawText(6, self.height() - 8, s["unita"])
        p.setFont(QFont(self.font().family(), 8))

        cam = QPainterPath()
        primo = True
        for tempo, v in s["punti"]:
            if tempo < inizio:
                continue
            x = a.left() + (tempo - inizio) / float(self.grafico.FINESTRA) * a.width()
            y = a.bottom() - (v - basso) / campo * a.height()
            if primo:
                cam.moveTo(x, y)
                primo = False
            else:
                cam.lineTo(x, y)
        p.setBrush(Qt.BrushStyle.NoBrush)
        p.setPen(QPen(s["colore"], 2))
        p.drawPath(cam)

        if self.cursore is not None and a.left() <= self.cursore <= a.right():
            quando = (inizio + (self.cursore - a.left())
                      / float(max(1, a.width())) * self.grafico.FINESTRA)
            v = self.grafico.valore_a(s, quando)
            if v is not None:
                y = a.bottom() - (v - basso) / campo * a.height()
                p.setPen(QPen(QColor(216, 168, 73, 140), 1, Qt.PenStyle.DashLine))
                p.drawLine(int(self.cursore), a.top(), int(self.cursore), a.bottom())
                p.setBrush(QBrush(s["colore"]))
                p.setPen(QPen(QColor("#15110d"), 1.5))
                p.drawEllipse(QPointF(self.cursore, y), 4, 4)
                p.setPen(QPen(s["colore"], 1))
                p.setFont(QFont(self.font().family(), 9, QFont.Weight.Bold))
                sec = int(round(fine - quando))
                p.drawText(int(self.cursore) + 8, int(y) - 8,
                           ("%." + str(dec) + "f %s  (%s)")
                           % (v, s["unita"],
                              L("adesso", "now") if sec <= 0 else "-%d s" % sec))

    def mouseMoveEvent(self, ev):
        self.cursore = ev.position().x()
        self.update()

    def leaveEvent(self, ev):
        self.cursore = None
        self.update()


class CurvaEditor(QWidget):
    u"""La curva: gradi in orizzontale, percento in verticale, punti trascinabili.

    Sta DENTRO al grafico grande, in un angolo. Copre un pezzo — non c'e' modo
    di evitarlo — ma e' anche l'unica cosa che si MODIFICA invece di guardarla,
    e tenerla dove si guarda ha senso.

    ⚠️ SI PUO' SPOSTARE. Qualunque angolo si scelga, un giorno ci passa sotto la
    linea che interessa: invece di indovinare l'angolo giusto per tutti, la si
    prende per la barretta in alto e la si mette dove serve. La posizione viene
    tenuta anche quando la finestra cambia dimensione, e resta comunque dentro
    ai bordi del grafico.

    ⚠️ GLI ASSI SONO FISSI, 20-100 gradi e 0-100%. Una scala che si adatta ai
    dati fa sembrare identiche due curve completamente diverse.
    """

    T_MIN, T_MAX = 20.0, 100.0
    RAGGIO = 5
    MANIGLIA = 17          # l'altezza della barretta per spostarla
    ANGOLO = 15            # il quadratino in basso a destra per ridimensionarla

    def __init__(self, parent=None):
        super().__init__(parent)
        self.punti = [list(p) for p in PRESET["equilibrato"]]
        self.vivo = None
        self.cambiata = None
        self.spostata = None       # richiamo verso il grafico che la contiene
        self._trascinato = -1
        self._presa = None         # da dove si sta spostando il riquadro
        self._tira = None          # da dove si sta ridimensionando
        self.ridimensionata = None
        self.setMouseTracking(True)
        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)

    def _area(self):
        return QRect(34, self.MANIGLIA + 6, max(1, self.width() - 46),
                     max(30, self.height() - self.MANIGLIA - 28))

    def _px(self, t, y):
        a = self._area()
        return QPointF(a.left() + (t - self.T_MIN) / (self.T_MAX - self.T_MIN) * a.width(),
                       a.bottom() - y / 100.0 * a.height())

    def _val(self, pos):
        a = self._area()
        t = self.T_MIN + (pos.x() - a.left()) / max(1, a.width()) * (self.T_MAX - self.T_MIN)
        y = (a.bottom() - pos.y()) / max(1, a.height()) * 100.0
        return max(self.T_MIN, min(self.T_MAX, t)), max(0.0, min(100.0, y))

    def paintEvent(self, ev):
        p = QPainter(self)
        p.setRenderHint(QPainter.RenderHint.Antialiasing)
        a = self._area()
        # ⚠️ Fondo pieno e bordo: sta SOPRA al grafico grande, e senza uno stacco
        # netto le linee che ci passano dietro sembrerebbero parte della curva.
        p.fillRect(self.rect(), QColor("#15110d"))
        p.setPen(QPen(OTTONE_SCURO, 1))
        p.drawRect(0, 0, self.width() - 1, self.height() - 1)

        # la barretta per spostarla, con i solchi che si vedono nelle maniglie
        p.fillRect(QRect(1, 1, self.width() - 2, self.MANIGLIA), QColor("#241f19"))
        p.setPen(QPen(OTTONE, 1))
        p.setFont(QFont(self.font().family(), 8, QFont.Weight.Bold))
        p.drawText(7, self.MANIGLIA - 4, L("Curva", "Curve"))
        p.setPen(QPen(OTTONE_SCURO, 1))
        cx = self.width() // 2
        for i in range(-3, 4):
            p.drawLine(cx + i * 4, 5, cx + i * 4, self.MANIGLIA - 5)

        # il quadratino per tirarla: tre trattini in diagonale, come si usa
        p.setPen(QPen(OTTONE_SCURO, 1))
        for i in range(3):
            d = 4 + i * 4
            p.drawLine(self.width() - 3, self.height() - 3 - d,
                       self.width() - 3 - d, self.height() - 3)

        p.setFont(QFont(self.font().family(), 7))
        for t in range(int(self.T_MIN), int(self.T_MAX) + 1, 10):
            x = self._px(t, 0).x()
            p.setPen(QPen(GRIGLIA, 1))
            p.drawLine(int(x), a.top(), int(x), a.bottom())
            if t % 20 == 0:
                p.setPen(QPen(OTTONE_SCURO, 1))
                p.drawText(int(x) - 8, a.bottom() + 13, "%d°" % t)
        # ⚠️ Le percentuali ogni dieci, non ogni cinquanta: e' l'asse su cui si
        # decide quanto gira la ventola, e con tre sole tacche si trascinava un
        # punto senza sapere se lo si stava mettendo al 55 o al 70.
        for y in range(0, 101, 10):
            yy = self._px(self.T_MIN, y).y()
            p.setPen(QPen(GRIGLIA if y % 20 else QColor("#4a4036"), 1))
            p.drawLine(a.left(), int(yy), a.right(), int(yy))
            if y % 20 == 0:
                p.setPen(QPen(OTTONE_SCURO, 1))
                p.drawText(4, int(yy) + 4, "%d%%" % y)
        if not self.punti:
            return
        cam = QPainterPath()
        cam.moveTo(a.left(), self._px(max(self.T_MIN, self.punti[0][0]), self.punti[0][1]).y())
        for t, y in self.punti:
            cam.lineTo(self._px(max(self.T_MIN, min(self.T_MAX, t)), y))
        cam.lineTo(a.right(), self._px(self.T_MAX, self.punti[-1][1]).y())
        sotto = QPainterPath(cam)
        sotto.lineTo(a.right(), a.bottom())
        sotto.lineTo(a.left(), a.bottom())
        sotto.closeSubpath()
        sf = QLinearGradient(0, a.top(), 0, a.bottom())
        sf.setColorAt(0.0, QColor(216, 168, 73, 80))
        sf.setColorAt(1.0, QColor(216, 168, 73, 12))
        p.fillPath(sotto, QBrush(sf))
        p.setBrush(Qt.BrushStyle.NoBrush)
        p.setPen(QPen(OTTONE, 2))
        p.drawPath(cam)
        p.setFont(QFont(self.font().family(), 7))
        for t, y in self.punti:
            c = self._px(max(self.T_MIN, min(self.T_MAX, t)), y)
            p.setPen(QPen(QColor("#2b241c"), 2))
            p.setBrush(QBrush(OTTONE))
            p.drawEllipse(c, self.RAGGIO, self.RAGGIO)
            # il valore accanto al punto: trascinandolo si vede dove si sta
            # mettendo, senza doverlo leggere sulla griglia
            p.setPen(QPen(QColor("#c9b184"), 1))
            p.setBrush(Qt.BrushStyle.NoBrush)
            p.drawText(int(c.x()) + 7, int(c.y()) - 6, "%d%%" % round(y))
        if self.vivo:
            c = self._px(max(self.T_MIN, min(self.T_MAX, self.vivo[0])), self.vivo[1])
            p.setBrush(QBrush(VIVO))
            p.setPen(QPen(QColor("#0d1a20"), 2))
            p.drawEllipse(c, 4.5, 4.5)

    def _vicino(self, pos):
        for i, (t, y) in enumerate(self.punti):
            c = self._px(max(self.T_MIN, min(self.T_MAX, t)), y)
            if (c.x() - pos.x()) ** 2 + (c.y() - pos.y()) ** 2 <= (self.RAGGIO + 7) ** 2:
                return i
        return -1

    def _nell_angolo(self, pos):
        return (pos.x() >= self.width() - self.ANGOLO
                and pos.y() >= self.height() - self.ANGOLO)

    def mousePressEvent(self, ev):
        if self._nell_angolo(ev.position()):
            self._tira = (ev.globalPosition().toPoint(), self.width(), self.height())
            return
        if ev.position().y() <= self.MANIGLIA:
            self._presa = ev.globalPosition().toPoint() - self.pos()
            return
        i = self._vicino(ev.position())
        if ev.button() == Qt.MouseButton.RightButton:
            # ⚠️ mai sotto due punti: con uno solo la curva non e' piu' una curva
            if i >= 0 and len(self.punti) > 2:
                del self.punti[i]
                self._fatto()
            return
        self._trascinato = i

    def mouseDoubleClickEvent(self, ev):
        if (ev.position().y() <= self.MANIGLIA or self._nell_angolo(ev.position())
                or self._vicino(ev.position()) >= 0):
            return
        t, y = self._val(ev.position())
        self.punti.append([round(t, 1), round(y)])
        self._fatto()

    def mouseMoveEvent(self, ev):
        if self._tira is not None:
            partenza, l0, h0 = self._tira
            d = ev.globalPosition().toPoint() - partenza
            if self.ridimensionata:
                self.ridimensionata(l0 + d.x(), h0 + d.y())
            return
        if self._presa is not None:
            if self.spostata:
                self.spostata(ev.globalPosition().toPoint() - self._presa)
            return
        if self._trascinato < 0:
            if self._nell_angolo(ev.position()):
                self.setCursor(Qt.CursorShape.SizeFDiagCursor)
            elif ev.position().y() <= self.MANIGLIA:
                self.setCursor(Qt.CursorShape.SizeAllCursor)
            else:
                self.setCursor(Qt.CursorShape.ArrowCursor)
            return
        t, y = self._val(ev.position())
        i = self._trascinato
        # non si scavalca il vicino: l'ordine dei punti non cambia trascinando
        if i > 0:
            t = max(t, self.punti[i - 1][0] + 0.5)
        if i < len(self.punti) - 1:
            t = min(t, self.punti[i + 1][0] - 0.5)
        self.punti[i] = [round(t, 1), round(y)]
        self.update()

    def mouseReleaseEvent(self, ev):
        self._presa = None
        self._tira = None
        if self._trascinato >= 0:
            self._trascinato = -1
            self._fatto()

    def _fatto(self):
        self.punti.sort()
        # non decrescente: un punto trascinato sotto il precedente e' quasi
        # sempre uno scivolone del mouse, e vorrebbe dire raffreddare di meno
        # mentre ci si scalda.
        for i in range(1, len(self.punti)):
            if self.punti[i][1] < self.punti[i - 1][1]:
                self.punti[i][1] = self.punti[i - 1][1]
        self.update()
        if self.cambiata:
            self.cambiata()

    def imposta(self, punti):
        self.punti = [list(p) for p in punti]
        self.update()

    def aggiorna_vivo(self, t, duty):
        self.vivo = None if t is None else (t, duty)
        self.update()


class Tessera(QFrame):
    u"""Un valore: pallino del colore della linea, nome, numero con l'unita'.

    Cliccandola si accende e si spegne la linea nel grafico; con un doppio clic
    si riscrive il nome. Le tessere SONO la legenda: tenerne una copia altrove
    vorrebbe dire due elenchi degli stessi numeri.
    """
    premuta = pyqtSignal(str)
    rinominata = pyqtSignal(str)

    def __init__(self, chiave, parent=None):
        super().__init__(parent)
        self.chiave = chiave
        self.setFrameShape(QFrame.Shape.StyledPanel)
        self.setCursor(Qt.CursorShape.PointingHandCursor)
        g = QVBoxLayout(self)
        g.setContentsMargins(8, 4, 8, 4)
        g.setSpacing(0)
        self.nome = QLabel("")
        self.nome.setStyleSheet("font-size: 10px;")
        self.valore = QLabel("")
        self.valore.setStyleSheet("font-size: 15px; font-weight: bold;")
        g.addWidget(self.nome)
        g.addWidget(self.valore)

    def aggiorna(self, s):
        col = s["colore"].name()
        acceso = s["visibile"]
        self.nome.setText(s["etichetta"])
        v = s["punti"][-1][1] if s["punti"] else None
        dec = {"C": 1, "%": 0, "rpm": 0, "W": 1, "V": 3}.get(s["unita"], 1)
        self.valore.setText("—" if v is None
                            else ("%." + str(dec) + "f %s") % (v, s["unita"]))
        self.nome.setStyleSheet("font-size:10px; color:%s;"
                                % (col if acceso else "#7a6c58"))
        self.valore.setStyleSheet("font-size:15px; font-weight:bold; color:%s;"
                                  % (col if acceso else "#8a7c68"))
        self.setStyleSheet("QFrame { border:1px solid %s; border-radius:4px; "
                           "background:%s; }"
                           % (col if acceso else "#3a322a",
                              "#241f19" if acceso else "#1b1713"))

    def mousePressEvent(self, ev):
        self.premuta.emit(self.chiave)

    def mouseDoubleClickEvent(self, ev):
        self.rinominata.emit(self.chiave)


# ---------- utilita' ----------
def leggi_json(percorso, ripiego):
    try:
        with io.open(percorso, encoding="utf-8") as f:
            d = json.load(f)
        return d if isinstance(d, dict) else ripiego
    except (IOError, OSError, ValueError):
        return ripiego


# Le linee FISSE: quelle che devono stare ferme sul loro valore, e su cui un
# allarme ha senso. Sono le uscite dell'alimentatore e i riferimenti del chip.
RAIL_FISSE = {"+12v": 12.0, "+5v": 5.0, "+3.3v": 3.3, "3vcc": 3.3,
              "avcc": 3.3, "avcc3": 3.3, "avsb": 3.3, "vcc": 3.3}

# ⚠️ QUESTE NON SI SORVEGLIANO, e non e' una dimenticanza.
#
# vddgfx e vddnb sono le tensioni interne dell'APU: SI MUOVONO PER PROGETTO,
# perche' il silicio le alza e le abbassa insieme alla frequenza. La prima
# versione le trattava come le altre e sullo schermo compariva
# «vddnb: 0.743 V invece di 1.09 V» a macchina ferma e sana. Un allarme che si
# accende quando va tutto bene e' peggio di nessun allarme: si impara a
# ignorarlo, e il giorno che ha ragione non lo guarda nessuno.
#
# VBat e' il caso opposto: fermo davvero, ma il chip lo legge attraverso un
# partitore suo e su questa scheda dichiara 2,03 V per una pila da 3 V. Il
# valore «giusto» dipende dalla scheda e non lo sappiamo, quindi si mostra e
# basta.
#
# Restano visibili tutte: mostrare senza allarmare e' diverso da nascondere.
DINAMICHE = ("vddgfx", "vddnb", "vcore", "cpu vcore", "cpu soc", "cpu vddp",
             "cpu sa", "cpu 1p8", "dram", "chipset", "vbat", "vid", "vtt")

# Quanti campioni prima di fidarsi del valore di riposo imparato. Uno solo
# sarebbe un colpo di fortuna: se capita durante un picco, quello diventa il
# riferimento e tutto il resto sembra sbagliato.
CAMPIONI_RIPOSO = 20


def nominale(etichetta):
    u"""Il valore atteso di una linea, se e' una linea fissa che conosciamo."""
    e = (etichetta or "").strip().lower()
    if any(d in e for d in DINAMICHE):
        return "dinamica"
    return RAIL_FISSE.get(e)


class Prova(QThread):
    u"""La prova del PWM gira in un filo suo: dura mezzo minuto.

    Nella finestra principale bloccherebbe tutto — niente aggiornamento delle
    temperature proprio mentre la ventola va al massimo, che e' l'unico momento
    in cui uno vorrebbe guardarle."""
    finita = pyqtSignal(bool, str)

    def __init__(self, pwm, fan, parent=None):
        super().__init__(parent)
        self.pwm, self.fan = pwm, fan

    def run(self):
        try:
            p = subprocess.run(["pkexec", HELPER, "prova"],
                               input=json.dumps({"pwm": self.pwm, "fan": self.fan}),
                               capture_output=True, text=True, timeout=180)
        except Exception as e:
            self.finita.emit(False, str(e))
            return
        if p.returncode != 0:
            self.finita.emit(False, (p.stderr or p.stdout or "").strip())
            return
        try:
            d = json.loads(p.stdout)
        except ValueError:
            self.finita.emit(False, p.stdout[:200])
            return
        giri = ", ".join("%s%% -> %s" % (round(x["pwm"] * 100 / 255), x["rpm"])
                         for x in d.get("letture", []))
        self.finita.emit(bool(d.get("risponde")), giri)


class Pannello(QDialog):
    u"""Un riquadro che si apre quando serve, e per il resto non c'e'.

    LO STANDARD DELLE NOSTRE APPLICAZIONI: la finestra principale mostra quello
    che si guarda, e le regolazioni si aprono a richiesta. Qui la differenza e'
    grossa — sorgente, anticipo e freni sono tre gruppi di comandi che si
    toccano una volta e poi restano fermi per mesi, e tenerli sempre a schermo
    voleva dire una colonna alta quanto la finestra accanto alla cosa che invece
    si guarda davvero, cioe' il grafico.

    ⚠️ Non e' modale: si puo' regolare l'anticipo e intanto guardare il grafico
    che risponde. Un pannello modale che copre il grafico mentre si regola il
    grafico non serve a niente.

    ⚠️ Il contenuto NON si ricostruisce a ogni apertura: e' sempre lo stesso
    widget, che vive anche a pannello chiuso. Cosi' i valori restano quelli che
    si stavano scrivendo, e chi aggiorna le letture non deve sapere se qualcuno
    sta guardando.
    """

    def __init__(self, titolo, contenuto, parent=None):
        super().__init__(parent)
        self.setWindowTitle(titolo)
        self.setModal(False)
        v = QVBoxLayout(self)
        v.setContentsMargins(10, 10, 10, 10)
        v.addWidget(contenuto)
        riga = QHBoxLayout()
        riga.addStretch(1)
        b = QPushButton(L("Chiudi", "Close"))
        b.clicked.connect(self.hide)
        riga.addWidget(b)
        v.addLayout(riga)

    def mostra(self):
        self.show()
        self.raise_()
        self.activateWindow()


# ---------- la finestra ----------
class Finestra(QMainWindow):

    def __init__(self):
        super().__init__()
        self.setWindowTitle(L("SkillFishOS Ventola", "SkillFishOS Fan Control"))
        if os.path.exists(ICON):
            self.setWindowIcon(QIcon(ICON))
        self.conf = leggi_json(CONF, {})
        self.stato = leggi_json(STATO, {})
        self.etichette = leggi_json(ETICHETTE, {})
        self.riposo = {}          # tensione -> valore di riposo imparato
        self.caselle_sorgente = {}
        self.tessere = {}
        self.pannelli_serie = {}

        centro = QWidget()
        self.setCentralWidget(centro)
        fuori = QVBoxLayout(centro)
        fuori.setContentsMargins(10, 10, 10, 10)
        fuori.setSpacing(8)

        # LA DISPOSIZIONE
        #   a sinistra   le tessere: temperature, ventola, giri, watt, tensioni;
        #   a destra     UN SOLO grafico, che scorre nel tempo;
        #   in basso     la curva, i preset e i pulsanti.
        #
        # ⚠️ Le tessere SONO la legenda e sono anche i valori: si accendono e si
        # spengono cliccandole, e con un doppio clic si riscrive il nome. Prima
        # c'erano una legenda sotto al grafico E una sezione con gli stessi
        # numeri: due elenchi delle stesse cose.
        corpo = QHBoxLayout()
        corpo.setSpacing(10)
        fuori.addLayout(corpo, 1)

        lato = QScrollArea()
        lato.setWidgetResizable(True)
        lato.setFrameShape(QFrame.Shape.NoFrame)
        lato.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        # ⚠️ Anche quella verticale sparisce: la rotellina continua a funzionare
        # lo stesso, e una barra di scorrimento accanto ai numeri ruba spazio e
        # attira l'occhio su niente. Stessa scelta della pagina web, cosi' le due
        # si somigliano davvero invece che quasi.
        lato.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        lato.setFixedWidth(LARGHEZZA_TESSERE)
        gabbia = QWidget()
        self.tessere_lato = QVBoxLayout(gabbia)
        self.tessere_lato.setContentsMargins(0, 0, 4, 0)
        self.tessere_lato.setSpacing(5)
        self.tessere_lato.addStretch(1)
        lato.setWidget(gabbia)
        corpo.addWidget(lato, 0)

        destra = QVBoxLayout()
        destra.setSpacing(6)
        corpo.addLayout(destra, 1)

        cima = QHBoxLayout()
        cima.addWidget(QLabel("<b>%s</b>" % L("Ultimi 5 minuti", "Last 5 minutes")))
        cima.addStretch(1)
        self.et_motivo = QLabel("")
        self.et_motivo.setStyleSheet("color: #9a8a70;")
        cima.addWidget(self.et_motivo)
        cima.addStretch(1)
        cima.addWidget(QLabel(L("Aggiornamento", "Refresh")))
        self.intervallo = QComboBox()
        # ⚠️ Rallentare non impoverisce piu' il grafico: i campioni li prende
        # il demone, sempre una volta al secondo. Questo e' solo il ritmo con
        # cui la finestra RIDISEGNA, e sopra i cinque secondi si sente: mentre
        # giochi, ridisegnare dodici linee lo paghi sui fotogrammi.
        for testo, ms in (("1 s", 1000), ("2 s", 2000), ("5 s", 5000),
                          ("10 s", 10000), ("15 s", 15000), ("30 s", 30000)):
            self.intervallo.addItem(testo, ms)
        self.intervallo.currentIndexChanged.connect(self._cambia_intervallo)
        cima.addWidget(self.intervallo)
        cima.addWidget(_aiuto("grafico", self))
        destra.addLayout(cima)

        self.grafico = Grafico()
        self.grafico.nuova_serie = self._rifai_tessere
        self.grafico.apri_serie = self._apri_serie
        destra.addWidget(self.grafico, 1)

        self.et_allarme = QLabel("")
        self.et_allarme.setWordWrap(True)
        self.et_allarme.setStyleSheet("color:#e0703a; font-weight:bold;")
        destra.addWidget(self.et_allarme)

        # -- la striscia in basso: curva, preset, pulsanti --------------------
        basso = QHBoxLayout()
        basso.setSpacing(10)
        fuori.addLayout(basso, 0)

        # ⚠️ La curva non e' qui: sta nell'angolo in basso a sinistra DENTRO al
        # grafico. Qui restano i preset, che sono la cosa che la cambia.
        self.curva = self.grafico.inserto
        self.curva.cambiata = self._curva_toccata
        self.curva.imposta(self.conf.get("curva") or PRESET["equilibrato"])
        gc = QVBoxLayout()
        gc.setSpacing(2)
        intest = QHBoxLayout()
        intest.addWidget(QLabel("<b>%s</b>" % L("Curva", "Curve")))
        intest.addWidget(_aiuto("curva", self))
        intest.addStretch(1)
        gc.addLayout(intest)
        gc.addLayout(self._preset())
        basso.addLayout(gc)

        pulsanti = QVBoxLayout()
        pulsanti.setSpacing(4)
        # ⚠️ Sorgente, anticipo e freni si aprono a richiesta: sono comandi che
        # si toccano una volta e poi restano fermi per mesi, e tenerli sempre a
        # schermo ruba spazio alla cosa che invece si guarda.
        self.pannelli = {}
        for nome, titolo, costruisci in (
                ("sorgente", L("Sorgente", "Source"), self._sez_sorgente),
                ("anticipo", L("Anticipo", "Lead"), self._sez_anticipo),
                ("sicurezza", L("Sicurezza", "Safety"), self._sez_sicurezza)):
            self.pannelli[nome] = Pannello(titolo, costruisci(), self)
            b = QPushButton(titolo)
            b.clicked.connect(lambda _c=False, k=nome: self.pannelli[k].mostra())
            pulsanti.addWidget(b)
        basso.addLayout(pulsanti)

        basso.addStretch(1)

        fine = QVBoxLayout()
        fine.addStretch(1)
        self.interruttore = QCheckBox(L("Controllo", "Control"))
        self.interruttore.setToolTip(L(
            "Se e' spento la ventola resta al firmware e la curva non ha effetto.",
            "With this off the fan stays with the firmware and the curve does nothing."))
        self.interruttore.setChecked(bool(self.conf.get("attivo")))
        # ⚠️ Accorciare la voce a una parola l'ha resa ambigua: «Controllo» non
        # dice che spegnendolo si perde anche l'emergenza termica. La parola
        # resta corta e la spiegazione va dietro al «?», come nel resto delle
        # nostre applicazioni.
        ri = QHBoxLayout()
        ri.setSpacing(4)
        ri.addWidget(self.interruttore)
        ri.addWidget(_aiuto("controllo", self))
        ri.addStretch(1)
        fine.addLayout(ri)
        self.etichetta_salva = QLabel("")
        self.etichetta_salva.setStyleSheet("color:#9a8a70;")
        self.etichetta_salva.setWordWrap(True)
        fine.addWidget(self.etichetta_salva)
        b = QPushButton(L("Applica", "Apply"))
        b.setMinimumHeight(34)
        b.clicked.connect(self.salva)
        fine.addWidget(b)
        basso.addLayout(fine)

        self._adatta_allo_schermo()

        self.orologio = QTimer(self)
        self.orologio.timeout.connect(self.aggiorna)
        self.orologio.start(1000)
        self.aggiorna()

    def _adatta_allo_schermo(self):
        u"""Grande quanto ci sta.

        ⚠️ Qui non si stringe piu' sull'altezza del contenuto: adesso il
        contenuto principale e' il grafico, che si allarga a riempire quello che
        gli si da'. Stringere vorrebbe dire rimpicciolire la cosa che si guarda.
        """
        sc = QApplication.primaryScreen()
        d = sc.availableGeometry() if sc else QRect(0, 0, 1280, 800)
        larg = min(1700, max(940, int(d.width() * 0.84)))
        alt = min(1050, max(600, int(d.height() * 0.86)))
        self.resize(larg, alt)
        self.move(d.left() + max(0, (d.width() - larg) // 2),
                  d.top() + max(0, (d.height() - alt) // 2))

    def _aggiorna_grafico(self, s, t):
        u"""Porta al grafico tutto quello che il demone ha appena pubblicato.

        Le letture arrivano gia' pronte in /run/skillfish/ventola.json: qui si
        traducono in serie, una per canale, piu' la ventola in percento che il
        demone non e' un sensore ma e' la cosa che si vuole confrontare con
        tutto il resto.
        """
        adesso = float(s.get("aggiornato") or 0) or 0.0
        if not adesso:
            return
        letture = []
        for x in s.get("sensori") or []:
            if x.get("value") is None:
                continue
            letture.append((x["key"], x.get("label") or x["key"],
                            x.get("unit") or "", x["value"]))
        duty = s.get("duty")
        if duty is not None:
            letture.append(("__ventola__", L("Ventola", "Fan"), "%", float(duty)))
        self.grafico.campiona(adesso, t, letture)
        self.legenda.aggiorna_valori()
        self.grafico.aggiorna_vivo(t, float(duty or 0))

    # -- le sezioni ---------------------------------------------------------
    @staticmethod
    def _box(titolo, chiave_aiuto=None):
        g = QGroupBox()
        v = QVBoxLayout(g)
        intest = QHBoxLayout()
        et = QLabel("<b>%s</b>" % titolo)
        intest.addWidget(et)
        intest.addStretch(1)
        if chiave_aiuto:
            intest.addWidget(_aiuto(chiave_aiuto, g))
        v.addLayout(intest)
        return g, v

    def _preset(self):
        u"""I preset, e la curva che cambia SUBITO quando ne scegli uno.

        ⚠️ Prima bisognava premere «Usa» per vedere che forma avesse il preset:
        si sceglieva alla cieca, e il nome da solo non dice quanto sale la
        ventola ne' a che temperatura. Adesso la curva nel riquadro accanto si
        ridisegna nell'istante in cui si spunta il pallino — si guarda, e se non
        piace se ne spunta un altro. Sulla ventola non cambia niente finche' non
        si preme Applica, che e' l'unico pulsante che tocca la macchina.
        """
        v = QVBoxLayout()
        v.setSpacing(2)
        self.gruppo = QButtonGroup(self)
        for chiave in ("silenzioso", "equilibrato", "prestazioni", "massimo"):
            r = QRadioButton(nome_preset(chiave))
            r.chiave = chiave
            self.gruppo.addButton(r)
            h = QHBoxLayout()
            h.setSpacing(2)
            h.addWidget(r)
            h.addWidget(Aiuto(aiuto_preset(chiave), nome_preset(chiave), self))
            h.addStretch(1)
            v.addLayout(h)
            if self.conf.get("preset") == chiave:
                r.setChecked(True)
        self.gruppo.buttonClicked.connect(self._prova_preset)
        v.addStretch(1)
        return v

    def _prova_preset(self, bottone):
        self.curva.imposta(PRESET[bottone.chiave])
        self.etichetta_salva.setText(
            L("%s: premi Applica", "%s: press Apply") % nome_preset(bottone.chiave))

    def _sez_sorgente(self):
        g, v = self._box(L("Sorgente", "Source"), "sorgente")
        self.contenitore_sorgente = QVBoxLayout()
        v.addLayout(self.contenitore_sorgente)
        v.addStretch(1)
        return g

    def _sez_anticipo(self):
        g, v = self._box(L("Anticipo", "Lead"), "anticipo")
        p = self.conf.get("predittivo") or {}
        self.pred_attivo = QCheckBox(L("Attivo", "On"))
        self.pred_attivo.setChecked(bool(p.get("attivo", True)))
        v.addWidget(self.pred_attivo)

        def riga(testo, widget, suffisso=""):
            h = QHBoxLayout()
            h.addWidget(QLabel(testo))
            h.addStretch(1)
            h.addWidget(widget)
            if suffisso:
                h.addWidget(QLabel(suffisso))
            v.addLayout(h)

        self.guadagno = QDoubleSpinBox()
        self.guadagno.setRange(0.0, 10.0)
        self.guadagno.setSingleStep(0.5)
        self.guadagno.setValue(float(p.get("guadagno", 2.5)))
        riga(L("Guadagno", "Gain"), self.guadagno)

        self.finestra = QSpinBox()
        self.finestra.setRange(2, 30)
        self.finestra.setValue(int(p.get("finestra", 6)))
        riga(L("Finestra", "Window"), self.finestra, "s")

        self.preraffredda = QSpinBox()
        self.preraffredda.setRange(20, 100)
        self.preraffredda.setValue(int(p.get("preraffredda", 55)))
        riga(L("Pre-raffredda", "Pre-cool"),
             self.preraffredda, "%")

        h = QHBoxLayout()
        h.addWidget(QLabel(L("Giochi", "Games")))
        h.addStretch(1)
        h.addWidget(_aiuto("gioco", g))
        v.addLayout(h)
        self.et_gioco = QLabel("")
        self.et_gioco.setStyleSheet("color:#9a8a70;")
        self.et_gioco.setWordWrap(True)
        v.addWidget(self.et_gioco)
        v.addStretch(1)
        return g

    def _sez_sicurezza(self):
        g, v = self._box(L("Sicurezza", "Safety"), "sicurezza")

        def riga(testo, widget, suffisso=""):
            h = QHBoxLayout()
            h.addWidget(QLabel(testo))
            h.addStretch(1)
            h.addWidget(widget)
            if suffisso:
                h.addWidget(QLabel(suffisso))
            v.addLayout(h)

        self.minimo = QSpinBox()
        self.minimo.setRange(20, 100)
        self.minimo.setValue(int(self.conf.get("minimo", 30)))
        riga(L("Minimo", "Minimum"), self.minimo, "%")

        self.isteresi = QDoubleSpinBox()
        self.isteresi.setRange(0.0, 20.0)
        self.isteresi.setSingleStep(0.5)
        self.isteresi.setValue(float(self.conf.get("isteresi", 3.0)))
        riga(L("Isteresi", "Hysteresis"), self.isteresi, "°C")

        self.emergenza = QDoubleSpinBox()
        self.emergenza.setRange(60.0, 105.0)
        self.emergenza.setValue(float(self.conf.get("emergenza", 88.0)))
        riga(L("Emergenza", "Emergency"), self.emergenza, "°C")

        self.et_uscita = QLabel("")
        self.et_uscita.setStyleSheet("color:#9a8a70;")
        self.et_uscita.setWordWrap(True)
        v.addWidget(self.et_uscita)

        h = QHBoxLayout()
        self.bottone_prova = QPushButton(L("Test PWM", "PWM test"))
        self.bottone_prova.clicked.connect(self._prova_uscita)
        h.addWidget(self.bottone_prova, 1)
        h.addWidget(_aiuto("prova", g))
        v.addLayout(h)
        self.et_prova = QLabel("")
        self.et_prova.setWordWrap(True)
        v.addWidget(self.et_prova)
        self._mostra_prova(leggi_json(PROVA, {}))
        v.addStretch(1)
        return g

    def _mostra_prova(self, d):
        if not d:
            self.et_prova.setText("")
            return
        if d.get("risponde"):
            self.et_prova.setStyleSheet("color:#8fbf6a;")
            self.et_prova.setText(L("Provata: la ventola risponde ai comandi.",
                                    "Tested: the fan responds to the control."))
        else:
            self.et_prova.setStyleSheet("color:#e0703a; font-weight:bold;")
            self.et_prova.setText(L(
                "Provata: la ventola NON risponde. Il valore viene accettato e i giri "
                "non cambiano — su questa macchina la ventola la governa il firmware, "
                "e la curva non ha effetto.",
                "Tested: the fan does NOT respond. The value is accepted and the speed "
                "does not change — on this machine the firmware keeps the fan to "
                "itself, and the curve has no effect."))

    def _prova_uscita(self):
        sensori = (self.stato or {}).get("sensori") or []
        ventole = [x for x in sensori if x.get("type") == "fan" and x.get("value")]
        pwm = self.conf.get("pwm") or self._scegli_pwm()
        if not ventole or not pwm:
            QMessageBox.information(self, L("Non si puo' provare", "Cannot test"), L(
                "Serve un'uscita PWM e una ventola che dia i giri. Su questa macchina "
                "manca almeno una delle due.",
                "This needs a PWM output and a fan that reports its speed. At least one "
                "of the two is missing on this machine."))
            return
        self.bottone_prova.setEnabled(False)
        self.et_prova.setStyleSheet("color:#9a8a70;")
        self.et_prova.setText(L("Prova in corso, una trentina di secondi. Si sente.",
                                "Testing, about thirty seconds. You will hear it."))
        self._filo = Prova(pwm, ventole[0]["key"], self)
        self._filo.finita.connect(self._prova_finita)
        self._filo.start()

    def _prova_finita(self, risponde, dettaglio):
        self.bottone_prova.setEnabled(True)
        self._mostra_prova(leggi_json(PROVA, {}) or {"risponde": risponde})
        if dettaglio:
            self.et_prova.setText(self.et_prova.text() + chr(10) + dettaglio)

    def _curva_toccata(self):
        u"""Trascinare un punto vuol dire «personalizzata»: nessun preset e'
        piu' quello scelto, e dirlo subito evita che si prema Usa credendo di
        confermare la curva che si ha davanti."""
        for b in self.gruppo.buttons():
            self.gruppo.setExclusive(False)
            b.setChecked(False)
            self.gruppo.setExclusive(True)
        self.etichetta_salva.setText(L("modificata, non ancora applicata",
                                       "changed, not applied yet"))

    def _preset_scelto(self):
        for b in self.gruppo.buttons():
            if b.isChecked():
                return b.chiave
        return "personalizzata"

    def _svuota(self, layout):
        while layout.count():
            v = layout.takeAt(0)
            w = v.widget()
            if w:
                w.deleteLater()
            elif v.layout():
                self._svuota(v.layout())

    # -- vita ---------------------------------------------------------------
    def _cambia_intervallo(self):
        u"""Ogni quanto la finestra rilegge e ridisegna.

        ⚠️ Il demone campiona una volta al secondo e non cambia ritmo: questo e'
        il ritmo della FINESTRA. Sotto il secondo non ci sarebbe niente di nuovo
        da mostrare, quindi la scelta parte da un secondo. Rallentare serve su
        una macchina carica: ridisegnare un grafico con dodici linee costa, e
        mentre si gioca quel costo lo si paga sui fotogrammi."""
        # ⚠️ currentIndexChanged scatta gia' aggiungendo la prima voce, mentre
        # la finestra si sta ancora costruendo e l'orologio non c'e'. L'errore
        # sarebbe finito dentro a uno slot Qt: scritto su stderr e ingoiato.
        if not hasattr(self, "orologio"):
            return
        self.orologio.setInterval(self.intervallo.currentData() or 1000)

    def _apri_serie(self, chiave):
        u"""Un clic sul grafico apre quella linea da sola.

        Nel grafico grande le unita' convivono in proporzione e l'asse verticale
        non puo' portare i numeri di nessuna. Qui la serie e' una sola, quindi
        l'asse ha i suoi volt, i suoi watt o i suoi gradi.

        ⚠️ Un pannello per serie, e si riusa: cliccando due volte sulla stessa
        linea non si aprono due finestre uguali."""
        s = self.grafico.serie.get(chiave)
        if not s:
            return
        p = self.pannelli_serie.get(chiave)
        if p is None:
            g = GraficoSingolo(self.grafico, chiave)
            p = Pannello(s["etichetta"], g, self)
            p.resize(700, 400)
            self.pannelli_serie[chiave] = p
        p.setWindowTitle(s["etichetta"])
        p.mostra()

    def _tessera(self, chiave):
        t = self.tessere.get(chiave)
        if t is None:
            t = Tessera(chiave)
            t.premuta.connect(self._accendi_serie)
            t.rinominata.connect(self._rinomina_serie)
            self.tessere[chiave] = t
        return t

    def _rifai_tessere(self):
        u"""Le tessere: quattro sopra, il resto nella colonna a destra.

        Sopra vanno i quattro numeri che si guardano sempre — la temperatura che
        comanda, la ventola, i giri, i watt — e sono grossi. Le altre stanno di
        lato. Nessuna compare due volte: erano due elenchi degli stessi numeri e
        il secondo non serviva a niente.
        """
        # ⚠️ In cima vanno i quattro numeri che si guardano sempre — la
        # temperatura che comanda, la ventola, i giri, i watt — e poi tutti gli
        # altri. Nessuno compare due volte.
        elenco = self.grafico.elenco()
        primi = ["__temperatura__", "__ventola__"]
        for unita in ("rpm", "W"):
            for k, s in elenco:
                if s["unita"] == unita:
                    primi.append(k)
                    break
        while self.tessere_lato.count():
            v = self.tessere_lato.takeAt(0)
            if v.widget():
                v.widget().setParent(None)
        for k in primi:
            if k in self.grafico.serie:
                self.tessere_lato.addWidget(self._tessera(k))
        for k, s in elenco:
            if k not in primi:
                self.tessere_lato.addWidget(self._tessera(k))
        self.tessere_lato.addStretch(1)

    def _accendi_serie(self, chiave):
        s = self.grafico.serie.get(chiave)
        if s:
            s["visibile"] = not s["visibile"]
            self.grafico.update()
            self._tessera(chiave).aggiorna(s)

    def _rinomina_serie(self, chiave):
        s = self.grafico.serie.get(chiave)
        if not s or chiave.startswith("__"):
            return
        nuovo, ok = QInputDialog.getText(
            self, L("Nome del sensore", "Sensor name"),
            L("Come si chiama davvero questo sensore?",
              "What is this sensor really called?"), text=s["etichetta"])
        if not ok or not nuovo.strip():
            return
        self.etichette[chiave] = nuovo.strip()[:40]
        s["etichetta"] = nuovo.strip()[:40]
        self._tessera(chiave).aggiorna(s)
        self._helper("etichette", self.etichette)

    def aggiorna(self):
        self.stato = leggi_json(STATO, {})
        s = self.stato
        if not s:
            self.et_motivo.setText(L(
                "Il controllo della ventola non sta girando. Premi Applica per accenderlo.",
                "The fan controller is not running. Press Apply to start it."))
            return

        t = s.get("temperatura")
        motivo = (s.get("motivo") or "")
        testo = {
            "curve": L("segue la curva", "following the curve"),
            "no temperature: full speed": L(
                "nessuna temperatura leggibile: al massimo per sicurezza",
                "no readable temperature: full speed, to be safe"),
        }.get(motivo, motivo)
        if motivo.startswith("emergency"):
            testo = L("EMERGENZA: al massimo", "EMERGENCY: full speed")
        elif motivo.startswith("anticipating"):
            testo = L("sta anticipando la salita", "anticipating the rise")
        elif motivo.startswith("pre-cooling"):
            testo = L("pre-raffredda per %s", "pre-cooling for %s") % (s.get("gioco") or "")
        if not s.get("in_controllo"):
            testo = L("la ventola e' ancora del firmware: la curva non e' attiva",
                      "the fan still belongs to the firmware: the curve is not in charge")
        self.et_motivo.setText(testo)

        sensori = s.get("sensori") or []
        self._riempi_sorgente(sensori)
        self._allarmi(sensori)

        meta = {x["key"]: (x.get("label") or x["key"], x.get("unit") or "")
                for x in sensori}
        meta["__ventola__"] = (L("Ventola", "Fan"), "%")
        meta["__temperatura__"] = (L("Temperatura", "Temperature"), "C")
        self.grafico.carica(s.get("storia") or {}, meta)
        for chiave, serie in self.grafico.elenco():
            w = self.tessere.get(chiave)
            if w is not None:
                w.aggiorna(serie)
        self.curva.aggiorna_vivo(t, float(s.get("duty") or 0))
        if s.get("gioco"):
            self.et_gioco.setText(L("Ultimo riconosciuto: %s", "Last recognised: %s")
                                  % s["gioco"])

    def _allarmi(self, sensori):
        u"""Le tensioni fuori posto, dette una volta sola e in un posto solo."""
        allarmi = []
        for x in sensori:
            if x.get("type") != "in" or x.get("value") is None:
                continue
            v = x["value"]
            atteso = nominale(x.get("label"))
            if atteso == "dinamica":
                continue           # si muove per progetto: si mostra, non si allarma
            if atteso is None:
                storia = self.riposo.setdefault(x["key"], [])
                if len(storia) < CAMPIONI_RIPOSO:
                    storia.append(v)
                    continue
                o = sorted(storia)
                atteso = o[len(o) // 2]
                tolleranza = 0.10
            else:
                tolleranza = 0.05
            if atteso and abs(v - atteso) / atteso > tolleranza:
                allarmi.append(L("%s: %.3f V invece di %.2f V",
                                 "%s: %.3f V instead of %.2f V")
                               % (x.get("label") or x["key"], v, atteso))
        self.et_allarme.setText("\n".join(allarmi[:3]))

    def _riempi_sorgente(self, sensori):
        temp = [x for x in sensori if x.get("type") == "temp"]
        chiavi = [x["key"] for x in temp]
        if set(chiavi) != set(self.caselle_sorgente):
            self._svuota(self.contenitore_sorgente)
            self.caselle_sorgente = {}
            scelte = set(self.conf.get("sorgente") or [])
            for x in temp:
                c = QCheckBox(x["label"])
                c.setChecked(x["key"] in scelte)
                self.caselle_sorgente[x["key"]] = c
                h = QHBoxLayout()
                h.addWidget(c)
                h.addStretch(1)
                val = QLabel("")
                val.setStyleSheet("color:#d8a849;")
                h.addWidget(val)
                c.valore = val
                self.contenitore_sorgente.addLayout(h)
        for x in temp:
            c = self.caselle_sorgente.get(x["key"])
            if c is not None and x.get("value") is not None:
                c.valore.setText("%.1f °C" % x["value"])

    # -- salvataggio --------------------------------------------------------
    def salva(self):
        sorgenti = [k for k, c in self.caselle_sorgente.items() if c.isChecked()]
        pwm = self.conf.get("pwm") or self._scegli_pwm()
        if self.interruttore.isChecked() and not sorgenti:
            QMessageBox.warning(self, L("Manca la sorgente", "No source chosen"), L(
                "Scegli almeno una temperatura da usare come sorgente della curva.",
                "Pick at least one temperature for the curve to follow."))
            return
        if self.interruttore.isChecked() and not pwm:
            QMessageBox.warning(self, L("Nessuna uscita", "No output"), L(
                "Su questa macchina non ho trovato un'uscita PWM che si possa comandare.",
                "I could not find a controllable PWM output on this machine."))
            return

        conf = {
            "attivo": self.interruttore.isChecked(),
            "pwm": pwm,
            "sorgente": sorgenti,
            "curva": self.curva.punti,
            "minimo": self.minimo.value(),
            "isteresi": self.isteresi.value(),
            "emergenza": self.emergenza.value(),
            "preset": self._preset_scelto(),
            "predittivo": {
                "attivo": self.pred_attivo.isChecked(),
                "guadagno": self.guadagno.value(),
                "finestra": self.finestra.value(),
                "preraffredda": self.preraffredda.value(),
            },
        }
        ok, msg = self._helper("scrivi", conf)
        if ok:
            self.conf = conf
            self.etichetta_salva.setText(L("applicata", "applied"))
        else:
            QMessageBox.critical(self, L("Non riesco a salvare", "Could not save"), msg)

    def _scegli_pwm(self):
        u"""L'uscita da comandare, se non e' gia' scelta.

        Si prende quella gia' in modo manuale sullo stesso chip della ventola
        che legge: sulla BC-250 e' pwm2, l'unica che non e' governata dal chip.
        Non e' un indovinello — e' l'unica configurazione coerente possibile
        quando c'e' una sola ventola vera."""
        sensori = (self.stato or {}).get("sensori") or []
        ventole = [x for x in sensori if x.get("type") == "fan" and x.get("value")]
        chip = ventole[0]["key"].split(":")[0] if ventole else ""
        try:
            sys.path.insert(0, "/usr/share/skillfish")
            import hwmon
            _, pwm = hwmon.elenca()
        except Exception:
            return ""
        candidate = [p for p in pwm if p.modo is not None and p.modo >= 0]
        if chip:
            stesso = [p for p in candidate if p.chiave.split(":")[0] == chip]
            candidate = stesso or candidate
        for p in candidate:
            if p.modo == 1:
                return p.chiave
        return candidate[0].chiave if candidate else ""

    @staticmethod
    def _helper(azione, dati):
        try:
            p = subprocess.run(["pkexec", HELPER, azione],
                               input=json.dumps(dati), capture_output=True,
                               text=True, timeout=120)
        except Exception as e:
            return False, str(e)
        if p.returncode == 0:
            return True, ""
        msg = (p.stderr or p.stdout or "").strip()
        if p.returncode == 126:
            msg = L("Autenticazione annullata.", "Authentication cancelled.")
        return False, msg or L("errore sconosciuto", "unknown error")


def main():
    app = QApplication(sys.argv)
    app.setApplicationName("SkillFishOS Fan Control")
    # ⚠️ SENZA QUESTA RIGA la barra delle applicazioni scrive «python3».
    # KDE lega la finestra al file .desktop attraverso questo nome, e senza il
    # legame ripiega sul nome dell'eseguibile — che per una applicazione Python
    # e' l'interprete. Il suggerimento diceva «python3», e con lui se ne andava
    # anche l'icona giusta. Le altre sette applicazioni ce l'avevano gia': era
    # rimasta indietro solo questa.
    app.setDesktopFileName("os.skillfish.fan")
    if os.path.exists(ICON):
        app.setWindowIcon(QIcon(ICON))
    f = Finestra()
    f.show()
    return app.exec()


if __name__ == "__main__":
    sys.exit(main())
