#!/usr/bin/python3

import os
import argparse
import threading

import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, GLib, GObject, Gio

'''
Error messages.
'''

CANNOT_CONFIGURE_MSG = "You cannot change the configuration because you are not root"

CANNOT_CONTROL_MSG = "You cannot control the service because you are not root"
def make_version_tuple(version_string):
    return tuple(map(int, version_string.split('.')))
#!/usr/bin/env python3

import os
import re
import json
import socket
import subprocess
from collections import namedtuple

Sensor = namedtuple('Sensor', ['name', 'description'])

class NbfcClientError(Exception):
    pass

class NbfcClient:
    """
    A client to interact with the NBFC service using Unix sockets.
    """

    def __init__(self):
        """
        Initializes the NbfcClient instance by retrieving necessary file paths.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        self.socket_file = self.get_compile_time_variable('socket_file')
        self.config_file = self.get_compile_time_variable('config_file')
        self.model_configs_dir = self.get_compile_time_variable('model_configs_dir')
        self.model_configs_dir_mutable = '/var/lib/nbfc/configs'

    # =========================================================================
    # Helper methods
    # =========================================================================

    def call_nbfc(self, args):
        """
        Calls the `nbfc` binary with the given arguments and returns the output.

        Args:
            args (list of str):
                The arguments to pass to the NBFC client.

        Returns:
            str:
                The output (STDOUT) from the client command.

        Raises:
            NbfcClientError:
                - If the `nbfc` binary could not be found.

                - If the client command returns a non-zero exit code.
                  The exception's text is the output written to STDERR.
        """

        command = ['nbfc'] + args

        try:
            result = subprocess.run(command, capture_output=True, text=True, check=False)
        except FileNotFoundError as e:
            raise NbfcClientError('Could not find the `nbfc` program. Is NBFC-Linux installed?') from e

        if result.returncode != 0:
            raise NbfcClientError(result.stderr.rstrip())

        return result.stdout.rstrip()

    def socket_communicate(self, data):
        """
        Sends a JSON-encoded message to the NBFC service via a Unix socket
        and returns the response.

        Args:
            data (dict):
                The data to send to the NBFC service.

        Returns:
            dict:
                The response from the NBFC service.

        Raises:
            NbfcClientError:
                If the socket file could not be found.

            PermissionError:
                If the socket file could not be opened.

            JSONDecodeError:
                If the received JSON is invalid.

            TypeError:
                If `data` could not be serialized to JSON.
        """

        with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:

            try:
                sock.connect(self.socket_file)
            except FileNotFoundError:
                raise NbfcClientError(f'Could not find {self.socket_file}. Is the service running?') from None

            message = "%s\nEND" % json.dumps(data)
            sock.sendall(message.encode('utf-8'))

            response = b''
            while True:
                data = sock.recv(1024)
                if not data:
                    break

                response += data

                if b'\nEND' in response:
                    break

            response = response.decode('utf-8')
            response = response.replace('\nEND', '')
            response = json.loads(response)
            return response

    # =========================================================================
    # Methods based on `call_nbfc`
    # =========================================================================

    def get_compile_time_variable(self, variable):
        """
        Retrieves the value of a compile-time variable defined in the NBFC binary.

        Args:
            variable (str):
                The name of the compile-time variable to retrieve.

        Returns:
            str:
                The value assigned to the compile time variable.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """
        return self.call_nbfc(['show-variable', variable])

    def get_version(self):
        """
        Return the version of the nbfc client.

        Returns:
            str:
                The version in form of "MAJOR.MINOR.PATCH".

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        output = self.call_nbfc(['--version'])

        match = re.search(r'\d+\.\d+\.\d+', output)

        if not match:
            raise NbfcClientError('Could not extract version')

        return match[0]

    def start(self, readonly=False):
        """
        Starts the NBFC service.

        Args:
            readonly (bool):
                If True, starts the service in read-only mode.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        args = ['start']

        if readonly:
            args.append('-r')

        self.call_nbfc(args)

    def restart(self, readonly=False):
        """
        Restarts the NBFC service.

        Args:
            readonly (bool):
                If True, starts the service in read-only mode.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        args = ['restart']

        if readonly:
            args.append('-r')

        self.call_nbfc(args)

    def stop(self):
        """
        Stops the NBFC service.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        self.call_nbfc(['stop'])

    def get_model_name(self):
        """
        Retrieve the model name of the notebook.

        Returns:
            str:
                The model name of the notebook.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        return self.call_nbfc(['get-model-name'])

    def list_configs(self):
        """
        List all available model configurations.

        Returns:
            list of str:
                A list of all available model configurations.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        configs = self.call_nbfc(['config', '-l'])
        configs = configs.strip()

        if configs:
            return configs.split('\n')
        else:
            return []

    def recommended_configs(self):
        """
        List recommended model configurations.

        Returns:
            list of str:
                A list of recommended configurations.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        Note:
            This returns recommended configurations based solely on comparing
            your model name with configuration file names.
            This recommendation does not imply any further significance or validation
            of the configurations beyond the string matching.
        """

        configs = self.call_nbfc(['config', '-r'])
        configs = configs.strip()

        if configs:
            return configs.split('\n')
        else:
            return []

    def get_available_sensors(self):
        """
        Returns a list of all available sensors.

        Returns:
            list of Sensor:
                A list of all available sensors.

        Raises:
            NbfcClientError:
                See `call_nbfc` for further information.
        """

        sensors = []

        output = self.call_nbfc(['complete-sensors'])

        for line in output.split('\n'):
            parts = line.split('\t', maxsplit=1)

            if len(parts) == 2:
                name, description = parts

                # This is needed for old version of NBFC-Linux where the
                # `complete-sensors` outputs a `none` sensor
                if name == 'none':
                    continue

                sensors.append(Sensor(name, description))

        return sensors

    # =========================================================================
    # Methods based on `socket_communicate`
    # =========================================================================

    def get_status(self):
        """
        Retrieves the status of the NBFC service.

        Returns:
            dict:
                The status information of the NBFC service.

        Raises:
            NbfcClientError:
                If there is an error in the response from the service.
        """

        response = self.socket_communicate({'Command': 'status'})

        if 'Error' in response:
            raise NbfcClientError(response['Error'])

        return response

    def set_fan_speed(self, speed, fan=None):
        """
        Sets the fan speed.

        Args:
            speed (float, int, str):
                The desired fan speed in percent or "auto" for setting
                fan to auto-mode.

            fan (int, optional):
                The fan index to set the speed for. If not given, set the
                speed for all available fans.

        Raises:
            NbfcClientError:
                If there is an error in the response from the service.
        """

        request = {'Command': 'set-fan-speed', 'Speed': speed}

        if fan is not None:
            request['Fan'] = fan

        response = self.socket_communicate(request)

        if 'Error' in response:
            raise NbfcClientError(response['Error'])

    # =========================================================================
    # Methods for accessing / setting the configuration
    # =========================================================================

    def get_service_config(self):
        """
        Retrieves the current service configuration.

        Returns:
            dict:
                The current service configuration.

        Raises:
            PermissionError:
                If the file could not be read due to insufficient permissions.

            IsADirectoryError:
                If the file is a directory.

            JSONDecodeError:
                If the configuration file is not valid JSON.
        """

        try:
            with open(self.config_file, 'r', encoding='UTF-8') as fh:
                return json.load(fh)
        except FileNotFoundError:
            return {}

    def set_service_config(self, config):
        """
        Writes a new configuration to the service config file.

        Args:
            config (dict):
                The configuration data to write.

        Raises:
            PermissionError:
                If the program does not have permission to write to the config file.

            IsADirectoryError:
                If the config file is a directory.

            TypeError:
                If `config` could not be serialized to JSON.
        """

        with open(self.config_file, 'w', encoding='UTF-8') as fh:
            json.dump(config, fh, indent=1)

    def get_model_configuration_file(self):
        """
        Retrieve the file path of the model configuration file.

        Returns:
            str:
                The resolved file path of the model configuration file.

        Raises:
            NbfcClientError:
                If the configuration file could not be resolved.
        """

        config = self.get_service_config()

        if 'SelectedConfigId' not in config:
            raise NbfcClientError('Configuration has no model configuration ("SelectedConfigId") set')

        config_id = config['SelectedConfigId']

        if config_id.startswith('/'):
            return config_id

        model_config_path = os.path.join(self.model_configs_dir_mutable, config_id + '.json')
        if os.path.exists(model_config_path):
            return model_config_path

        model_config_path = os.path.join(self.model_configs_dir, config_id + '.json')
        if os.path.exists(model_config_path):
            return model_config_path

        raise NbfcClientError(f'No configuration file found for SelectedConfigId = "{config_id}"')

    def get_model_configuration(self):
        """
        Return the model configuration.

        Returns:
            dict:
                The model configuration.

        Raises:
            FileNotFoundError:
                If the file could not be found.

            PermissionError:
                If the file could not be read due to insufficient permissions.

            IsADirectoryError:
                If the file is a directory.

            JSONDecodeError:
                If the file contains invalid JSON.
        """

        config_file = self.get_model_configuration_file()

        with open(config_file, 'r', encoding='UTF-8') as fh:
            return json.load(fh)
class SubprocessWorker(GObject.GObject):
    __gsignals__ = {
        "output_line": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
        "error_line":  (GObject.SignalFlags.RUN_FIRST, None, (str,)),
        "finished":    (GObject.SignalFlags.RUN_FIRST, None, (int,))
    }

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

    def start(self):
        thread = threading.Thread(target=self._run_process, daemon=True)
        thread.start()

    def _run_process(self):
        process = subprocess.Popen(
            self.command,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            bufsize=1
        )

        def read_stream(stream, signal_name):
            for line in stream:
                GLib.idle_add(self.emit, signal_name, line)
            stream.close()

        stdout_thread = threading.Thread(target=read_stream, args=(process.stdout, "output_line"))
        stderr_thread = threading.Thread(target=read_stream, args=(process.stderr, "error_line"))

        stdout_thread.start()
        stderr_thread.start()

        stdout_thread.join()
        stderr_thread.join()

        exit_code = process.wait()
        GLib.idle_add(self.emit, "finished", exit_code)
def validate_fan_index(fan_temperature_source, fan_count):
    if 'FanIndex' not in fan_temperature_source:
        return ['Missing field: FanIndex']

    fan_index = fan_temperature_source['FanIndex']

    if not isinstance(fan_index, int):
        return ['FanIndex: Invalid type (expected integer)']

    if fan_index < 0:
        return ['FanIndex: Cannot be negative']

    if fan_index >= fan_count:
        return ['FanIndex: No fan found for FanIndex `%d`' % fan_index]

    return []

def validate_algorithm_type(fan_temperature_source):
    if 'TemperatureAlgorithmType' not in fan_temperature_source:
        return []

    algorithm = fan_temperature_source['TemperatureAlgorithmType']

    if not isinstance(algorithm, str):
        return ['TemperatureAlgorithmType: Invalid type (expected string)']

    if algorithm not in ('Average', 'Min', 'Max'):
        return ['TemperatureAlgorithmType: Invalid value: %s' % algorithm]

    return []

def validate_sensors(fan_temperature_source):
    if 'Sensors' not in fan_temperature_source:
        return []

    sensors = fan_temperature_source['Sensors']

    if not isinstance(sensors, list):
        return ['Sensors: Invalid type (expected array)']

    errs = []

    for i, sensor in enumerate(fan_temperature_source['Sensors']):
        if not isinstance(sensor, str):
            errs.append('Sensors[%d]: Invalid type (expected string)' % i)

    return errs

def validate_fan_temperature_sources(fan_temperature_sources, fan_count):
    '''
    Checks `fan_temperature_sources` for errors.

    Args:
        fan_temperature_sources (list): A list of FanTemperatureSource objects.
        fan_count (int): The fan count of the service.

    Returns:
        list: A list of error strings describing what's wrong. This list is
              empty if no errors are found.
    '''

    errors = []

    for i, fan_temperature_source in enumerate(fan_temperature_sources):

        for err in validate_fan_index(fan_temperature_source, fan_count):
            errors.append('FanTemperatureSources[%d]: %s' % (i, err))

        for err in validate_algorithm_type(fan_temperature_source):
            errors.append('FanTemperatureSources[%d]: %s' % (i, err))

        for err in validate_sensors(fan_temperature_source):
            errors.append('FanTemperatureSources[%d]: %s' % (i, err))

        # Check for invalid fields
        for field in fan_temperature_source:
            if field not in ('FanIndex', 'TemperatureAlgorithmType', 'Sensors'):
                errors.append('FanTemperatureSources[%d]: Unknown field: %s' % (i, field))

    return errors

def fix_fan_temperature_source(fan_temperature_source, fan_count):
    # =========================================================================
    # Fix FanIndex
    #
    # - Drop configuration completely if FanIndex is invalid
    # =========================================================================

    if 'FanIndex' not in fan_temperature_source:
        return None

    fan_index = fan_temperature_source.get('FanIndex', None)

    if not isinstance(fan_index, int):
        return None

    if fan_index < 0:
        return None

    if fan_index >= fan_count:
        return None

    # =========================================================================
    # Fix TemperatureAlgorithmType
    #
    # - Unset the value if TemperatureAlgorithmType is invalid
    # =========================================================================

    algorithm = None

    if 'TemperatureAlgorithmType' in fan_temperature_source:
        algorithm = fan_temperature_source['TemperatureAlgorithmType']

        if algorithm not in ('Average', 'Min', 'Max'):
            algorithm = None

    # =========================================================================
    # Fix Sensors
    #
    # - Drop sensors that are not string
    # =========================================================================

    sensors = []

    if 'Sensors' in fan_temperature_source:
        if isinstance(fan_temperature_source['Sensors'], list):
            for sensor in fan_temperature_source['Sensors']:
                if not isinstance(sensor, str):
                    continue

                sensors.append(sensor)

    # =========================================================================
    # Return the object
    # =========================================================================

    obj = {'FanIndex': fan_index}

    if algorithm:
        obj['TemperatureAlgorithmType'] = algorithm

    if sensors:
        obj['Sensors'] = sensors

    return obj

def fix_fan_temperature_sources(fan_temperature_sources, fan_count):
    '''
    Fixes an invalid FanTemperatureSources config.

    Args:
        fan_temperature_sources (list): A list of FanTemperatureSource objects.
        fan_count (int): The fan count of the service.

    Returns:
        list: A list of fixed FanTemperatureSource objects.
    '''

    result = []

    for fan_temperature_source in fan_temperature_sources:
        o = fix_fan_temperature_source(fan_temperature_source, fan_count)
        if o:
            result.append(o)

    return result

def get_children(container):
    children = []
    child = container.get_first_child()
    while child is not None:
        children.append(child)
        child = child.get_next_sibling()
    return children

class ErrorWidget(Gtk.Window):
    def __init__(self, parent, title, message):
        super().__init__(title=title, transient_for=parent, modal=True)

        # =====================================================================
        # Box (Vertical)
        # =====================================================================

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        vbox.set_margin_start(12)
        vbox.set_margin_end(12)
        vbox.set_margin_top(12)
        vbox.set_margin_bottom(12)
        self.set_child(vbox)

        # =====================================================================
        # Markup
        # =====================================================================

        desc = Gtk.Label()
        desc.set_margin_start(12)
        desc.set_margin_end(12)
        desc.set_margin_top(12)
        desc.set_margin_bottom(12)
        desc.set_text(message)
        vbox.append(desc)

        # =====================================================================
        # OK Button
        # =====================================================================

        ok_button = Gtk.Button(label="OK")
        ok_button.connect("clicked", lambda *_: self.close())
        vbox.append(ok_button)

        self.present()

def show_error_message(widget, title, message):
    if widget is not None:
        toplevel = widget.get_ancestor(Gtk.Window)
    else:
        toplevel = None

    dialog = ErrorWidget(toplevel, title, message)

class FileDialogHelper:
    @staticmethod
    def get_open_filename(parent, title="Open File", filters=None):
        dialog = Gtk.FileDialog.new()
        dialog.set_title(title)

        if filters:
            filter_store = Gio.ListStore.new(Gtk.FileFilter)
            for name, pattern in filters:
                file_filter = Gtk.FileFilter()
                file_filter.set_name(name)
                file_filter.add_pattern(pattern)
                filter_store.append(file_filter)
            dialog.set_filters(filter_store)

        selected_file = {"path": None}
        loop = GLib.MainLoop()

        def on_open_ready(dialog, result):
            try:
                f = dialog.open_finish(result)
                selected_file["path"] = f.get_path()
            except GLib.Error:
                selected_file["path"] = None
            finally:
                loop.quit()

        #dialog.open(parent, None, on_open_ready)
        dialog.open(None, None, on_open_ready)
        loop.run()

        return selected_file["path"]

class SimpleItem(GObject.Object):
    __gtype_name__ = "SimpleItem"

    def __init__(self, key, value):
        super().__init__()
        self.k = key
        self.v = value

    @GObject.Property
    def key(self):
        return self.k

    @GObject.Property
    def value(self):
        return self.v

    def getKey(self):
        return self.k

    def getValue(self):
        return self.v

class SimpleDropDown(Gtk.DropDown):
    def __init__(self):
        factory = Gtk.SignalListItemFactory()
        factory.connect("setup", self._on_factory_setup)
        factory.connect("bind", self._on_factory_bind)

        self.model = Gio.ListStore(item_type=SimpleItem)

        super().__init__(model=self.model, factory=factory)

    def _on_factory_setup(self, factory, list_item):
        label = Gtk.Label()
        list_item.set_child(label)

    def _on_factory_bind(self, factory, list_item):
        label = list_item.get_child()
        item = list_item.get_item()
        label.set_text(item.getValue())

    def add(self, key, value):
        self.model.append(SimpleItem(key, value))

class SimpleListView(Gtk.Box):
    __gtype_name__ = 'SimpleListView'

    def __init__(self, orientation=Gtk.Orientation.VERTICAL, **kwargs):
        super().__init__(orientation=orientation, **kwargs)

        # 1) Model: ListStore for Item objects
        self.model = Gio.ListStore.new(SimpleItem)

        # 2) Selection model
        self.selection = Gtk.SingleSelection.new(self.model)

        # 3) Factory for rendering items
        self.factory = Gtk.SignalListItemFactory.new()
        self.factory.connect("setup", self._on_setup)
        self.factory.connect("bind",  self._on_bind)

        # 4) ListView creation
        self.list_view = Gtk.ListView.new(self.selection, self.factory)

        # 5) Wrap in ScrolledWindow
        scrolled = Gtk.ScrolledWindow.new()
        scrolled.set_child(self.list_view)
        self.append(scrolled)

    def _on_setup(self, factory, list_item):
        """Create the child widget (a left-aligned label)."""
        label = Gtk.Label.new()
        label.set_xalign(0)
        list_item.set_child(label)

    def _on_bind(self, factory, list_item):
        """Bind each Item.value to the label text."""
        item = list_item.get_item()
        label = list_item.get_child()
        label.set_text(item.value)

    def add(self, key, value):
        self.model.append(SimpleItem(key, value))

    def get_selected_idx(self):
        return self.selection.get_selected()

    def get_selected_id(self):
        """Return the id of the currently selected item, or None."""
        idx = self.selection.get_selected()
        if idx >= 0:
            return self.model.get_item(idx).getKey()
        return None

    def get_selected_value(self):
        """Return the display value of the currently selected item, or None."""
        idx = self.selection.get_selected()
        if idx >= 0:
            return self.model.get_item(idx).getValue()
        return None

    def select_id(self, id_to_select):
        """Programmatically select the item matching the given id."""
        for idx in range(self.model.get_n_items()):
            if self.model.get_item(idx).getKey() == id_to_select:
                self.selection.set_selected(idx)
                return


class Globals(GObject.GObject):
    __gsignals__ = {
        "restart_service":      (GObject.SignalFlags.RUN_FIRST, None, (int,)),
        "model_config_changed": (GObject.SignalFlags.RUN_FIRST, None, ()),
    }

    is_root = (os.geteuid() == 0)
    nbfc_client = None

    def __init__(self):
        super().__init__()

    def init(self):
        self.nbfc_client = NbfcClient()

GLOBALS = Globals()
VERSION = "0.2.1"
REQUIRED_NBFC_VERSION = '0.3.16'
GITHUB_URL = 'https://github.com/nbfc-linux/nbfc-linux'

ABOUT_NBFC_LINUX = """\
<b>NBFC-Linux</b> is a fan control utility designed for Linux systems.

