@@ -530,6 +530,111 @@ def power_schedule(action, minutes):
530530 return {"ok" : False , "error" : "azione sconosciuta" }
531531
532532
533+ # ---------------- auto rules + screen snapshot ----------------
534+ import urllib .request
535+ RULES_DEFAULT = {"enabled" : True , "temp_limit" : 92 , "samples" : 6 }
536+ LASTFRAME = "/tmp/skillfish-lastframe.jpg" # captured as the desktop user, so must be user-writable
537+ _RULES = {"hot" : 0 , "last_action" : "" , "snap_ts" : 0 }
538+
539+
540+ def save_conf ():
541+ try :
542+ with open (CONF , "w" ) as f :
543+ json .dump (CONFIG , f , indent = 2 )
544+ except Exception :
545+ pass
546+
547+
548+ def rules_cfg ():
549+ return {** RULES_DEFAULT , ** (CONFIG .get ("rules_cfg" ) or {})}
550+
551+
552+ def rules_loop ():
553+ while True :
554+ try :
555+ c = rules_cfg ()
556+ if CONFIG .get ("modules" , {}).get ("rules" ) and c ["enabled" ]:
557+ v = read_all ()
558+ temps = [x for x in (v .get ("gpu_temp" ), v .get ("cpu_temp" )) if isinstance (x , (int , float ))]
559+ t = max (temps ) if temps else 0
560+ if t >= c ["temp_limit" ]:
561+ _RULES ["hot" ] += 1
562+ if _RULES ["hot" ] >= c ["samples" ]:
563+ apply_preset ("Stock" )
564+ _RULES ["hot" ] = 0
565+ _RULES ["last_action" ] = "%s — %d°C ≥ %d°C → preset Stock applicato" % (
566+ time .strftime ("%H:%M:%S" ), round (t ), c ["temp_limit" ])
567+ else :
568+ _RULES ["hot" ] = 0
569+ except Exception :
570+ pass
571+ time .sleep (2 )
572+
573+
574+ def snapshot_loop ():
575+ u = CONFIG .get ("user" , "skillfish" )
576+ # keep the screen awake so the "last frame" is the real desktop, not a blanked one
577+ for args in (["xset" , "s" , "off" ], ["xset" , "-dpms" ]):
578+ try :
579+ subprocess .run (["sudo" , "-u" , u , "env" , "DISPLAY=:0" ] + args ,
580+ stdout = subprocess .DEVNULL , stderr = subprocess .DEVNULL , timeout = 5 )
581+ except Exception :
582+ pass
583+ while True :
584+ try :
585+ if CONFIG .get ("modules" , {}).get ("rules" ):
586+ subprocess .run (["sudo" , "-u" , u , "env" , "DISPLAY=:0" ,
587+ "import" , "-window" , "root" , "-resize" , "960x" , LASTFRAME ],
588+ stdout = subprocess .DEVNULL , stderr = subprocess .DEVNULL , timeout = 12 )
589+ try :
590+ os .chmod (LASTFRAME , 0o644 )
591+ except Exception :
592+ pass
593+ _RULES ["snap_ts" ] = int (time .time ())
594+ except Exception :
595+ pass
596+ time .sleep (20 )
597+
598+
599+ # ---------------- AI-Ops (local LLM log diagnosis) ----------------
600+ def _ollama_models ():
601+ try :
602+ r = subprocess .run (["docker" , "exec" , AI_CONTAINER , "ollama" , "list" ],
603+ capture_output = True , text = True , timeout = 8 ).stdout
604+ return [ln .split ()[0 ] for ln in r .splitlines ()[1 :] if ln .strip ()]
605+ except Exception :
606+ return []
607+
608+
609+ def aiops_diagnose (question ):
610+ if not _port_open (11434 ):
611+ return {"ok" : False , "error" : "Motore AI spento: accendilo dal modulo AI." }
612+ models = _ollama_models ()
613+ model = models [0 ] if models else "qwen3:14b"
614+ jrn = subprocess .run (["journalctl" , "-p" , "warning" , "-n" , "60" , "--no-pager" , "-o" , "short" ],
615+ capture_output = True , text = True , timeout = 8 ).stdout
616+ try :
617+ frz = open ("/var/log/skillfish-freeze.log" ).read ()[- 1500 :]
618+ except Exception :
619+ frz = "(nessun freeze registrato)"
620+ v = read_all ()
621+ tele = "GPU %s°C / CPU %s°C, GPU %s MHz, %s W, ventola %s RPM" % (
622+ v .get ("gpu_temp" ), v .get ("cpu_temp" ), v .get ("gpu_freq" ), v .get ("gpu_power" ), v .get ("fan" ))
623+ q = question or "Analizza i log e lo stato: ci sono problemi? Causa probabile e come risolvere?"
624+ prompt = ("Sei l'assistente di sistema di SkillFishOS su una scheda AMD BC-250. "
625+ "Rispondi in italiano, conciso e pratico.\n \n === Telemetria ===\n %s\n \n "
626+ "=== Log freeze ===\n %s\n \n === journalctl (warning+) ===\n %s\n \n === Domanda ===\n %s\n " %
627+ (tele , frz , jrn [- 3000 :], q ))
628+ try :
629+ req = urllib .request .Request ("http://127.0.0.1:11434/api/generate" ,
630+ data = json .dumps ({"model" : model , "prompt" : prompt , "stream" : False }).encode (),
631+ headers = {"Content-Type" : "application/json" })
632+ out = json .loads (urllib .request .urlopen (req , timeout = 120 ).read ().decode ())
633+ return {"ok" : True , "model" : model , "answer" : out .get ("response" , "" ).strip ()}
634+ except Exception as e :
635+ return {"ok" : False , "error" : "Ollama: %s" % e }
636+
637+
533638# ---------------- HTTP ----------------
534639CONFIG = load_conf ()
535640_login_fails = {} # ip -> (count, first_ts)
@@ -721,6 +826,21 @@ class Handler(BaseHTTPRequestHandler):
721826 if not self ._guard ("wol" ):
722827 return
723828 return self ._json (200 , wol_info ())
829+ if path == "/api/rules" :
830+ if not self ._guard ("rules" ):
831+ return
832+ c = rules_cfg ()
833+ c ["last_action" ] = _RULES ["last_action" ]
834+ c ["snap_age" ] = int (time .time ()) - _RULES ["snap_ts" ] if _RULES ["snap_ts" ] else None
835+ c ["has_frame" ] = os .path .isfile (LASTFRAME )
836+ return self ._json (200 , c )
837+ if path == "/api/rules/frame" :
838+ if not self ._guard ("rules" ):
839+ return
840+ if os .path .isfile (LASTFRAME ):
841+ with open (LASTFRAME , "rb" ) as f :
842+ return self ._send (200 , f .read (), "image/jpeg" )
843+ return self ._json (404 , {"error" : "no frame" })
724844 return self ._json (404 , {"error" : "not found" })
725845
726846 def do_POST (self ):
@@ -812,6 +932,21 @@ class Handler(BaseHTTPRequestHandler):
812932 if not self ._guard ("wol" ):
813933 return
814934 return self ._json (200 , power_schedule (data .get ("action" ), data .get ("minutes" , 1 )))
935+ if path == "/api/rules" :
936+ if not self ._guard ("rules" ):
937+ return
938+ cfg = rules_cfg ()
939+ if "enabled" in data :
940+ cfg ["enabled" ] = bool (data ["enabled" ])
941+ if "temp_limit" in data :
942+ cfg ["temp_limit" ] = max (70 , min (100 , int (data ["temp_limit" ])))
943+ CONFIG ["rules_cfg" ] = cfg
944+ save_conf ()
945+ return self ._json (200 , {"ok" : True , ** cfg })
946+ if path == "/api/aiops/diagnose" :
947+ if not self ._guard ("aiops" ):
948+ return
949+ return self ._json (200 , aiops_diagnose (data .get ("question" )))
815950 return self ._json (404 , {"error" : "not found" })
816951
817952 def _login (self ):
@@ -895,6 +1030,8 @@ def main():
8951030 httpd .socket = ctx .wrap_socket (httpd .socket , server_side = True )
8961031 sys .stderr .write ("SkillFish Remote on https://%s:%d (PAM=%s)\n " % (bind , port , _PAM_OK ))
8971032 sys .stderr .flush ()
1033+ threading .Thread (target = rules_loop , daemon = True ).start ()
1034+ threading .Thread (target = snapshot_loop , daemon = True ).start ()
8981035 httpd .serve_forever ()
8991036
9001037
0 commit comments