software-development
Today

Мой маленький мониторинг

Делюсь очередной полезной штукой из личных разработок, на этот раз на тему мониторинга температуры в компьютере.

~120 строк на Python.

Правый верхний угол, старая версия - с прозрачным фоном.

Задача

Честно говоря, задача мониторинга температуры это такой своеобразный «неуловимый Джо» — ненужная мелкая ерунда, реализуемая в больших системах для галочки и по остаточному принципу.

Ну и конечно отображение температуры с датчиков персонального компьютера давным давно реализовано и присутствует в виде виджета во всех основных графических окружениях и для любых ОС.

Чаще всего это выглядит как-то так:

Найдите тут температуру.

Это все к вопросу об уникальности, важности и полезности решения, как бы заранее отвечая на самый важный вопрос бытия: «нах#я я все это читаю».

Кстати одна из первых разработанных автором систем была как раз на тему мониторинга температуры у парка серверов.

Дело было давно и сервера (как и сетевое оборудование) стояли не в охлаждаемых и чистых серверных, а где придется.

С соответствующими последствиями по перегреву.

Выглядело оно как-то так:

Данные тестовые, разумеется а сам мониторинг запущен в режиме отладки, локально.

Так выглядит виновник сегодняшнего торжества:

Исходный код можно посмотреть чуть ниже в статье или вот тут в виде gist. Теперь рассказываю подробнее как оно все работает.

Кстати думаю для многих станет сюрпризом, что X-сервер позволяет рисовать графические элементы и текст в чужих окнах.

Без предупреждения, без индикации, без каких-то отдельных прав и тд.

Очко сотрудников СБ только что заиграло новыми красками.

Реализация

Поскольку у автора используется тот еще зоопарк разных систем, хотелось сделать максимально переносимое решение.

Хардкодить все на чистом С я посчитал излишним, поэтому был взят Python и библиотека Xlib, которая есть везде, куда еще не добрались проклятые зумеры со своим Wayland.

К моему великому сожалению, оказалось что знаменитая связка Tcl/Tk не умеет работать с root window, поэтому использовать их не получится.

Именно поэтому был взят петон, да.

Собственно эта библиотека является единственной внешней зависимостью в этом проекте, для FreeBSD устанавливается вот так:

pkg install py311-python-xlib

Аналогичные пакеты есть в любом линуксе и BSD. Теперь стоит рассказать про сами датчики.

Датчики температуры в случае FreeBSD отдают свои значения через sysctl и требуют подгрузки специального модуля ядра.

Чаще всего это будет coretemp:

kldload coretemp

Чтение значений датчиков будет выглядеть как-то так:

Для процессоров AMD есть отдельный модуль amdtemp:

kldload amdtemp

Названия датчиков отличаются, поэтому для чтения используется немного другой паттерн:

sysctl dev.amdtemp.0 |grep core

В случае линукса температуру с датчиков можно получить из специальной виртуальной файловой системы /dev:

cat /sys/class/thermal/thermal_zone*/temp

Так это выглядит в работе:

В работе на процессоре AMD

Исходный код

Ниже представлен полный исходный код моей утилиты, технически это shebang — самозапускаемый скрипт на Python, поэтому его нужно сохранить с расширением .sh и поставить бит запуска.

Собственно код:

#!/usr/bin/env python3

import Xlib
from Xlib import display, X   # display и X - не импортируются автоматически
import subprocess,time,logging

#настройки логирования
logging.basicConfig(level=logging.INFO)
#logging.basicConfig(level=logging.DEBUG)

# координаты на экране для отображения
POS_X = 150
POS_Y = 50
# для FreeBSD и машины с AMD
PATTERN = 'sysctl dev.amdtemp.0 |grep core'
# для FreeBSD с модулем coretemp
#PATTERN = 'sysctl dev.cpu |grep temperature'
# для Linux
#PATTERN = "cat /sys/class/thermal/thermal_zone*/temp | awk '{ print \"temp: \" ($1 / 1000) \"C\" }'"
# шрифт
FONT = '-misc-fixed-medium-r-normal--13-120-75-75-c-70-iso8859-1'
# период обновления
REFRESH_SECS = 15

last_dim = [0,0]

# определение рабочего стола
# d - display (экран)s
def get_root_window(d):
    
    screen = d.screen()
    # root window - рабочий стол по-умолчанию
    root = screen.root

    # Получаем ID всех окон верхнего уровня
    windowIDs = root.get_full_property(d.intern_atom('_NET_CLIENT_LIST'), 
                                                  X.AnyPropertyType).value
    logging.debug('Found %d windows.',len(windowIDs))

    for windowID in windowIDs:
        # Create a window object from the ID to access its properties
        window = d.create_resource_object('window', windowID)

        try:
            # Get the window title (WM_NAME or _NET_WM_NAME)
            # Use get_wm_name() for simplicity, 
            # or look up EWMH properties for better compatibility
            window_name = window.get_wm_name()
            if window_name:
                # в продвинутых DE вроде KDE/Xfce за рабочий стол отвечает
                # отдельное окно и рисовать придется в нем
                if 'Desktop' in window_name:
                    logging.debug("Found desktop ID: %d - Name: %s",
                                                 windowID,window_name)
                    return window
            else:
                # Бывает, что у окна нет заголовка
                logging.debug("ID: %d - Name: None (no WM_NAME property)",
                                                 windowID)

        except X.BadWindow:
            # Обработка ситуации, когда считываемое окно больше не существует
            logging.debug("ID: %d - Window no longer exists",windowID)
    # если отдельного окна с именем Desktop не обнаружено - рисуем
    # прямо на root window 
    return root