<b>Author</b>: <a href="https://github.com/braph">Benjamin Abendroth</a>

<b>License</b>: GPL-3.0

<b>Project Homepage</b>: <a href="https://github.com/nbfc-linux/nbfc-linux">GitHub.com/NBFC-Linux/NBFC-Linux</a>

<b>Donation Link</b>: <a href="https://paypal.me/BenjaminAbendroth">PayPal.me/BenjaminAbendroth</a>

<b>AUR Packages</b>: Provided by <a href="https://github.com/BachoSeven">Francesco Minnocci</a>
"""

class AboutWidget(Gtk.Window):
    def __init__(self, parent):
        super().__init__(title="About NBFC-Gtk", transient_for=parent, modal=True)

        # =====================================================================
        # Geometry
        # =====================================================================

        self.set_default_size(300, 200)

        # =====================================================================
        # Box (Vertical)
        # =====================================================================

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        vbox.set_margin_start(12)
        vbox.set_margin_end(12)
        vbox.set_margin_top(12)
        vbox.set_margin_bottom(12)
        self.set_child(vbox)

        # =====================================================================
        # Markup
        # =====================================================================

        desc = Gtk.Label()
        desc.set_markup(ABOUT_NBFC_LINUX)
        vbox.append(desc)

        # =====================================================================
        # OK Button
        # =====================================================================

        ok_button = Gtk.Button(label="OK")
        ok_button.connect("clicked", lambda *_: self.close())
        vbox.append(ok_button)
class ApplyButtonsWidget(Gtk.Box):
    def __init__(self):
        super().__init__(orientation=Gtk.Orientation.VERTICAL)

        # =====================================================================
        # Read-only checkbox
        # =====================================================================

        self.read_only_checkbox = Gtk.CheckButton(label="(Re-)start in read-only mode")
        self.read_only_checkbox.set_margin_start(6)
        self.read_only_checkbox.set_margin_end(6)
        self.read_only_checkbox.set_margin_top(6)
        #self.read_only_checkbox.set_margin_bottom(6)
        self.append(self.read_only_checkbox)

        # =====================================================================
        # Save and Apply buttons
        # =====================================================================

        hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        self.append(hbox)

        self.save_button = Gtk.Button(label="Save")
        self.save_button.set_margin_start(6)
        self.save_button.set_margin_end(6)
        self.save_button.set_margin_top(6)
        self.save_button.set_margin_bottom(6)
        self.save_button.set_hexpand(True)
        hbox.append(self.save_button)

        self.apply_button = Gtk.Button(label="Apply with (re-)start")
        self.apply_button.set_margin_start(6)
        self.apply_button.set_margin_end(6)
        self.apply_button.set_margin_top(6)
        self.apply_button.set_margin_bottom(6)
        self.apply_button.set_hexpand(True)
        hbox.append(self.apply_button)

        # =====================================================================
        # Error label
        # =====================================================================

        self.error_label = Gtk.Label()
        self.error_label.set_margin_start(6)
        self.error_label.set_margin_end(6)
        self.error_label.set_margin_top(6)
        self.error_label.set_margin_bottom(6)
        self.error_label.set_visible(False)
        self.append(self.error_label)

    def enable(self):
        self.error_label.set_visible(False)
        self.read_only_checkbox.set_sensitive(True)
        self.save_button.set_sensitive(True)
        self.apply_button.set_sensitive(True)

    def disable(self, reason):
        self.error_label.set_text(reason)
        self.error_label.set_visible(True)
        self.read_only_checkbox.set_sensitive(False)
        self.save_button.set_sensitive(False)
        self.apply_button.set_sensitive(False)
class ServiceControlWidget(Gtk.Box):
    def __init__(self):
        super().__init__(orientation=Gtk.Orientation.VERTICAL)

        # =====================================================================
        # Timer
        # =====================================================================

        self.timer_id = None

        # =====================================================================
        # Service Status
        # =====================================================================

        self.status_label = Gtk.Label()
        self.status_label.set_margin_start(6)
        self.status_label.set_margin_end(6)
        self.status_label.set_margin_top(6)
        self.status_label.set_margin_bottom(6)
        self.append(self.status_label)

        # =====================================================================
        # Service Log
        # =====================================================================

        scrolled = Gtk.ScrolledWindow()
        scrolled.set_margin_start(6)
        scrolled.set_margin_end(6)
        scrolled.set_margin_top(6)
        scrolled.set_margin_bottom(6)
        scrolled.set_vexpand(True)
        scrolled.set_hexpand(True)
        self.append(scrolled)

        self.log = Gtk.TextView()
        self.log.set_editable(False)
        scrolled.set_child(self.log)

        # =====================================================================
        # Read-Only Checkbox
        # =====================================================================

        self.read_only_checkbox = Gtk.CheckButton(label="Start in read-only mode")
        self.read_only_checkbox.set_margin_start(6)
        self.read_only_checkbox.set_margin_end(6)
        self.read_only_checkbox.set_margin_top(6)
        self.read_only_checkbox.set_margin_bottom(6)
        self.append(self.read_only_checkbox)

        # =====================================================================
        # Buttons
        # =====================================================================

        hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        self.append(hbox)

        self.start_button = Gtk.Button(label="Start")
        self.start_button.set_margin_start(6)
        self.start_button.set_margin_end(6)
        self.start_button.set_margin_top(6)
        self.start_button.set_margin_bottom(6)
        self.start_button.set_hexpand(True)
        self.start_button.connect("clicked", self.start_button_clicked)
        hbox.append(self.start_button)

        self.stop_button = Gtk.Button(label="Stop")
        self.stop_button.set_margin_start(6)
        self.stop_button.set_margin_end(6)
        self.stop_button.set_margin_top(6)
        self.stop_button.set_margin_bottom(6)
        self.stop_button.set_hexpand(True)
        self.stop_button.connect("clicked", self.stop_button_clicked)
        hbox.append(self.stop_button)

        # =====================================================================
        # Message label
        # =====================================================================

        self.message = Gtk.Label()
        self.message.set_margin_start(6)
        self.message.set_margin_end(6)
        self.message.set_margin_top(6)
        self.message.set_margin_bottom(6)
        self.append(self.message)

        # =====================================================================
        # Init
        # =====================================================================

        GLOBALS.connect("restart_service", self.service_restart_signal)

    # =========================================================================
    # Widget start / stop
    # =========================================================================

    def start(self):
        self.update()
        if self.timer_id is None:
            self.timer_id = GLib.timeout_add(500, self.update)

    def stop(self):
        if self.timer_id is not None:
            GLib.source_remove(self.timer_id)
            self.timer_id = None

    # =========================================================================
    # Helper functions
    # =========================================================================

    def update(self):
        try:
            GLOBALS.nbfc_client.get_status()
            self.status_label.set_markup("<b>Running</b>")
            self.start_button.set_sensitive(False)
            self.stop_button.set_sensitive(True)
        except Exception as e:
            self.status_label.set_markup("<b>Stopped</b>")
            self.start_button.set_sensitive(True)
            self.stop_button.set_sensitive(False)

        if not GLOBALS.is_root:
            self.read_only_checkbox.set_sensitive(False)
            self.start_button.set_sensitive(False)
            self.stop_button.set_sensitive(False)
            self.message.set_visible(True)
            self.message.set_text(CANNOT_CONTROL_MSG)
        else:
            self.message.set_visible(False)

        return True

    def call_with_log(self, args):
        self.log.get_buffer().set_text("")

        # We need to attach the worker thread to the class instance
        # because it would get destroyed otherwise
        self.worker = SubprocessWorker(args)
        self.worker.connect("output_line", self.handle_output)
        self.worker.connect("error_line", self.handle_output)
        self.worker.start()

    def handle_output(self, _, line):
        buffer = self.log.get_buffer()
        buffer.insert(buffer.get_end_iter(), '%s\n' % line.rstrip())

    def service_start(self, readonly):
        args = ['nbfc', 'start']

        if readonly:
            args.append('-r')

        self.call_with_log(args)

    def service_restart(self, readonly):
        args = ['nbfc', 'restart']

        if readonly:
            args.append('-r')

        self.call_with_log(args)

    def service_stop(self):
        GLOBALS.nbfc_client.stop()

    # =========================================================================
    # Signal functions
    # =========================================================================

    def service_restart_signal(self, _, readonly):
        self.service_restart(readonly)

    def start_button_clicked(self, _):
        self.service_start(self.read_only_checkbox.get_active())

    def stop_button_clicked(self, _):
        self.service_stop()
class BasicConfigWidget(Gtk.Box):
    def __init__(self):
        super().__init__(orientation=Gtk.Orientation.VERTICAL)

        # =====================================================================
        # Model label
        # =====================================================================

        hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        self.append(hbox)

        label = Gtk.Label(label="Your laptop model:")
        label.set_hexpand(True)
        label.set_margin_start(6)
        label.set_margin_end(6)
        label.set_margin_top(6)
        label.set_margin_bottom(6)
        hbox.append(label)

        self.model_name_label = Gtk.Label()
        self.model_name_label.set_hexpand(True)
        self.model_name_label.set_margin_start(6)
        self.model_name_label.set_margin_end(6)
        self.model_name_label.set_margin_top(6)
        self.model_name_label.set_margin_bottom(6)
        hbox.append(self.model_name_label)

        # =====================================================================
        # Selected config input + Reset
        # =====================================================================

        hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        self.append(hbox)

        self.selected_config_input = Gtk.Entry()
        self.selected_config_input.set_placeholder_text("Configuration File")
        self.selected_config_input.set_hexpand(True)
        self.selected_config_input.set_margin_start(6)
        self.selected_config_input.set_margin_end(6)
        self.selected_config_input.set_margin_top(6)
        self.selected_config_input.set_margin_bottom(6)
        self.selected_config_input.connect("changed", self.update_apply_buttons)
        hbox.append(self.selected_config_input)

        self.reset_button = Gtk.Button(label="Reset")
        self.reset_button.set_margin_start(6)
        self.reset_button.set_margin_end(6)
        self.reset_button.set_margin_top(6)
        self.reset_button.set_margin_bottom(6)
        self.reset_button.connect("clicked", self.reset_button_clicked)
        hbox.append(self.reset_button)

        # =====================================================================
        # Radio Buttons
        # =====================================================================

        self.list_all_radio = Gtk.CheckButton(label="List all configurations")
        self.list_all_radio.set_margin_start(6)
        self.list_all_radio.set_margin_end(6)
        self.list_all_radio.set_margin_top(6)
        self.list_all_radio.set_margin_bottom(6)
        self.list_all_radio.set_group(None)
        self.list_all_radio.set_active(True)
        self.list_all_radio.connect("toggled", self.list_all_radio_checked)
        self.append(self.list_all_radio)

        self.list_recommended_radio = Gtk.CheckButton(label="List recommended configurations")
        self.list_recommended_radio.set_margin_start(6)
        self.list_recommended_radio.set_margin_end(6)
        self.list_recommended_radio.set_margin_top(6)
        self.list_recommended_radio.set_margin_bottom(6)
        self.list_recommended_radio.set_group(self.list_all_radio)
        self.list_recommended_radio.connect("toggled", self.list_recommended_radio_checked)
        self.append(self.list_recommended_radio)

        self.custom_file_radio = Gtk.CheckButton(label="Custom file")
        self.custom_file_radio.set_margin_start(6)
        self.custom_file_radio.set_margin_end(6)
        self.custom_file_radio.set_margin_top(6)
        self.custom_file_radio.set_margin_bottom(6)
        self.custom_file_radio.set_group(self.list_all_radio)
        self.custom_file_radio.connect("toggled", self.custom_file_radio_checked)
        self.append(self.custom_file_radio)

        # =====================================================================
        # Model selection combo box + Set button
        # =====================================================================

        hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        self.append(hbox)

        self.configurations_dropdown = SimpleDropDown()
        self.configurations_dropdown.set_margin_start(6)
        self.configurations_dropdown.set_margin_end(6)
        self.configurations_dropdown.set_margin_top(6)
        self.configurations_dropdown.set_margin_bottom(6)
        self.configurations_dropdown.set_hexpand(True)
        hbox.append(self.configurations_dropdown)

        self.set_button = Gtk.Button(label="Set")
        self.set_button.set_margin_start(6)
        self.set_button.set_margin_end(6)
        self.set_button.set_margin_top(6)
        self.set_button.set_margin_bottom(6)
        self.set_button.connect("clicked", self.set_button_clicked)
        hbox.append(self.set_button)

        # =====================================================================
        # File selection
        # =====================================================================

        self.select_file_button = Gtk.Button(label="Select file ...")
        self.select_file_button.set_margin_start(6)
        self.select_file_button.set_margin_end(6)
        self.select_file_button.set_margin_top(6)
        self.select_file_button.set_margin_bottom(6)
        self.select_file_button.set_hexpand(True)
        self.select_file_button.connect("clicked", self.select_file_button_clicked)
        self.append(self.select_file_button)

        # =====================================================================
        # Stretch
        # =====================================================================

        stretch = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        stretch.set_vexpand(True)
        self.append(stretch)

        # =====================================================================
        # Apply buttons
        # =====================================================================

        self.apply_buttons_widget = ApplyButtonsWidget()
        self.apply_buttons_widget.save_button.connect("clicked", self.save_button_clicked)
        self.apply_buttons_widget.apply_button.connect("clicked", self.apply_button_clicked)
        self.append(self.apply_buttons_widget)

        # =====================================================================
        # Initialization
        # =====================================================================

        self.list_all_radio_checked(None)
        self.update_apply_buttons()

        try:
            self.reset_config()
        except:
            pass

        try:
            model = GLOBALS.nbfc_client.get_model_name()
            self.model_name_label.set_markup(f"<b>{model}</b>")
        except:
            self.model_name_label.set_markupText("<b>Could not get model name</b>")

    # =========================================================================
    # Widget start / stop
    # =========================================================================

    def start(self):
        pass

    def stop(self):
        pass

    # =========================================================================
    # Helper functions
    # =========================================================================

    def update_apply_buttons(self, *_):
        if not GLOBALS.is_root:
            self.apply_buttons_widget.disable(CANNOT_CONFIGURE_MSG)
        elif not self.selected_config_input.get_text():
            self.apply_buttons_widget.disable("No model configuration selected")
        else:
            self.apply_buttons_widget.enable()

    def reset_config(self):
        '''
        Reset the `SelectedConfigId` field to its original value.

        This may raise an exception.
        '''

        config = GLOBALS.nbfc_client.get_service_config()

        SelectedConfigId = config.get('SelectedConfigId', '')

        self.selected_config_input.set_text(SelectedConfigId)

    def save_config(self):
        '''
        Save the selected configuration to the service configuration file.

        This may raise an exception.
        '''

        config = GLOBALS.nbfc_client.get_service_config()

        old_config = config.get('SelectedConfigId', '')

        config['SelectedConfigId'] = self.selected_config_input.get_text()

        GLOBALS.nbfc_client.set_service_config(config)

        if old_config != config['SelectedConfigId']:
            GLib.idle_add(GLOBALS.emit, "model_config_changed")

    def update_configuration_combobox(self, available_configs):
        self.configurations_dropdown.model.remove_all()

        for config in available_configs:
            self.configurations_dropdown.add(config, config)

        if self.configurations_dropdown.model.get_n_items():
            self.set_button.set_sensitive(True)
        else:
            self.set_button.set_sensitive(False)

    # =========================================================================
    # Signal functions
    # =========================================================================

    def reset_button_clicked(self, _):
        try:
            self.reset_config()
        except Exception as e:
            show_error_message(self, "Error", str(e))

    def save_button_clicked(self, _):
        try:
            self.save_config()
        except Exception as e:
            show_error_message(self, "Error", str(e))

    def apply_button_clicked(self, _):
        try:
            self.save_config()
            read_only = self.apply_buttons_widget.read_only_checkbox.get_active()
            GLib.idle_add(GLOBALS.emit, "restart_service", read_only)
        except Exception as e:
            show_error_message(self, "Error", str(e))

    def list_all_radio_checked(self, _):
        self.select_file_button.set_visible(False)
        self.configurations_dropdown.set_visible(True)
        self.set_button.set_visible(True)

        configs = GLOBALS.nbfc_client.list_configs()
        self.update_configuration_combobox(configs)

    def list_recommended_radio_checked(self, _):
        self.select_file_button.set_visible(False)
        self.configurations_dropdown.set_visible(True)
        self.set_button.set_visible(True)

        configs = GLOBALS.nbfc_client.recommended_configs()
        self.update_configuration_combobox(configs)

    def custom_file_radio_checked(self, _):
        self.select_file_button.set_visible(True)
        self.set_button.set_visible(False)
        self.configurations_dropdown.set_visible(False)

    def set_button_clicked(self, _):
        item = self.configurations_dropdown.get_selected_item()
        if item:
            self.selected_config_input.set_text(item.getKey())

    def select_file_button_clicked(self, _):
        path = FileDialogHelper.get_open_filename(self, "Choose Configuration File", filters=[("JSON Files", "*.json")])
        if path:
            self.selected_config_input.set_text(path)
class FanWidget(Gtk.Frame):
    def __init__(self):
        super().__init__()

        self.fan_index = None

        # =====================================================================
        # Layout
        # =====================================================================

        self.set_margin_start(6)
        self.set_margin_end(6)
        self.set_margin_top(6)
        self.set_margin_bottom(6)

        # =====================================================================
        # Grid box
        # =====================================================================

        grid_box = Gtk.Grid()
        grid_box.set_margin_top(6)
        grid_box.set_margin_bottom(6)
        grid_box.set_halign(Gtk.Align.CENTER)
        grid_box.set_column_homogeneous(True)
        self.set_child(grid_box)

        # =====================================================================
        # Grid content
        # =====================================================================

        label = Gtk.Label(label="Fan name")
        self.name_label = Gtk.Label()
        grid_box.attach(label, 0, 0, 1, 1)
        grid_box.attach(self.name_label, 1, 0, 1, 1)

        label = Gtk.Label(label="Temperature")
        self.temperature_label = Gtk.Label()
        grid_box.attach(label, 0, 1, 1, 1)
        grid_box.attach(self.temperature_label, 1, 1, 1, 1)

        label = Gtk.Label(label="Auto mode")
        self.auto_mode_label = Gtk.Label()
        grid_box.attach(label, 0, 2, 1, 1)
        grid_box.attach(self.auto_mode_label, 1, 2, 1, 1)

        label = Gtk.Label(label="Critical")
        self.critical_label = Gtk.Label()
        grid_box.attach(label, 0, 3, 1, 1)
        grid_box.attach(self.critical_label, 1, 3, 1, 1)

        label = Gtk.Label(label="Current speed")
        self.current_speed_label = Gtk.Label()
        grid_box.attach(label, 0, 4, 1, 1)
        grid_box.attach(self.current_speed_label, 1, 4, 1, 1)

        label = Gtk.Label(label="Target speed")
        self.target_speed_label = Gtk.Label()
        grid_box.attach(label, 0, 5, 1, 1)
        grid_box.attach(self.target_speed_label, 1, 5, 1, 1)

        label = Gtk.Label(label="Speed steps")
        self.speed_steps_label = Gtk.Label()
        grid_box.attach(label, 0, 6, 1, 1)
        grid_box.attach(self.speed_steps_label, 1, 6, 1, 1)

        # =====================================================================
        # Auto mode checkbox
        # =====================================================================

        self.auto_mode_checkbox = Gtk.CheckButton(label="Auto mode")
        self.auto_mode_checkbox.connect("toggled", self.update_fan_speed)
        grid_box.attach(self.auto_mode_checkbox, 0, 7, 2, 1)
        
        # =====================================================================
        # Speed slider
        # =====================================================================

        adjustment = Gtk.Adjustment(value=0, lower=0, upper=100, step_increment=1, page_increment=10, page_size=0)
        self.speed_scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adjustment)
        self.speed_scale.set_hexpand(True)
        self.speed_scale.set_value_pos(Gtk.PositionType.RIGHT)
        self.speed_scale.set_digits(0)
        self.speed_scale.connect("value-changed", self.update_fan_speed)
        grid_box.attach(self.speed_scale, 0, 8, 2, 1)

    # =========================================================================
    # Helper functions
    # =========================================================================

    def update(self, fan_index, fan_data):
        self.fan_index = fan_index
        self.name_label.set_text(fan_data['Name'])
        self.temperature_label.set_text(str(fan_data['Temperature']))
        self.auto_mode_label.set_text(str(fan_data['AutoMode']))
        self.critical_label.set_text(str(fan_data['Critical']))
        self.current_speed_label.set_text(str(fan_data['CurrentSpeed']))
        self.target_speed_label.set_text(str(fan_data['TargetSpeed']))
        self.speed_steps_label.set_text(str(fan_data['SpeedSteps']))

        # Block signals to avoid triggering during update
        self.auto_mode_checkbox.handler_block_by_func(self.update_fan_speed)
        self.speed_scale.handler_block_by_func(self.update_fan_speed)

        self.auto_mode_checkbox.set_active(fan_data['AutoMode'])
        self.speed_scale.set_value(int(fan_data['RequestedSpeed']))

        # Unblock signals after update
        self.auto_mode_checkbox.handler_unblock_by_func(self.update_fan_speed)
        self.speed_scale.handler_unblock_by_func(self.update_fan_speed)

    # =========================================================================
    # Signal functions
    # =========================================================================

    def update_fan_speed(self, *_):
        auto_mode = self.auto_mode_checkbox.get_active()

        if auto_mode:
            GLOBALS.nbfc_client.set_fan_speed('auto', self.fan_index)
        else:
            GLOBALS.nbfc_client.set_fan_speed(self.speed_scale.get_value(), self.fan_index)

        self.speed_scale.set_sensitive(not auto_mode)
class FanControlWidget(Gtk.Box):
    def __init__(self):
        super().__init__(orientation=Gtk.Orientation.VERTICAL)

        # =====================================================================
        # Timer
        # =====================================================================

        self.timer_id = None

        # =====================================================================
        # Error Widget
        # =====================================================================

        self.error_label = Gtk.Label()
        self.error_label.set_hexpand(True)
        self.error_label.set_vexpand(True)
        self.append(self.error_label)

        # =====================================================================
        # Contents
        # =====================================================================

        self.scrolled = Gtk.ScrolledWindow()
        self.scrolled.set_vexpand(True)
        self.scrolled.set_hexpand(True)
        self.append(self.scrolled)

        self.widgets = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.scrolled.set_child(self.widgets)

    # =========================================================================
    # Widget start / stop
    # =========================================================================

    def start(self):
        self.update()

        if self.timer_id is None:
            self.timer_id = GLib.timeout_add(500, self.update)

    def stop(self):
        if self.timer_id is not None:
            GLib.source_remove(self.timer_id)
            self.timer_id = None

    # =========================================================================
    # Helper functions
    # =========================================================================

    def update(self):
        try:
            status = GLOBALS.nbfc_client.get_status()
            self.scrolled.set_visible(True)
            self.error_label.set_visible(False)
        except Exception as e:
            self.scrolled.set_visible(False)
            self.error_label.set_visible(True)
            self.error_label.set_text(str(e))
            return

        while len(get_children(self.widgets)) < len(status['Fans']):
            widget = FanWidget()
            self.widgets.append(widget)

        while len(get_children(self.widgets)) > len(status['Fans']):
            widget = get_children(self.widgets)[-1]
            self.widgets.remove(widget)

        for fan_index, fan_data in enumerate(status['Fans']):
            widget = get_children(self.widgets)[fan_index]
            widget.update(fan_index, fan_data)

        return True
class SensorWidget(Gtk.Box):
    def __init__(self):
        super().__init__(orientation=Gtk.Orientation.VERTICAL)

        self.fan_index = None

        # =====================================================================
        # Algorithm type
        # =====================================================================

        label = Gtk.Label(label="Algorithm:")
        label.set_margin_start(6)
        label.set_margin_end(6)
        label.set_margin_top(6)
        label.set_margin_bottom(6)
        self.append(label)

        algorithm_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        self.append(algorithm_box)

        self.default_radio = Gtk.CheckButton(label="Default")
        self.default_radio.set_margin_start(6)
        self.default_radio.set_margin_end(6)
        self.default_radio.set_margin_top(6)
        self.default_radio.set_margin_bottom(6)
        self.default_radio.set_group(None)
        self.default_radio.set_active(True)
        algorithm_box.append(self.default_radio)

        self.average_radio = Gtk.CheckButton(label="Average")
        self.average_radio.set_margin_start(6)
        self.average_radio.set_margin_end(6)
        self.average_radio.set_margin_top(6)
        self.average_radio.set_margin_bottom(6)
        self.average_radio.set_group(self.default_radio)
        algorithm_box.append(self.average_radio)

        self.max_radio = Gtk.CheckButton(label="Max")
        self.max_radio.set_margin_start(6)
        self.max_radio.set_margin_end(6)
        self.max_radio.set_margin_top(6)
        self.max_radio.set_margin_bottom(6)
        self.max_radio.set_group(self.default_radio)
        algorithm_box.append(self.max_radio)

        self.min_radio = Gtk.CheckButton(label="Min")
        self.min_radio.set_margin_start(6)
        self.min_radio.set_margin_end(6)
        self.min_radio.set_margin_top(6)
        self.min_radio.set_margin_bottom(6)
        self.min_radio.set_group(self.default_radio)
        algorithm_box.append(self.min_radio)

        # =====================================================================
        # Temperature Sources
        # =====================================================================

        label = Gtk.Label(label="Temperature Sources:")
        label.set_margin_start(6)
        label.set_margin_end(6)
        label.set_margin_top(6)
        label.set_margin_bottom(6)
        self.append(label)

        self.temperature_sources = SimpleListView()
        self.temperature_sources.set_vexpand(True)
        self.append(self.temperature_sources)

        # =====================================================================
        # Sensors
        # =====================================================================

        self.sensors = SimpleDropDown()
        self.sensors.set_margin_start(6)
        self.sensors.set_margin_end(6)
        self.sensors.set_margin_top(6)
        self.sensors.set_margin_bottom(6)
        self.sensors.connect("notify::selected-item", self.sensors_changed)
        self.append(self.sensors)

        # =====================================================================
        # Custom sensor
        # =====================================================================

        self.custom_sensor = Gtk.Entry()
        self.custom_sensor.set_margin_start(6)
        self.custom_sensor.set_margin_end(6)
        self.custom_sensor.set_margin_top(6)
        self.custom_sensor.set_margin_bottom(6)
        self.custom_sensor.set_visible(False)
        self.append(self.custom_sensor)

        # =====================================================================
        # Buttons
        # =====================================================================

        button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        button_box.set_hexpand(True)
        self.append(button_box)

        self.add_button = Gtk.Button(label="Add")
        self.add_button.set_hexpand(True)
        self.add_button.set_margin_start(6)
        self.add_button.set_margin_end(6)
        self.add_button.set_margin_top(6)
        self.add_button.set_margin_bottom(6)
        self.add_button.connect("clicked", self.add_button_clicked)
        button_box.append(self.add_button)

        self.del_button = Gtk.Button(label="Delete")
        self.del_button.set_hexpand(True)
        self.del_button.set_margin_start(6)
        self.del_button.set_margin_end(6)
        self.del_button.set_margin_top(6)
        self.del_button.set_margin_bottom(6)
        self.del_button.connect("clicked", self.del_button_clicked)
        button_box.append(self.del_button)

    # =========================================================================
    # Helper functions
    # =========================================================================

    def set_fan_index(self, index):
        self.fan_index = index

    def set_available_sensors(self, available_sensors):
        for sensor in available_sensors:
            self.sensors.add(sensor.name, "%s (%s)" % (sensor.name, sensor.description))

        self.sensors.add("<command>", "Custom Shell Command")
        self.sensors.add("<custom>", "Custom Sensor or File")

    def update(self, fan_temperature_source):
        {
            'Default': self.default_radio,
            'Average': self.average_radio,
            'Max':     self.max_radio,
            'Min':     self.min_radio
        }[fan_temperature_source.get('TemperatureAlgorithmType', 'Default')].set_active(True)

        self.temperature_sources.model.remove_all()

        for sensor in fan_temperature_source.get('Sensors', []):
            try:
                item = self.find_sensor_item(sensor)
                self.temperature_sources.add(item.getKey(), item.getValue())
            except Exception:
                self.temperature_sources.add(sensor, sensor)

    def find_sensor_item(self, sensor):
        for i in range(self.sensors.model.get_n_items()):
            item = self.sensors.model.get_item(i)
            if item.getKey() == sensor:
                return item

        raise Exception('No sensor found for %s' % sensor)

    def get_config(self):
        sensors = []
        for i in range(self.temperature_sources.model.get_n_items()):
            item = self.temperature_sources.model.get_item(i)
            sensors.append(item.getKey())

        algorithm = None
        if self.average_radio.get_active():
            algorithm = 'Average'
        elif self.max_radio.get_active():
            algorithm = 'Max'
        elif self.min_radio.get_active():
            algorithm = 'Min'

        cfg = {'FanIndex': self.fan_index}

        if algorithm:
            cfg['TemperatureAlgorithmType'] = algorithm

        if sensors:
            cfg['Sensors'] = sensors

        return cfg

    # =========================================================================
    # Signal functions
    # =========================================================================

    def sensors_changed(self, *_):
        item = self.sensors.get_selected_item()
        if item is None:
            return

        if item.getKey() == '<custom>':
            self.custom_sensor.set_visible(True)
            self.custom_sensor.set_placeholder_text("Sensor Name or File")
        elif item.getKey() == '<command>':
            self.custom_sensor.set_visible(True)
            self.custom_sensor.set_placeholder_text("Shell Command")
        else:
            self.custom_sensor.set_visible(False)

    def add_button_clicked(self, _):
        item = self.sensors.get_selected_item()
        if item is None:
            return

        if item.getKey() == '<custom>':
            text = self.custom_sensor.get_text()
            if not text.strip():
                return

            key = val = text
            self.custom_sensor.set_text("")
        elif item.getKey() == '<command>':
            text = self.custom_sensor.get_text()
            if not text.strip():
                return

            key = val = '$ %s' % self.custom_sensor.get_text()
            self.custom_sensor.set_text("")
        else:
            key = item.getKey()
            val = item.getValue()

        self.temperature_sources.add(key, val)

    def del_button_clicked(self, _):
        idx = self.temperature_sources.get_selected_idx()
        if idx >= 0 and idx < self.temperature_sources.model.get_n_items():
            self.temperature_sources.model.remove(idx)
class SensorsWidget(Gtk.Box):
    def __init__(self):
        super().__init__(orientation=Gtk.Orientation.VERTICAL)

        GLOBALS.connect("model_config_changed", self.model_config_changed_signal)

        # =====================================================================
        # Error Widget
        # =====================================================================

        self.error_widget = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.append(self.error_widget)

        self.error_label = Gtk.Label()
        self.error_label.set_hexpand(True)
        self.error_label.set_vexpand(True)
        self.error_widget.append(self.error_label)

        button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        self.error_widget.append(button_box)

        self.retry_button = Gtk.Button(label="Retry")
        self.retry_button.set_margin_start(6)
        self.retry_button.set_margin_end(6)
        self.retry_button.set_margin_top(6)
        self.retry_button.set_margin_bottom(6)
        self.retry_button.set_hexpand(True)
        self.retry_button.connect("clicked", self.retry_button_clicked)
        button_box.append(self.retry_button)

        self.fix_button = Gtk.Button(label="Fix errors automatically")
        self.fix_button.set_margin_start(6)
        self.fix_button.set_margin_end(6)
        self.fix_button.set_margin_top(6)
        self.fix_button.set_margin_bottom(6)
        self.fix_button.set_hexpand(True)
        self.fix_button.connect("clicked", self.fix_button_clicked)
        button_box.append(self.fix_button)

        # =====================================================================
        # Main Widget
        # =====================================================================

        self.main_widget = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.append(self.main_widget)

        self.notebook = Gtk.Notebook()
        self.main_widget.append(self.notebook)

        self.apply_buttons_widget = ApplyButtonsWidget()
        self.apply_buttons_widget.save_button.connect("clicked", self.save_button_clicked)
        self.apply_buttons_widget.apply_button.connect("clicked", self.apply_button_clicked)
        self.main_widget.append(self.apply_buttons_widget)

        self.setup_ui()

    # =========================================================================
    # Widget start / stop
    # =========================================================================

    def start(self):
        pass

    def stop(self):
        pass

    # =========================================================================
    # Helper functions
    # =========================================================================

    def save(self):
        config = GLOBALS.nbfc_client.get_service_config()
        config['FanTemperatureSources'] = self.get_fan_temperature_sources()
        if not len(config['FanTemperatureSources']):
            del config['FanTemperatureSources']
        GLOBALS.nbfc_client.set_service_config(config)

    def setup_ui(self, fix_errors=False):
        if not GLOBALS.is_root:
            self.apply_buttons_widget.disable(CANNOT_CONFIGURE_MSG)
        else:
            self.apply_buttons_widget.enable()

        # =====================================================================
        # Get model configuration
        # =====================================================================

        try:
            config = GLOBALS.nbfc_client.get_service_config()
            fan_temperature_sources = config.get('FanTemperatureSources', [])
            model_config = GLOBALS.nbfc_client.get_model_configuration()
            self.error_widget.set_visible(False)
            self.main_widget.set_visible(True)
        except Exception as e:
            self.error_widget.set_visible(True)
            self.main_widget.set_visible(False)
            self.error_label.set_text(str(e))
            self.fix_button.set_sensitive(False)
            self.retry_button.set_sensitive(True)
            self.apply_buttons_widget.disable("")
            return

        # =====================================================================
        # Get available temperature sensors
        # =====================================================================

        available_sensors = GLOBALS.nbfc_client.get_available_sensors()

        # =====================================================================
        # Ensure that the FanTemperatureSources in the config are valid.
        # Give the user the chance to fix it or fix it automatically.
        # =====================================================================

        errors = validate_fan_temperature_sources(
            fan_temperature_sources,
            len(model_config['FanConfigurations']))

        if errors and not fix_errors:
            self.error_widget.set_visible(True)
            self.main_widget.set_visible(False)
            self.error_label.set_text('\n\n'.join(errors))
            self.fix_button.set_sensitive(True)
            self.retry_button.set_sensitive(True)
            self.apply_buttons_widget.disable("")
            return
        elif errors and fix_errors:
            fan_temperature_sources = fix_fan_temperature_sources(
                fan_temperature_sources,
                len(model_config['FanConfigurations']))

        self.error_widget.set_visible(False)
        self.main_widget.set_visible(True)

        # =====================================================================
        # Add widgets to self.tab_widget
        # =====================================================================

        while self.notebook.get_n_pages() < len(model_config['FanConfigurations']):
            widget = SensorWidget()
            self.notebook.append_page(widget, Gtk.Label())

        while self.notebook.get_n_pages() > len(model_config['FanConfigurations']):
            last = self.notebook.get_n_pages() - 1
            self.notebook.remove_page(last)

        # =====================================================================
        # Set fan names to tabs
        # =====================================================================

        for i, fan_config in enumerate(model_config['FanConfigurations']):
            widget = self.notebook.get_nth_page(i)
            self.notebook.set_tab_label(widget, Gtk.Label(label=fan_config.get('FanDisplayName', 'Fan #%d' % i)))
            widget.set_available_sensors(available_sensors)
            widget.set_fan_index(i)

        # =====================================================================
        # Update TemperatureSourceWidget 
        # =====================================================================

        for fan_temperature_source in fan_temperature_sources:
            fan_index = fan_temperature_source['FanIndex']
            widget = self.notebook.get_nth_page(fan_index)
            widget.update(fan_temperature_source)

    def get_fan_temperature_sources(self):
        fan_temperature_sources = []

        for i in range(self.notebook.get_n_pages()):
            widget = self.notebook.get_nth_page(i)
            config = widget.get_config()

            # If FanTemperatureSource only has 'FanIndex', don't add it
            if len(config) > 1:
                fan_temperature_sources.append(config)

        return fan_temperature_sources

    # =========================================================================
    # Signal functions
    # =========================================================================

    def save_button_clicked(self, _):
        try:
            self.save()
        except Exception as e:
            show_error_message(self, "Error", str(e))

    def apply_button_clicked(self, _):
        try:
            self.save()
            read_only = self.apply_buttons_widget.read_only_checkbox.get_active()
            GLib.idle_add(GLOBALS.emit, "restart_service", read_only)
        except Exception as e:
            show_error_message(self, "Error", str(e))

    def fix_button_clicked(self, _):
        self.setup_ui(fix_errors=True)

    def retry_button_clicked(self, _):
        self.setup_ui(fix_errors=False)

    def model_config_changed_signal(self, *_):
        self.setup_ui(fix_errors=False)
class UpdateWidget(Gtk.Box):
    def __init__(self):
        super().__init__(orientation=Gtk.Orientation.VERTICAL)

        # =====================================================================
        # Description
        # =====================================================================

        label = Gtk.Label(label="Fetch new configuration files from the internet")
        label.set_margin_start(6)
        label.set_margin_end(6)
        label.set_margin_top(6)
        label.set_margin_bottom(6)
        self.append(label)

        # =====================================================================
        # Log
        # =====================================================================

        scrolled = Gtk.ScrolledWindow()
        scrolled.set_vexpand(True)
        scrolled.set_hexpand(True)
        self.append(scrolled)

        self.log = Gtk.TextView()
        self.log.set_editable(False)
        scrolled.set_child(self.log)

        # =====================================================================
        # Error message
        # =====================================================================

        self.error_label = Gtk.Label()
        self.error_label.set_margin_start(6)
        self.error_label.set_margin_end(6)
        self.error_label.set_margin_top(6)
        self.error_label.set_margin_bottom(6)
        self.append(self.error_label)

        # =====================================================================
        # Update button
        # =====================================================================

        self.update_button = Gtk.Button(label="Update")
        self.update_button.set_margin_start(6)
        self.update_button.set_margin_end(6)
        self.update_button.set_margin_top(6)
        self.update_button.set_margin_bottom(6)
        self.update_button.connect("clicked", self.update_button_clicked)
        self.append(self.update_button)

        # =====================================================================
        # Init code
        # =====================================================================

        if GLOBALS.is_root:
            self.error_label.set_visible(False)
        else:
            self.error_label.set_visible(True)
            self.error_label.set_text("You cannot update the configurations because you are not root")
            self.update_button.set_sensitive(False)

    # =========================================================================
    # Widget start / stop
    # =========================================================================

    def start(self):
        pass

    def stop(self):
        pass

    # =========================================================================
    # Signal functions
    # =========================================================================

    def handle_output(self, worker, line):
        buffer = self.log.get_buffer()
        end_iter = buffer.get_end_iter()
        buffer.insert(end_iter, '%s\n' % line.rstrip())

    def update_button_clicked(self, _):
        self.log.get_buffer().set_text("")
        self.update_button.set_sensitive(False)

        # We need to attach the worker thread to the class instance
        # because it would get destroyed otherwise
        self.worker = SubprocessWorker(['nbfc', 'update'])
        self.worker.connect("output_line", self.handle_output)
        self.worker.connect("error_line", self.handle_output)
        self.worker.connect("finished", self.command_finished)
        self.worker.start()

    def command_finished(self, worker, exitstatus):
        self.update_button.set_sensitive(True)
class MainWindow(Gtk.ApplicationWindow):
    def __init__(self, app):
        super().__init__(application=app)

        # =====================================================================
        # Title and Geometry
        # =====================================================================

        self.set_title("NBFC Client")
        self.set_default_size(400, 600)

        # =====================================================================
        # Box (vertical)
        # =====================================================================

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.set_child(vbox)

        # =====================================================================
        # Menu
        # =====================================================================

        # Menu model
        menu_model = Gio.Menu()
        
        # Application menu
        app_menu = Gio.Menu()

        # About
        about_entry = app_menu.append("About", "win.about")
        about_action = Gio.SimpleAction.new("about", None)
        about_action.connect("activate", lambda *_: self.showAbout())
        self.add_action(about_action)

        # Quit
        quit_entry = app_menu.append("Quit", "win.quit")
        quit_action = Gio.SimpleAction.new("quit", None)
        quit_action.connect("activate", lambda *_: self.close())
        self.add_action(quit_action)

        menu_model.append_submenu("Application", app_menu)

        # Menu bar
        menubar = Gtk.PopoverMenuBar.new_from_model(menu_model)
        vbox.append(menubar)

        # =====================================================================
        # Tab widget
        # =====================================================================

        self.notebook = Gtk.Notebook()
        self.notebook.set_margin_start(12)
        self.notebook.set_margin_end(12)
        self.notebook.set_margin_top(12)
        self.notebook.set_margin_bottom(12)
        self.notebook.connect('switch-page', self.notebook_tab_changed)
        vbox.append(self.notebook)

        # =====================================================================
        # Tabs
        # =====================================================================

        self.widgets = {}
        self.widgets['service'] = ServiceControlWidget()
        self.widgets['fans']    = FanControlWidget()
        self.widgets['basic']   = BasicConfigWidget()
        self.widgets['sensors'] = SensorsWidget()
        self.widgets['update']  = UpdateWidget()

        self.notebook.append_page(self.widgets['service'], Gtk.Label(label='Service'))
        self.notebook.append_page(self.widgets['fans'],    Gtk.Label(label='Fans'))
        self.notebook.append_page(self.widgets['basic'],   Gtk.Label(label='Basic Configuration'))
        self.notebook.append_page(self.widgets['sensors'], Gtk.Label(label='Sensors'))
        self.notebook.append_page(self.widgets['update'],  Gtk.Label(label='Update'))

    # =========================================================================
    # Public functions
    # =========================================================================

    def setTabById(self, id_):
        widget = self.widgets[id_]
        page_num = self.notebook.page_num(widget)
        self.notebook.set_current_page(page_num)

    # =========================================================================
    # Signal functions
    # =========================================================================

    def notebook_tab_changed(self, notebook, page, page_num):
        for i in range(self.notebook.get_n_pages()):
            widget = self.notebook.get_nth_page(i)
            
            if i == page_num:
                widget.start()
            else:
                widget.stop()

    def showAbout(self, *_):
        dialog = AboutWidget(self)
        dialog.present()
class EarlyErrorWindow(Gtk.ApplicationWindow):
    def __init__(self, app, title, message):
        super().__init__(application=app)

        # =====================================================================
        # Title
        # =====================================================================

        self.set_title(title)

        # =====================================================================
        # Box (Vertical)
        # =====================================================================

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        vbox.set_margin_start(12)
        vbox.set_margin_end(12)
        vbox.set_margin_top(12)
        vbox.set_margin_bottom(12)
        self.set_child(vbox)

        # =====================================================================
        # Label
        # =====================================================================

        self.desc = Gtk.Label()
        self.desc.set_margin_start(12)
        self.desc.set_margin_end(12)
        self.desc.set_margin_top(12)
        self.desc.set_margin_bottom(12)
        self.desc.set_text(message)
        vbox.append(self.desc)

        # =====================================================================
        # OK Button
        # =====================================================================

        ok_button = Gtk.Button(label="OK")
        ok_button.connect("clicked", lambda *_: self.close())
        vbox.append(ok_button)

    def set_markup(self, markup):
        self.desc.set_markup(markup)

argp = argparse.ArgumentParser(
    prog='nbfc-gtk',
    description='Gtk-based GUI for NBFC-Linux')

argp.add_argument('--version', action='version', version=f'%(prog)s {VERSION}')

grp = argp.add_argument_group(title='Widgets')

grp.add_argument('--service',
    help='Start with service widget',
    dest='widget', action='store_const', const='service')

grp.add_argument('--fans',
    help='Start with fans widget',
    dest='widget', action='store_const', const='fans')

grp.add_argument('--basic',
    help='Start with basic configuration widget',
    dest='widget', action='store_const', const='basic')

grp.add_argument('--sensors',
    help='Start with sensors widget',
    dest='widget', action='store_const', const='sensors')

grp.add_argument('--update',
    help='Start with update widget',
    dest='widget', action='store_const', const='update')

opts = argp.parse_args()

class App(Gtk.Application):
    def __init__(self):
        super().__init__()

    def do_activate(self):

        # =====================================================================
        # Initialize globals
        # =====================================================================

        try:
            GLOBALS.init()
        except Exception as e:
            win = EarlyErrorWindow(self, "Error", str(e))
            win.present()
            return

        # =====================================================================
        # Get current version of NBFC-Linux
        # =====================================================================

        try:
            current_version = GLOBALS.nbfc_client.get_version()
        except Exception as e:
            win = EarlyErrorWindow(self, "Error", f"Could not get version of NBFC client: {e}")
            win.present()
            return

        # =====================================================================
        # Check for required version
        # =====================================================================

        if make_version_tuple(current_version) < make_version_tuple(REQUIRED_NBFC_VERSION):
            errmsg = f'''\
NBFC-Linux version <b>{REQUIRED_NBFC_VERSION}</b> or newer is required to run this program.

You can get the latest version from <a href="{GITHUB_URL}">GitHub.com</a>'''
            win = EarlyErrorWindow(self, "Version Error", "")
            win.set_markup(errmsg)
            win.present()
            return

        # =====================================================================
        # Finally run the app
        # =====================================================================

        win = MainWindow(self)

        if opts.widget is not None:
            win.setTabById(opts.widget)

        win.present()

app = App()
app.run()
