# -*- coding: UTF-8 -*- # This file is part of the jetson_stats package (https://github.com/rbonghi/jetson_stats or http://rnext.it). # Copyright (c) 2019-2023 Raffaello Bonghi. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import re import os # Logging import logging # Launch command from .command import Command from .common import cat, GenericInterface from .exceptions import JtopException # Create logger logger = logging.getLogger(__name__) COMMAND_TIMEOUT = 4.0 FAN_MANUAL_NAME = 'manual' FAN_TEMP_CONTROL_NAME = 'temp_control' FAN_PWM_RE = re.compile(r'^pwm\d+$') FAN_NVFAN_NAME_RE = re.compile(r'^\d+)>$') FAN_NVFAN_OPTIONS_RE = re.compile(r'FAN_(?P\w+) (?P\w+) {$') FAN_NVFAN_DEFAULT_RE = re.compile(r'FAN_DEFAULT_(?P\w+) (?P\w+)') # Fan configurations FAN_PWM_CAP = 255 def ValueToPWM(value, pwm_cap=FAN_PWM_CAP): return int(value * pwm_cap / 100) def PWMtoValue(value, pwm_cap=FAN_PWM_CAP): return float(value * 100 / pwm_cap) def get_all_rpm_system(root_dir): pwm_files = {} for dir in os.listdir(root_dir): full_path = os.path.join(root_dir, dir) if os.path.isdir(full_path): # Find all pwm in folder for file in os.listdir(full_path): if 'rpm' == file: name_file = os.path.join(full_path, 'name') name = cat(name_file).strip() if os.path.isfile(name_file) else dir pwm_files[name] = os.path.join(full_path, file) logger.info("RPM {name} found in {root_path}".format(name=name, root_path=full_path)) return pwm_files def get_all_cooling_system(root_dir): pwm_files = {} if not os.path.isdir(root_dir): logger.error("Folder {root_dir} doesn't exist".format(root_dir=root_dir)) return pwm_files # Fin all fans for dir in os.listdir(root_dir): full_path = os.path.join(root_dir, dir) if os.path.isdir(full_path): fan_device_paths = [] fan_rpm_path = [] # Find all pwm in folder for file in os.listdir(full_path): if FAN_PWM_RE.match(file) or file == 'target_pwm': fan_device_paths += [os.path.join(full_path, file)] # Check if there are rpm values if file == 'rpm_measured': fan_rpm_path += [os.path.join(full_path, file)] # If there are pwm is added in list if fan_device_paths: name_file = os.path.join(full_path, 'name') name = cat(name_file).strip() if os.path.isfile(name_file) else dir pwm_files[name] = {'path': full_path, 'pwm': fan_device_paths} logger.info("Fan {name}({num}) found in {root_path}".format(name=name, root_path=full_path, num=len(fan_device_paths))) if fan_rpm_path: pwm_files[name]['rpm'] = fan_rpm_path logger.info("RPM {name}({num}) found in {root_path}".format(name=name, root_path=full_path, num=len(fan_device_paths))) # Find all rpm systems rpm_list = get_all_rpm_system(root_dir) for fan, rpm in zip(pwm_files, rpm_list): pwm_files[fan]['rpm'] = [rpm_list[rpm]] return pwm_files def get_all_legacy_fan(): pwm_files = {} root_path = "" for path in ['/sys/kernel/debug/tegra_fan', '/sys/devices/pwm-fan']: if os.path.exists(path): root_path = path break if not root_path: return pwm_files # Check if this fan is already listed if os.path.isdir(os.path.join(root_path, 'hwmon')): return pwm_files # Otherwise add in list name = os.path.basename(root_path) # Find all commands fan_device_paths = [] fan_rpm_path = [] for file in os.listdir(root_path): if file == 'target_pwm': fan_device_paths += [os.path.join(root_path, file)] # Check if there are rpm values elif file == 'rpm_measured': fan_rpm_path += [os.path.join(root_path, file)] # If there are pwm is added in list if fan_device_paths: pwm_files[name] = {'path': root_path, 'pwm': fan_device_paths} logger.info("Found legacy FAN {name}({num}) found in {root_path}".format(name=name, root_path=root_path, num=len(fan_device_paths))) if fan_rpm_path: pwm_files[name]['rpm'] = fan_rpm_path logger.info("Legacy RPM {name}({num}) found in {root_path}".format(name=name, root_path=root_path, num=len(fan_device_paths))) return pwm_files def nvfancontrol_query(): status = {} try: nvpmodel_p = Command(['nvfancontrol', '-q']) lines = nvpmodel_p(timeout=COMMAND_TIMEOUT) for line in lines: values = line.split(':') fan_name = values[0].lower() query = values[1].replace("FAN_", "").lower() if fan_name not in status: status[fan_name] = {} status[fan_name][query] = values[2] except (OSError, Command.CommandException): pass return status def decode_nvfancontrol(): nvfan = {} current_fan = '' if os.path.isfile("/etc/nvfancontrol.conf"): with open("/etc/nvfancontrol.conf", 'r') as fp: for line in fp: match_name = re.search(FAN_NVFAN_NAME_RE, line.strip()) match_values = re.search(FAN_NVFAN_OPTIONS_RE, line.strip()) if match_name: parsed_line = match_name.groupdict() current_fan = 'fan{num}'.format(num=parsed_line['num']) nvfan[current_fan] = {} elif match_values: parsed_line = match_values.groupdict() type_name = parsed_line['type'].lower() if type_name not in nvfan[current_fan]: nvfan[current_fan][type_name] = [] nvfan[current_fan][type_name] += [parsed_line['value']] return nvfan def change_nvfancontrol_default(name, value): with open("/etc/nvfancontrol.conf", "r") as f: lines = f.readlines() with open("/etc/nvfancontrol.conf", "w") as f: for line in lines: match_defaults = re.search(FAN_NVFAN_DEFAULT_RE, line.strip()) if match_defaults: parsed_line = match_defaults.groupdict() if name.upper() == parsed_line['type']: # Override line with new value line = line.replace(parsed_line['value'], value) # Print line f.write(line) def nvfancontrol_is_active(): try: output = Command.run_command(['systemctl', 'status', 'nvfancontrol.service']) return 'Active: active' in '\n'.join(output) except Command.CommandException: return False class Fan(GenericInterface): """ This class enable to control your fan or set of fan. Please read the documentation on :py:attr:`~jtop.jtop.fan` .. code-block:: python with jtop() as jetson: if jetson.ok(): jetson.fan.set_profile("tegra_fan", "manual") Below all methods available using the :py:attr:`~jtop.jtop.fan` attribute """ def __init__(self): super(Fan, self).__init__() # list of all profiles in self._init (check services) def all_profiles(self, name): """ Return a list of all profiles available for a fan. * **Before** Jetpack 5 * temp_control * manual * **After** Jetpack 5 jtop map `nvfancontrol `_ * Quiet * Cool * Manual (Stop nvfancontrol service) All fan are always with `Manual` profile option. :param name: Name of Fan :type name: str :raises JtopException: Fan name doesn't exist :return: List of all profiles available :rtype: list """ # noqa if name not in self._data: raise JtopException("Fan \"{name}\" does not exist".format(name=name)) return self._init[name] def set_profile(self, name, profile): """ Set a new profile for a fan. Check which profile is available with :py:func:`~all_profiles`. :param name: Name of Fan :type name: str :param profile: Profile name :type profile: str :raises JtopException: Fan name doesn't exist or wrong profile """ if name not in self._data: raise JtopException("Fan \"{name}\" does not exist".format(name=name)) if profile not in self.all_profiles(name): all_profiles = ' '.join(self.all_profiles(name)) raise JtopException("Profile \"{profile}\" does not exist for Fan \"{name}\". Available: {all_profiles}".format( profile=profile, name=name, all_profiles=all_profiles)) # Skip if the new profile is the same of the previous if profile == self._data[name]['profile']: return # Set new fan profile self._controller.put({'fan': {'command': 'profile', 'name': name, 'profile': profile}}) def get_profile(self, name): """ Return the current profile is enabled on fan. This value is also readable from :py:attr:`~jtop.jtop.fan`. :param name: Name of Fan :type name: str :raises JtopException: Fan name doesn't exist :return: fan profile name :rtype: str """ if name not in self._data: raise JtopException("Fan \"{name}\" does not exist".format(name=name)) return self._data[name]['profile'] def get_profile_default(self, name): """ Get the default profile for this fan. Usually is the first profile listed in :py:func:`~all_profiles` :param name: Name of Fan :type name: str :raises JtopException: Fan name doesn't exist :return: profile default name :rtype: str """ if name not in self._data: raise JtopException("Fan \"{name}\" does not exist".format(name=name)) return self._init[name][0] @property def profile(self): """ This property show the current profile selected on first fan on your board. This is a simplified version of :py:func:`~set_profile` where *name* is the first fan listed. .. code-block:: python with jtop() as jetson: if jetson.ok(): # Print profile print(jetson.fan.profile) # Set new profile jetson.fan.profile = 'quiet' :return: current profile in fan :rtype: str """ # Return first fan name and get speed if len(self._data) > 0: # Extract first name name = list(self._data.keys())[0] # Get profile return self.get_profile(name) return None @profile.setter def profile(self, value): if len(self._data) > 0: # Extract first name name = list(self._data.keys())[0] # Set speed for first fan self.set_profile(name, value) def set_speed(self, name, speed, idx=0): """ Set a new speed for a selected fan. :param name: Name of Fan :type name: str :param speed: New speed value, a number between [0, 100] :type speed: float :param idx: Index fan, defaults to 0 :type idx: int, optional :raises JtopException: Fan name doesn't exist or wrong index """ if name not in self._data: raise JtopException("Fan \"{name}\" does not exist".format(name=name)) if idx >= len(self._data[name]['speed']) or idx < 0: raise JtopException("Fan \"{name}\" have only {len} fans".format(name=name, len=len(self._data[name]['speed']))) # Skip if the new profile is the same of the previous if speed == self._data[name]['speed'][idx]: return # Set new fan speed self._controller.put({'fan': {'command': 'speed', 'name': name, 'speed': speed, 'idx': idx}}) def get_speed(self, name, idx=0): """ Return for a selected Fan and index the current speed. This value is also readable from :py:attr:`~jtop.jtop.fan`. :param name: Name of Fan :type name: str :param idx: Index fan, defaults to 0 :type idx: int, optional :raises JtopException: Fan name doesn't exist or wrong index :return: fan speed a number between [0, 100] :rtype: int """ if name not in self._data: raise JtopException("Fan \"{name}\" does not exist".format(name=name)) if idx >= len(self._data[name]['speed']) or idx < 0: raise JtopException("Fan \"{name}\" have only {len} fans".format(name=name, len=len(self._data[name]['speed']))) return self._data[name]['speed'][idx] @property def speed(self): """ This property show the current speed between [0, 100] on first fan on your board. This is a simplified version of :py:func:`~set_speed` where *name* is the first fan listed. .. code-block:: python with jtop() as jetson: if jetson.ok(): # Read speed print(jetson.fan.speed) # Set new speed jetson.fan.speed = 90 :return: current fan speed :rtype: float """ # Return first fan name and get speed if len(self._data) > 0: # Extract first name name = list(self._data.keys())[0] return_value = self.get_speed(name) # Return first speed return return_value return None @speed.setter def speed(self, value): if len(self._data) > 0: # Extract first name name = list(self._data.keys())[0] # Set speed for first fan self.set_speed(name, value) def get_rpm(self, name, idx=0): """ This method return RPM fan. This output is always available on all Jetson. This value is also readable from :py:attr:`~jtop.jtop.fan`. :param name: Name of Fan :type name: str :param idx: Index fan, defaults to 0 :type idx: int, optional :raises JtopException: Fan name doesn't exist, rpm doesn't exist or wrong index :return: RPM value :rtype: int """ if name not in self._data: raise JtopException("Fan \"{name}\" does not exist".format(name=name)) if 'rpm' not in self._data[name]: raise JtopException("Fan \"{name}\" doesn't have RPM".format(name=name)) if idx >= len(self._data[name]['rpm']) or idx < 0: raise JtopException("Fan \"{name}\" have only {len} fans".format(name=name, len=len(self._data[name]['rpm']))) return self._data[name]['rpm'][idx] @property def rpm(self): """ This property show the current fan selected on first fan on your board. .. note:: You can only read RPM, but **not** set a new speed You cannot set a new RPM value .. code-block:: python with jtop() as jetson: if jetson.ok(): # Read RPM print(jetson.fan.rpm) :return: rpm first fan :rtype: int """ # Return first fan name and get speed if len(self._data) > 0: # Extract first name name = list(self._data.keys())[0] # Return first speed return self.get_rpm(name) return None def check_config(config): if 'fan' not in config: return fans = config.get('fan') for fan in fans: if not isinstance(fans[fan], dict): logger.warning("Old fan configuration store. Cleaned") config.set('fan', {}) else: if 'speed' in fans[fan]: if not isinstance(fans[fan]['speed'], list): logger.warning("Old speed configuration store. Cleaned") config.set('fan', {}) class FanService(object): def __init__(self, config): # Load configuration self._config = config # Check consistency fan check_config(config) # Load base hwmon folder root_dir = "/sys/class/hwmon" if os.getenv('JTOP_TESTING', False): root_dir = "/fake_sys/class/hwmon" logger.warning("Running in JTOP_TESTING folder={root_dir}".format(root_dir=root_dir)) # Find all fan available self._fan_list = get_all_cooling_system(root_dir) self._fan_list.update(get_all_legacy_fan()) # Check if there is nvfan control self._nvfancontrol = os.path.isfile('/etc/systemd/system/nvfancontrol.service') or os.path.islink('/etc/systemd/system/nvfancontrol.service') # Initialize controller if self._nvfancontrol: logger.info("Found nvfancontrol.service") nv_fan_modes = decode_nvfancontrol() # Add all nvfan profiles for fan, nvfan in zip(self._fan_list, nv_fan_modes): self._fan_list[fan].update(nv_fan_modes[nvfan]) # Add extra profile for disabled service if 'profile' in self._fan_list[fan]: self._fan_list[fan]['profile'] += [FAN_MANUAL_NAME] else: for name, fan in self._fan_list.items(): # Initialize profile list self._fan_list[name]['profile'] = [] # Find temp controller control = os.path.join(fan['path'], FAN_TEMP_CONTROL_NAME) if os.path.isfile(control): # Add control path self._fan_list[name]['control'] = control # Add profiles self._fan_list[name]['profile'] += [FAN_TEMP_CONTROL_NAME] logger.info("Fan temp controller {name} found in {root_path}".format(name=name, root_path=control)) # Add default profile self._fan_list[name]['profile'] += [FAN_MANUAL_NAME] if not self._fan_list: logger.warning("No fan found") def initialization(self): # Load configuration fan_config = self._config.get('fan', {}) for name, fan in fan_config.items(): if 'profile' in fan: profile = fan['profile'] logger.info("Initialization {name}".format(name=name)) self.set_profile(name, profile) if profile == FAN_MANUAL_NAME and 'speed' in fan: speed, index = fan['speed'] self.set_speed(name, speed, index) logger.info("Initialization {name} {index} speed {speed}%".format(name=name, index=index, speed=speed)) def get_configs(self): governors = {} for fan, data in self._fan_list.items(): governors[fan] = data['profile'] return governors def get_profile(self, name): if name not in self._fan_list: logger.error("Fan \"{name}\" does not exist".format(name=name)) return "" profile = FAN_MANUAL_NAME if self._nvfancontrol: if nvfancontrol_is_active(): nvfan_query = nvfancontrol_query() for fan_list_name, nvfan in zip(self._fan_list, nvfan_query): if fan_list_name == name: return nvfan_query[nvfan]['profile'] else: if 'control' in self._fan_list[name]: control_value = int(cat(self._fan_list[name]['control'])) == 1 return FAN_TEMP_CONTROL_NAME if control_value else FAN_MANUAL_NAME return profile def set_profile(self, name, profile): if name not in self._fan_list: logger.error("Fan \"{name}\" does not exist".format(name=name)) return False # Check current status before change if profile == self.get_profile(name): logger.warning("Fan {name} profile {profile} already active".format(name=name, profile=profile)) return True if self._nvfancontrol: is_active = nvfancontrol_is_active() # Check first if the fan control is active and after enable the service if profile in self._fan_list[name]['profile']: if profile == FAN_MANUAL_NAME: if is_active: os.system('systemctl stop nvfancontrol') logger.info("Profile set {profile}".format(profile=profile)) else: # Check if active and stop if is_active: os.system('systemctl stop nvfancontrol') logger.info("Stop nvfancontrol service") # Update nvfile change_nvfancontrol_default('profile', profile) logger.info("Change /etc/nvfancontrol.conf profile in {profile}".format(profile=profile)) # Remove nvfancontrol status file if os.path.isfile("/var/lib/nvfancontrol/status"): os.remove("/var/lib/nvfancontrol/status") logger.info("Removed /var/lib/nvfancontrol/status") # Restart service os.system('systemctl start nvfancontrol') logger.info("Restart nvfancontrol With profile: \"{profile}\"".format(profile=profile)) else: logger.error("Profile {profile} doesn't exist".format(profile=profile)) return False else: if profile in self._fan_list[name]['profile']: control_value = "0" if profile == FAN_MANUAL_NAME else "1" # Write control if exist if 'control' in self._fan_list[name]: control = self._fan_list[name]['control'] # Set for all pwm the same speed value if os.access(control, os.W_OK): with open(control, 'w') as f: f.write(control_value) logger.info("Profile set {profile}".format(profile=profile)) else: logger.error("Profile {profile} doesn't exist".format(profile=profile)) return False # Update configuration on board fan_config = self._config.get('fan', {}) # Set new profile if name not in fan_config: fan_config[name] = {} fan_config[name]['profile'] = profile # Set new jetson_clocks configuration self._config.set('fan', fan_config) return True def set_speed(self, name, speed, index): if name not in self._fan_list: logger.error("This fan {name} doesn't exist".format(name=name)) return if index >= len(self._fan_list[name]['pwm']): logger.error("Wrong index {index} for {name}".format(index=index, name=name)) return # Update configuration on board fan_config = self._config.get('fan', {}) # Set new profile if name not in fan_config: fan_config[name] = {} fan_config[name]['speed'] = (speed, index) # Set new jetson_clocks configuration self._config.set('fan', fan_config) # Convert in PWM pwm = ValueToPWM(speed) # Set for all pwm the same speed value pwm_path = self._fan_list[name]['pwm'][index] try: if os.access(pwm_path, os.W_OK): with open(pwm_path, 'w') as f: f.write(str(pwm)) except OSError as e: logger.error("I cannot set fan speed: {speed} - error {e}".format(speed=speed, e=e)) def get_status(self): fan_status = {} # Read all fan status for name, data in self._fan_list.items(): # Read pwm from all fan fan_status[name] = { 'speed': [PWMtoValue(float(cat(pwm))) for pwm in data['pwm']], } if 'rpm' in data: fan_status[name]['rpm'] = [int(cat(rpm)) for rpm in data['rpm']] # Check status fan control if self._nvfancontrol: nvfan_query = {} if nvfancontrol_is_active(): nvfan_query = nvfancontrol_query() if nvfan_query: for fan, nvfan in zip(fan_status, nvfan_query): fan_status[fan].update(nvfan_query[nvfan]) else: for fan in fan_status: fan_status[fan]['profile'] = FAN_MANUAL_NAME else: for name, data in self._fan_list.items(): if 'control' in data: control_value = int(cat(data['control'])) == 1 fan_status[name]['profile'] = FAN_TEMP_CONTROL_NAME if control_value else FAN_MANUAL_NAME else: fan_status[name]['profile'] = FAN_MANUAL_NAME return fan_status # EOF