# очистка области
def clear_rect(msg):
    global last_dim
    # если есть сохраненные размеры - используем их
    if last_dim[0] > 0:	
    	root.fill_rectangle(gc2, POS_X,POS_Y-last_dim[1], 
    	                         last_dim[0]-20, last_dim[1]+5)
    else:
        # расчет размеров надписи в пикселях
        text_extents = font.query_text_extents(msg)
        tw = text_extents.overall_width
        th = text_extents.font_ascent + text_extents.font_descent
        # очистка происходит через отрисовку черного прямоугольника
        # clear_area плохо работает с KDE
        root.fill_rectangle(gc2, POS_X,POS_Y-th, tw-20, th+5)
        # запоминаем размеры надписи для следующей очистки
        last_dim = [tw,th]

# отрисовка сообщения на экране
# msg - текст сообщения
def draw_message(msg):
    # очистка области
    clear_rect(msg)
    # отрисовка текста
    root.draw_text(gc, POS_X, POS_Y, msg)
    display.flush()

# инициализация подключения к Х-серверу
display = Xlib.display.Display()
screen = display.screen()
root = get_root_window(display)

# Access the window ID (an integer)
root_id = root.id

logging.debug("Root window ID: %d",root_id)
# для реакции на события
root.change_attributes(event_mask=X.ExposureMask)  # "adds" this event mask
# создание графического контекста, белый текст на черном фоне
gc = root.create_gc(foreground = screen.white_pixel, 
                    background = screen.black_pixel)
# дополнительный контекст для заливки области черным
colormap = screen.default_colormap
color = colormap.alloc_named_color('black') 
gc2 = root.create_gc(foreground=color.pixel)

# загружаем шрифт, которым будет отрисовываться текст
# если шрифт с таким названием не будет найден - вылетит ошибка
try:
    font = display.open_font(FONT)
    gc.font = font.id
except Exception as e:
    logging.exception(e)
    exit(1)

try:
    # бесконечный цикл, в котором происходит все действо
    while 1:
            # запуск процесса для получения значений датчиков
            process = subprocess.Popen(PATTERN, 
                    shell=True, text=True,
                    stdout=subprocess.PIPE)
            # в этой переменной будет массив строк со значениями   
            stdout_list = process.communicate()[0].split('\n')

            out = ''
            # делаем чистку
            for s in stdout_list:
                # убираем ошибочные строки, если нет : - нет и значения
                if ':' not in s: continue
                kv = s.split(':')
                # добавляем запятую в качестве разделителя 
                if len(out) > 0: out+= ','
                # добавляем значение датчика в строку
                out+= kv[1]

            logging.debug(out)
            # отрисовываем полученную строку
            draw_message(out.encode())
            # задержка между итерациями
            time.sleep(REFRESH_SECS)
            
except KeyboardInterrupt:
        # при нажатии Ctrl-C делаем очистку области экрана, где
        # происходила отрисовка виджета
        x = POS_X // 2
        y = POS_Y // 2
        root.clear_area(x,y,last_dim[0]+x,last_dim[1]+y,True)
        display.flush()

Как видите тут нет ООП и нет многопоточности — реализация максимально упрощена. Также не стал городить считывание параметров, поэтому ключевые настройки находятся в самом скрипте.

Настройка логирования:

logging.basicConfig(level=logging.INFO)
#logging.basicConfig(level=logging.DEBUG)

Координаты для отображения на экране (левый нижний угол):

POS_X = 150
POS_Y = 50

Выбор паттерна для считывания значений датчиков:

# для FreeBSD и машины с AMD
PATTERN = 'sysctl dev.amdtemp.0 |grep core'
# для FreeBSD с модулем coretemp
PATTERN = 'sysctl dev.cpu |grep temperature'
# для Linux
PATTERN = "cat /sys/class/thermal/thermal_zone*/temp | awk '{ print \"temp: \" ($1 / 1000) \"C\" }'"

Шрифт, которым будут отображаться значения температуры, указывается в специальном формате:

FONT = '-misc-fixed-medium-r-normal--13-120-75-75-c-70-iso8859-1'

Частота обновлений в секундах:

REFRESH_SECS = 15

Так виджет выглядит на Ubuntu и Xfce: