From c1b7887c5572fbf49198c619aeac55968dded979 Mon Sep 17 00:00:00 2001 From: Joseph Coffland Date: Wed, 22 May 2019 17:38:42 -0700 Subject: [PATCH] =?utf8?q?Automatically=20scale=20max=20CPU=20speed=20to?= =?utf8?q?=20reduce=20RPi=20temp,=20Disable=20USB=20camera=20if=20RPi=20te?= =?utf8?q?mperature=20above=2080=C2=B0C,=20back=20on=20at=2075=C2=B0C?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 + scripts/rc.local | 5 +- src/bbserial/.gitignore | 8 +++ src/py/bbctrl/Camera.py | 62 +++++++++++------- src/py/bbctrl/Ctrl.py | 21 ------ src/py/bbctrl/MonitorTemp.py | 103 ++++++++++++++++++++++++++++++ src/py/bbctrl/Web.py | 9 ++- src/py/bbctrl/__init__.py | 1 + src/resources/images/overtemp.jpg | Bin 0 -> 15875 bytes 9 files changed, 162 insertions(+), 49 deletions(-) create mode 100644 src/bbserial/.gitignore create mode 100644 src/py/bbctrl/MonitorTemp.py create mode 100644 src/resources/images/overtemp.jpg diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ef92b..c97aa73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Buildbotics CNC Controller Firmware Changelog - Increased AVR serial and command buffers. - Boost HDMI signal. - Rewrote RPi serial driver. + - Automatically scale max CPU speed to reduce RPi temp. + - Disable USB camera if RPi temperature above 80°C, back on at 75°C. ## v0.4.7 - Fix homing switch to motor channel mapping with non-standard axis order. diff --git a/scripts/rc.local b/scripts/rc.local index aecc57f..43e549a 100755 --- a/scripts/rc.local +++ b/scripts/rc.local @@ -23,9 +23,8 @@ fi # Reload udev /etc/init.d/udev restart -# Stop boot splash so it does not interfere with X if GPU enabled -grep ^dtoverlay=vc4-kms-v3d /boot/config.txt >/dev/null -if [ $? -eq 0 ]; then plymouth quit; fi +# Stop boot splash so it doesn't interfere with X if GPU enabled and to save CPU +plymouth quit # Start X in /home/pi cd /home/pi diff --git a/src/bbserial/.gitignore b/src/bbserial/.gitignore new file mode 100644 index 0000000..a90b6ca --- /dev/null +++ b/src/bbserial/.gitignore @@ -0,0 +1,8 @@ +/.*.cmd +/.tmp_versions +/Module.symvers +/*.ko +/*.mod.c +/*.o +/kernel +/modules.order diff --git a/src/py/bbctrl/Camera.py b/src/py/bbctrl/Camera.py index a5f0fcd..385c381 100755 --- a/src/py/bbctrl/Camera.py +++ b/src/py/bbctrl/Camera.py @@ -290,16 +290,17 @@ class Camera(object): self.fps = args.fps self.fourcc = string_to_fourcc(args.fourcc) - self.offline_jpg = get_image_resource('http/images/offline.jpg') - self.in_use_jpg = get_image_resource('http/images/in-use.jpg') + self.overtemp = False self.dev = None self.clients = [] self.path = None + self.have_camera = False # Find connected cameras for i in range(4): path = '/dev/video%d' % i if os.path.exists(path): + self.have_camera = True self.open(path) break @@ -317,8 +318,13 @@ class Camera(object): path = str(device.device_node) - if action == 'add': self.open(path) - if action == 'remove' and path == self.path: self.close() + if action == 'add': + self.have_camera = True + self.open(path) + + if action == 'remove' and path == self.path: + self.have_camera = False + self.close() def _send_frame(self, frame): @@ -344,13 +350,21 @@ class Camera(object): self.log.warning('Failed to read from camera.') self.ioloop.remove_handler(fd) self.close() - return + def _update_client_image(self): + if self.have_camera and not self.overtemp: return + if self.overtemp and self.have_camera: img = 'overtemp' + else: img = 'offline' + + if len(self.clients): self.clients[-1].write_img(img) + def open(self, path): try: + self._update_client_image() self.path = path + if self.overtemp: return self.dev = VideoDevice(path) caps = self.dev.get_info() @@ -389,34 +403,29 @@ class Camera(object): self.dev = None - def close(self): + def close(self, overtemp = False): + self._update_client_image() if self.dev is None: return + try: self.ioloop.remove_handler(self.dev) try: self.dev.stop() except: pass - self._close_dev() - for client in self.clients: - client.write_frame_twice(self.offline_jpg) - - self.log.info('Closed camera %s' % self.path) + self._close_dev() + self.log.info('Closed camera') - except: self.log.warning('Closing camera') + except: self.log.exception('Exception while closing camera') finally: self.dev = None def add_client(self, client): self.log.info('Adding camera client: %d' % len(self.clients)) - if len(self.clients): - self.clients[-1].write_frame_twice(self.in_use_jpg) - + if len(self.clients): self.clients[-1].write_img('in-use') self.clients.append(client) - - if self.dev is None: - client.write_frame_twice(self.offline_jpg) + self._update_client_image() def remove_client(self, client): @@ -426,6 +435,14 @@ class Camera(object): except: pass + def set_overtemp(self, overtemp): + if self.overtemp == overtemp: return + self.overtemp = overtemp + + if overtemp: self.close(True) + elif self.path is not None: self.open(self.path) + + class VideoHandler(web.RequestHandler): boundary = '---boundary---' @@ -449,13 +466,14 @@ class VideoHandler(web.RequestHandler): self.set_header('Expires', 'Mon, 3 Jan 2000 12:34:56 GMT') self.set_header('Pragma', 'no-cache') - if self.camera is None: - frame = get_image_resource('http/images/offline.jpg') - self.write_frame_twice(frame) - + if self.camera is None: self.write_img('offline') else: self.camera.add_client(self) + def write_img(self, name): + self.write_frame_twice(get_image_resource('http/images/%s.jpg' % name)) + + def write_frame(self, frame): # Don't allow too many frames to queue up min_size = len(frame) * 2 diff --git a/src/py/bbctrl/Ctrl.py b/src/py/bbctrl/Ctrl.py index 2a0955a..854f538 100644 --- a/src/py/bbctrl/Ctrl.py +++ b/src/py/bbctrl/Ctrl.py @@ -36,8 +36,6 @@ class Ctrl(object): self.ioloop = bbctrl.IOLoop(ioloop) self.id = id self.timeout = None # Used in demo mode - self.last_temp_warn = 0 - self.temp_thresh = 80 if id and not os.path.exists(id): os.mkdir(id) @@ -67,8 +65,6 @@ class Ctrl(object): self.lcd.add_new_page(bbctrl.MainLCDPage(self)) self.lcd.add_new_page(bbctrl.IPLCDPage(self.lcd)) - if not args.demo: self.check_temp() - except Exception: self.log.get('Ctrl').exception() @@ -86,23 +82,6 @@ class Ctrl(object): self.timeout = self.ioloop.call_later(t, cb, *args, **kwargs) - def check_temp(self): - with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f: - temp = round(int(f.read()) / 1000) - - # Reset temperature warning threshold after timeout - if time.time() < self.last_temp_warn + 60: self.temp_thresh = 80 - - if self.temp_thresh < temp: - self.last_temp_warn = time.time() - self.temp_thresh = temp - - log = self.log.get('Ctrl') - log.info('Hot RaspberryPi at %d°C' % temp) - - self.ioloop.call_later(15, self.check_temp) - - def get_path(self, dir = None, filename = None): path = './' + self.id if self.id else '.' path = path if dir is None else (path + '/' + dir) diff --git a/src/py/bbctrl/MonitorTemp.py b/src/py/bbctrl/MonitorTemp.py new file mode 100644 index 0000000..839b2e6 --- /dev/null +++ b/src/py/bbctrl/MonitorTemp.py @@ -0,0 +1,103 @@ +################################################################################ +# # +# This file is part of the Buildbotics firmware. # +# # +# Copyright (c) 2015 - 2018, Buildbotics LLC # +# All rights reserved. # +# # +# This file ("the software") is free software: you can redistribute it # +# and/or modify it under the terms of the GNU General Public License, # +# version 2 as published by the Free Software Foundation. You should # +# have received a copy of the GNU General Public License, version 2 # +# along with the software. If not, see . # +# # +# The software 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 # +# Lesser General Public License for more details. # +# # +# You should have received a copy of the GNU Lesser General Public # +# License along with the software. If not, see # +# . # +# # +# For information regarding this software email: # +# "Joseph Coffland" # +# # +################################################################################ + +import time + + +def read_temp(): + with open('/sys/class/thermal/thermal_zone0/temp', 'r') as f: + return round(int(f.read()) / 1000) + + +def set_max_freq(freq): + filename = '/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq' + with open(filename, 'w') as f: f.write('%d\n' % freq) + + +class MonitorTemp(object): + def __init__(self, app): + self.app = app + + ctrl = app.get_ctrl() + self.log = ctrl.log.get('Mon') + self.ioloop = ctrl.ioloop + + self.last_temp_warn = 0 + self.temp_thresh = 80 + self.min_temp = 60 + self.max_temp = 80 + self.min_freq = 600000 + self.max_freq = 1200000 + self.low_camera_temp = 75 + self.high_camera_temp = 80 + + self.callback() + + + # Scale max CPU based on temperature + def scale_cpu(self, temp): + if temp < self.min_temp: cpu_freq = self.max_freq + elif self.max_temp < temp: cpu_freq = self.min_freq + else: + r = 1 - float(temp - self.min_temp) / \ + (self.max_temp - self.min_temp) + cpu_freq = self.min_freq + (self.max_freq - self.min_freq) * r + + set_max_freq(cpu_freq) + + + def update_camera(self, temp): + if self.app.camera is None: return + + # Disable camera if temp too high + if temp < self.low_camera_temp: self.app.camera.set_overtemp(False) + elif self.high_camera_temp < temp: + self.app.camera.set_overtemp(True) + + + def log_warnings(self, temp): + # Reset temperature warning threshold after timeout + if time.time() < self.last_temp_warn + 60: self.temp_thresh = 80 + + if self.temp_thresh < temp: + self.last_temp_warn = time.time() + self.temp_thresh = temp + + self.log.info('Hot RaspberryPi at %d°C' % temp) + + + def callback(self): + try: + temp = read_temp() + + self.scale_cpu(temp) + self.update_camera(temp) + self.log_warnings(temp) + + except: self.log.exception() + + self.ioloop.call_later(5, self.callback) diff --git a/src/py/bbctrl/Web.py b/src/py/bbctrl/Web.py index 7a0d499..51e7c16 100644 --- a/src/py/bbctrl/Web.py +++ b/src/py/bbctrl/Web.py @@ -479,14 +479,17 @@ class Web(tornado.web.Application): self.ioloop = ioloop self.ctrls = {} - # Init controller - if not self.args.demo: self.get_ctrl() - # Init camera if not args.disable_camera: if self.args.demo: log = bbctrl.log.Log(args, ioloop, 'camera.log') else: log = self.get_ctrl().log self.camera = bbctrl.Camera(ioloop, args, log) + else: self.camera = None + + # Init controller + if not self.args.demo: + self.get_ctrl() + self.monitor = bbctrl.MonitorTemp(self) handlers = [ (r'/websocket', WSConnection), diff --git a/src/py/bbctrl/__init__.py b/src/py/bbctrl/__init__.py index b8eed91..3f54b19 100644 --- a/src/py/bbctrl/__init__.py +++ b/src/py/bbctrl/__init__.py @@ -58,6 +58,7 @@ from bbctrl.Camera import Camera, VideoHandler from bbctrl.AVR import AVR from bbctrl.AVREmu import AVREmu from bbctrl.IOLoop import IOLoop +from bbctrl.MonitorTemp import MonitorTemp import bbctrl.Cmd as Cmd import bbctrl.v4l2 as v4l2 import bbctrl.Log as log diff --git a/src/resources/images/overtemp.jpg b/src/resources/images/overtemp.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d39b31a1d3060535093e807e056f5d3874539f9e GIT binary patch literal 15875 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 dKzy7qA5Y5^J*_}M=xEg|JAlc=`