From f129d42d04b18848ff89a94950c4eeb379c94445 Mon Sep 17 00:00:00 2001 From: Kirby Kalbaugh Date: Wed, 1 Apr 2026 13:40:16 -0400 Subject: [PATCH 1/6] init --- spiro/config.py | 3 ++ spiro/experimenter.py | 56 ++++++++++++++++------ spiro/hwcontrol.py | 82 +++++++++++++++++++++++++++------ spiro/static/spiro.js | 3 +- spiro/templates/experiment.html | 4 +- spiro/templates/index.html | 2 +- spiro/webui.py | 46 ++++++++++++++---- 7 files changed, 154 insertions(+), 42 deletions(-) diff --git a/spiro/config.py b/spiro/config.py index 84c36e6..a7bad29 100644 --- a/spiro/config.py +++ b/spiro/config.py @@ -33,6 +33,9 @@ def _resolve_version(): class Config(object): defaults = { 'calibration' : 8, # number of steps taken after positional sensor is lit + 'position_count': 8, # number of capture positions around the stage + 'secondary_stop_steps': 25, # steps between the coarse and fine stop trigger; set to 0 to disable + 'secondary_home_steps': 25, # backwards-compatible alias for older configs 'sensor': 4, # pin for mini microswitch positional sensor 'LED': 17, # pin for turning on/off led 'PWMa': 8, # first pwm pin diff --git a/spiro/experimenter.py b/spiro/experimenter.py index 9e1f786..5f0a568 100644 --- a/spiro/experimenter.py +++ b/spiro/experimenter.py @@ -19,6 +19,7 @@ def __init__(self, hw=None, cam=None): self.hw = hw self.cam = cam self.cfg = Config() + self.position_count = self._get_position_count() self.delay = 60 self.duration = 7 self.dir = os.path.expanduser('~') @@ -31,14 +32,24 @@ def __init__(self, hw=None, cam=None): self.stop_experiment = False self.status_change = threading.Event() self.next_status = '' - self.last_captured = [''] * 4 - self.preview = [''] * 4 + self.last_captured = [''] * self.position_count + self.preview = [''] * self.position_count self.preview_lock = threading.Lock() self.nshots = 0 self.idlepos = 0 threading.Thread.__init__(self) + def _get_position_count(self): + return max(1, int(self.cfg.get('position_count') or 8)) + + + def _reset_position_buffers(self): + self.position_count = self._get_position_count() + self.last_captured = [''] * self.position_count + self.preview = [''] * self.position_count + + def stop(self): self.status = "Stopping" self.next_status = '' @@ -209,7 +220,7 @@ def runExperiment(self): self.status = "Initiating" self.starttime = time.time() self.endtime = time.time() + 60 * 60 * 24 * self.duration - self.last_captured = [''] * 4 + self._reset_position_buffers() self.delay = self.delay or 0.001 self.nshots = self.duration * 24 * 60 // self.delay self.cam.exposure_mode = "auto" @@ -220,7 +231,7 @@ def runExperiment(self): # make sure we don't write directly to home dir self.dir = os.path.join(os.path.expanduser('~'), self.getDefName()) - for i in range(4): + for i in range(self.position_count): platedir = "plate" + str(i + 1) os.makedirs(os.path.join(self.dir, platedir), exist_ok=True) @@ -231,19 +242,24 @@ def runExperiment(self): if nextloop > self.endtime: nextloop = self.endtime - for i in range(4): - # rotate stage to starting position + self.hw.motorOn(True) + for i in range(self.position_count): if i == 0: - self.hw.motorOn(True) self.status = "Finding start position" debug("Finding initial position.") - self.hw.findStart(calibration=self.cfg.get('calibration')) + if not self.hw.findStart(calibration=self.cfg.get('calibration')): + self.stop_experiment = True + break debug("Found initial position.") - if self.status != "Stopping": self.status = "Imaging" else: - # rotate cube 90 degrees - debug("Rotating stage.") - self.hw.halfStep(100, 0.03) + self.status = "Finding next position" + debug("Finding next capture position.") + if not self.hw.findNextStop(): + self.stop_experiment = True + break + + if self.status != "Stopping": + self.status = "Imaging" # wait for the cube to stabilize time.sleep(1.5) @@ -252,18 +268,28 @@ def runExperiment(self): name = os.path.join("plate" + str(i + 1), "plate" + str(i + 1) + "-" + now) self.takePicture(name, i) + if self.stop_experiment: + self.hw.motorOn(False) + break + self.nshots -= 1 self.hw.motorOn(False) if self.status != "Stopping": self.status = "Waiting" if self.idlepos > 0: - # alternate between resting positions during idle, stepping 45 degrees per image + # alternate between resting positions during idle using sensed stops self.hw.motorOn(True) - self.hw.halfStep(50 * self.idlepos, 0.03) + for i in range(self.idlepos): + if not self.hw.findNextStop(): + self.stop_experiment = True + break self.hw.motorOn(False) + if self.stop_experiment: + break + self.idlepos += 1 - if self.idlepos > 7: + if self.idlepos >= self.position_count: self.idlepos = 0 while time.time() < nextloop and not self.stop_experiment: diff --git a/spiro/hwcontrol.py b/spiro/hwcontrol.py index f64f94f..8290edd 100644 --- a/spiro/hwcontrol.py +++ b/spiro/hwcontrol.py @@ -1,6 +1,13 @@ # hwcontrol.py - # various functions for controlling the spiro hardware # +# Hardware: +# - Raspberry Pi GPIO pins for motor control, LED, and positional switch +# - Stepper motor with 200 steps per revolution (1.8 degree step angle) +# - Adafruit DRV8833 DC/Stepper Motor Driver Breakout Board +# - ArduCam motorized focus camera (optional) + + import RPi.GPIO as gpio import time @@ -69,24 +76,71 @@ def GPIOInit(self): def cleanup(self): gpio.cleanup() + def _sensor_active(self): + if not self._valid_pin(self.pins.get('sensor')): + return False + return bool(gpio.input(self.pins['sensor'])) + + def _secondary_stop_steps(self): + value = self.cfg.get('secondary_stop_steps') + if value is None: + value = self.cfg.get('secondary_home_steps') + return max(0, int(value or 0)) + + def _clear_sensor(self, deadline, delay): + while self._sensor_active() and time.time() < deadline: + self.halfStep(1, delay) + return not self._sensor_active() + + def _step_until_sensor(self, expected_state, deadline, delay): + while self._valid_pin(self.pins.get('sensor')) and self._sensor_active() != expected_state and time.time() < deadline: + self.halfStep(1, delay) + return self._sensor_active() == expected_state + + def _find_stop(self, calibration=0, timeout=60, label='stop position'): + first_stop_delay = 0.03 + second_stop_delay = 0.06 + deadline = time.time() + timeout + secondary_stop_steps = self._secondary_stop_steps() + + if not self._valid_pin(self.pins.get('sensor')): + return False + + if not self._clear_sensor(deadline, first_stop_delay): + log(f"Timed out while clearing {label}! Images will be misaligned.") + return False + + if not self._step_until_sensor(True, deadline, first_stop_delay): + log(f"Timed out while finding {label}! Images will be misaligned.") + return False + + if secondary_stop_steps > 0: + if not self._clear_sensor(deadline, first_stop_delay): + log(f"Timed out while clearing {label}! Images will be misaligned.") + return False + + for i in range(secondary_stop_steps): + if time.time() >= deadline: + log(f"Timed out while finding {label}! Images will be misaligned.") + return False + self.halfStep(1, first_stop_delay) + + if not self._step_until_sensor(True, deadline, second_stop_delay): + log(f"Timed out while finding {label}! Images will be misaligned.") + return False + + if calibration: + self.halfStep(calibration, first_stop_delay) + return True + def findStart(self, calibration=None): """rotates the imaging stage until the positional switch is activated""" calibration = calibration or self.cfg.get('calibration') - timeout = 60 - starttime = time.time() - - # make sure that switch is not depressed when starting - if self._valid_pin(self.pins.get('sensor')) and gpio.input(self.pins['sensor']): - while gpio.input(self.pins['sensor']) and time.time() < starttime + timeout: - self.halfStep(1, 0.03) - - while self._valid_pin(self.pins.get('sensor')) and not gpio.input(self.pins['sensor']) and time.time() < starttime + timeout: - self.halfStep(1, 0.03) + return self._find_stop(calibration=calibration, label='start position') - if time.time() < starttime + timeout: - self.halfStep(calibration, 0.03) - else: - log("Timed out while finding start position! Images will be misaligned.") + def findNextStop(self): + """rotates the imaging stage until the next positional stop is activated""" + return self._find_stop(label='next stop position') # sets the motor pins as element in sequence def setStepper(self, M_seq, i): diff --git a/spiro/static/spiro.js b/spiro/static/spiro.js index d58fbdf..77f2eea 100644 --- a/spiro/static/spiro.js +++ b/spiro/static/spiro.js @@ -35,11 +35,12 @@ function updateShutter(value) { function calcDiskSpace() { disk = document.getElementById('disk'); + positionCount = parseFloat(disk.dataset.positionCount || 8); delaydom = document.getElementById('delay'); delay = parseFloat(delaydom.value); duration = document.getElementById('duration'); duration = parseFloat(duration.value); - req = 4 * 8 * duration * 24 * 60 / delay / 1024; + req = positionCount * 8 * duration * 24 * 60 / delay / 1024; disk.innerHTML = req.toFixed(1) + " GB"; avail = document.getElementById('diskavail').innerHTML; avail = parseFloat(avail.split(" ")[0]); diff --git a/spiro/templates/experiment.html b/spiro/templates/experiment.html index 1a0b823..0eae033 100644 --- a/spiro/templates/experiment.html +++ b/spiro/templates/experiment.html @@ -34,7 +34,7 @@
{% if running %} -{% for i in range(4) %} +{% for i in range(position_count) %}
@@ -82,7 +82,7 @@

Experiment info

-{{ (4 * 4 * duration * 24 * 60 / delay / 1024)|round(1) }} GB +{{ (position_count * 8 * duration * 24 * 60 / delay / 1024)|round(1) }} GB
{{ diskspace }} GB diff --git a/spiro/templates/index.html b/spiro/templates/index.html index b424758..8e2ee07 100644 --- a/spiro/templates/index.html +++ b/spiro/templates/index.html @@ -44,7 +44,7 @@
- +
diff --git a/spiro/webui.py b/spiro/webui.py index 6745427..d6760bb 100644 --- a/spiro/webui.py +++ b/spiro/webui.py @@ -5,11 +5,11 @@ import io import os import re +import subprocess import time import shutil import signal import hashlib -import subprocess from threading import Thread, Lock, Condition from waitress import serve @@ -41,6 +41,19 @@ def run(self): lock.release() +class StopFinder(Thread): + def run(self): + lock.acquire() + try: + hw.motorOn(True) + time.sleep(0.5) + hw.findNextStop() + time.sleep(0.5) + finally: + hw.motorOn(False) + lock.release() + + #class StreamingOutput(io.BufferedIOBase): # def __init__(self): # self.frame = None @@ -57,8 +70,6 @@ def run(self): # self.condition.notify_all() # self.buffer.seek(0) # return self.buffer.write(buf) -import io -from threading import Condition class StreamingOutput(io.BufferedIOBase): def __init__(self): @@ -165,6 +176,14 @@ def checkPass(pwd): return False +def get_plate_dirs(check_dir): + plate_dirs = [] + for entry in os.scandir(check_dir): + if entry.is_dir() and re.fullmatch(r'plate\d+', entry.name): + plate_dirs.append((int(entry.name[5:]), entry.name)) + return [name for _, name in sorted(plate_dirs)] + + @app.route('/index.html') @app.route('/') def index(): @@ -308,6 +327,14 @@ def rotate(value): return redirect(url_for('index')) +@not_while_running +@app.route('/nextposition') +def nextposition(): + stop_finder = StopFinder() + stop_finder.start() + return redirect(url_for('index')) + + @not_while_running @app.route('/findstart') @app.route('/findstart/') @@ -357,7 +384,7 @@ def dayStill(): @app.route('/lastcapture/.png') def lastCapture(num): - if num < 0 or num > 3: + if num < 0 or num >= experimenter.position_count: return redirect(url_for('static', filename='empty.png')) else: if experimenter.last_captured[num] == '': @@ -373,7 +400,7 @@ def lastCapture(num): @app.route('/preview/.jpg') def preview(num): - if num < 0 or num > 3: + if num < 0 or num >= experimenter.position_count: return redirect(url_for('static', filename='empty.png')) elif experimenter.preview[num] == '': return redirect(url_for('static', filename='empty.png')) @@ -452,11 +479,12 @@ def experiment(): df = shutil.disk_usage(os.path.expanduser('~')) diskspace = round(df.free / 1024 ** 3, 1) - diskreq = round(experimenter.nshots * 4 * 8 / 1024, 1) + diskreq = round(experimenter.nshots * experimenter.position_count * 8 / 1024, 1) return render_template('experiment.html', running=experimenter.running, directory=experimenter.dir, starttime=time.ctime(experimenter.starttime), delay=experimenter.delay, endtime=time.ctime(experimenter.endtime), diskspace=diskspace, duration=experimenter.duration, - status=experimenter.status, nshots=experimenter.nshots + 1, diskreq=diskreq, name=cfg.get('name'), + status=experimenter.status, nshots=experimenter.nshots + 1, diskreq=diskreq, + position_count=experimenter.position_count, name=cfg.get('name'), defname=experimenter.getDefName()) @@ -605,7 +633,7 @@ def file_browser(): for entry in os.scandir(dir): if entry.is_dir() and os.path.dirname(entry.path) == dir and not entry.name.startswith('.') \ - and all(os.path.exists(os.path.join(entry.path, f'plate{i+1}')) for i in range(4)): + and get_plate_dirs(entry.path): du = subprocess.check_output(['/usr/bin/du','-s', entry.path]).split()[0].decode('utf-8') dirs.append((entry.name, round(int(du)/1024**2, 1))) return render_template('filemanager.html', dirs=sorted(dirs), diskspace=diskspace, name=cfg.get('name'), running=experimenter.running) @@ -651,7 +679,7 @@ def dir_browser(dir): abort(404) files = {} - for plate in ['plate' + str(i+1) for i in range(4)]: + for plate in get_plate_dirs(dir): plate_files = [] for entry in os.scandir(os.path.join(dir, plate)): if entry.is_file() and os.path.dirname(entry.path).startswith(dir) and not entry.name.startswith('.') and \ From f0499711d3daaf6a73fac5021fe20a785065b9ea Mon Sep 17 00:00:00 2001 From: Kirby Kalbaugh Date: Wed, 1 Apr 2026 15:37:59 -0400 Subject: [PATCH 2/6] updates --- spiro/static/rotate.png | Bin 0 -> 5828 bytes spiro/static/spiro.css | 7 +++++++ spiro/templates/index.html | 5 ++++- spiro/webui.py | 11 +++++++++++ 4 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 spiro/static/rotate.png diff --git a/spiro/static/rotate.png b/spiro/static/rotate.png new file mode 100644 index 0000000000000000000000000000000000000000..39d074e57fa358dfc84805d434e83a812446338c GIT binary patch literal 5828 zcmV;#7CY&QP)q6t`#0-E3!mA&XQ#~ZX_LFlK41o=iA%8o86t=+r8V{+v9zouv+eL zZ+W*nzx#jl&CEBHpatHdPu~}HbAIA$uO{%W1=A?rg0h0r%ytp=FsyExwHr={^P0>2 zmeOoX8p=kwpOfl22rU8^SeP`B)^Tt}m(fQ}sT+Ve&Ow`IP$sb^AaLb`s$Er~2;LnN zkO1E#h$9>`7*lv_5I8%yA{<=pKDl_y-4ZH@@4_D4)EWhjgKMX{k0KksSyJ7=Jx$b_ zTS{vLu&4?ZDpzooa8v~_fxuNaV5nXQAOhzxU||)ked-<+Z24H412A6g!5CV_kb!Hs zUj+=KhPBlO1ON|LWBaeFfV=ncJ<+CB^`K&%MBj;wR)dzA%Mf`{j^DgGcWmb3O#Gdn zolhfhCED)KM>GLFExu$?`3g;%U;IMC1-0?1Iw z(A>h8Uc8=(-@I~jXzcqZlL(xJ?fqg~cYRac{)VQyC{`OZFN`T(a%p~PXzKh_{Nzv0 zrfVKJMa@>#cQ>r4JAf`DT6tA(F*bdEdg$%{nMlZx!nm;CI zWPmSr{qpSLpa0u063ZSqrfAWYTUS9izZ)MvS|!0-n4V9tc3%I_pYsnq-+%)}%PcMp zgzBVDeB5Y(0&iv^F~`6=e&l4L>Vezz=`G<q%Wh2%jce^?)~@NzG5r_q_H`$wXj*Q&kN>>(nk?&p_MAfI~y8MmIBab1^=1 zak?jY>~uO%z%kf58EC`ol1g71Rj=H%x`Eu*irs5yRqbY`+1B;av;SMN+DTr#q0J9% z8K9IN1=s*I4K`Lr8k;asf>zxo)l1ExrqIa!2lftBRN*#1w3W5d2m_5oD1Rk(K-H@^ zZ&*dvePks*HMHg8W+rKf_7pocNGbugDBF!Ow(_t6XdGf4!#W3G{4t-V9f5UV zQxoj*$#TxKW9A4tN5Hi{vJGT&*bQh}IRLk2{jvrwmup{zDWz^zoqL}HRNJpJ9M`PD z@UQ7}X|+j}Z%(m&*YW4xiI)T%gAGRaA9V|w=2EWuwI;G|!?JKGHIPQRpCNsBx)v*) znl%mJi?VIA3SmuvICu3Xx%uIB6*S5N2NZT3f9_PWDBxN@vkh+G2qj!J(jjQt&y6eV z$@-0Js_mZg>9~`%3|5gKF6%V%)8{8)tfdaP914`C*m2wOBd3J*rLKVd4J+Sb4nb2; z%SsM^*-xJqfPX5Hwd073vyOEq7$ZA5S2LIO_wW$+w$mk z7{+q2B+%Ga-xy*Fw>D^iB}hJ%Hm-5zzY2yq06R$)Wy+qtJVO>H=Xp0WuX4FEMWDLg zJaQ`T2)M0}ZikyO;vO`TZ%A_R-MXdX_YEe-j!?;W-!jfYhAv|Dz}Kv*o|a)dFm~g^ z8&<&*)3K^@F{W}ow!m%Ow>>+O!~@WB1DJ1JP3rxbHGq&IeC27o%`cHj=zCj|t=&lR zaT`;)q$P0MI)CGW)~GENG%Z@WcEd{2u%f(K$Xz3Wc+))*6lM!1U_VYC5Fdrhc5#N5h~vUVj@&=Oe9&@A8s zwd?g7ytylLWai?uV>eRJ5w43^)^UN61B4LWSjkdB%brWg(&ADniq?amp%wf_&U!sD zFTsOufSLDZv@y!!bBMkJT+k5?rUidu`T1~^M78;BRFz698IIL5Z5|wv@^jvD=ShZR z^)(rH(4|#HYem@5iU6+->W^BFini$s(?mAaTbcV`{}U1ZM$YbKCDNMZ@Xp`gk##2v zD4Lzh@rp)t*U6TxSli^ZYapSj8U{nqN_8_qBzd>fUeS!f7kO!k{c$<}y1d2M4e7yZfmUYdz=P^w zYL`tDVqvrI&8mZwsW}jmk9P=yCNiPAvy87&jyFrWfQdzb#8^foXNty`5%oHmg$%-m zR%!5{zQu&?wySew=F+SwEFuby$fn;HRy(#uBu$x|f<_&HMhu{7=NQ~d2#OrSDu7lY z=yX$O7H1d9Qq3{XyNuB(Us-edY$(l+|`GV9IaBe{oZhd z5ipQ*1UWU5tzP7|O(!_VB52|eG|4Gw`F=Y|d!0mj+n?NZ5Fb67@37qyRGebPwgJYDuCvfZM!=hl_6h`46w;! z1rvlvYyoqwa%nYaLQ}eci3`w(q#rkC0!=c5#=|E`r2B!V_l!LFl|8sjDq2}>>)y?M ztmZ4Q8AJGH1(TfzO$2CKs9Z`@xN#md>H;*Y^9q5MFIQdzGZij;@|itRtQKfq+m_v% z!dv!i2GDxVTyY&}nsN#I)ja74AJ>3OSh> z&T#$MO1?}q^FT`%&Gv_W)cZyps|;E(7LMEj+2?v$T?!jU?3+jP6Tx&ELJH+6F&*2^ z8XzP+cIEo%9pF9=%j&FenTXn3yu2C4ipYYlfl@od)uCh@aP+W_%7UPga)72@Cszi8sxN%z z@9r6X^lSGbc)>7m%>ox$-t=1=yV(YyLAXC^*G4mdK+I6e!Zw~PY^^yQ?jSk{96RfG7%jszHX?pwzr(1Lir&jL%f{I36Mk1+& zqEVru5y=>|$*U-|FMHYRu6_0!_YFVx_xGXV`KxMUDr_2j6|-Nz^Y-Y*9Uldiy{3#7 zk@E?IjH54nT&kQv8O3H&xuG0m##2A`c-sYNLzxtfm>Y^-s$8M#6JtMN_M0lk zzA_F!_rxm+WTNbC*_scKGkP*+@YF3^^*@3%l%dHXi zSN6l$TlVL|RxY5?{WCR(1X+@);XER&m&UqHUFS4mTmOmwJkw`;)9ydC3syaimI7K1 zuJVFr_(Fho9*qNh-}%BzxGa_Ts#bR3KC*d3C%bYxdybvicmfpy;tW2s$`$S+FxF%7 z>m79w5UQx#i65Rx+upQ~|Jg2(@3Epm6RKPhpjoM<88lNFj1MK*$ZPzc|M+JqQZ4<5`kl_<`6y+z#>vPt1bd!JJJNYu-e}G zuZff^;2wN>Pcy5W5DpGk3N**=WMR;_e4Dx7rKqH|`9}Zou|ynIt$5&8x2%q;=@xgg zeMAMUSqF@<;<{>&IX~Zx%31c;Sp*FE&ts!Y?NJP|;Of8OWU@A%# z9fDO#M>ydUCg)sEa_Hnw&JLCoY5Lv2+6z9n3SF5ZktMX-bSh_>7)wlxj_7l0ipxm0 zqh13r$%+!+_{2}sHLhwoYg*YvLUkeQ%1mehVBAI>i-}UY>mm?ED4nTX?lrNKFP!c1 zB((dfzPllUbTmi-lCpuul^NVo@X4XOVO({ml3$O$kggR~GbkLXYp8Po6m768UHu5_h$38l&%6*8}k~roXPy?cv*K$%xhtoj&rZk&6JT1$A+{8 zrm>H*X)L5}#J+A?4tHT$Fy>!4EvMj&aL2H@1`h1`XI_V4R2>Y2v{TTiL(rr`pc&vc zK@}308KDxD(U2?9G)o3p)s7NKmQ${^ukA7p7=dyErKq-xK(Revlwd?ZSlO5)NEQO< zct&{tsZr;!6$KpFe|+V2=*wzVf(dq@WC&i%Ix|0r|qAt|S1JE=JQpdTbYDbAr(`QO@BZcmn3Jyny>{{gtxsP&zX*nX5 z)48oF_Fu>8m(ItExo1)-H~i?IzncE{CtvMh6}N|KE~Xu51!sP{2TiIBXh8$ccU8+| zGNrqb1yoKrf31k~jV_^Fpp9mkpHe1qwzZvp`Fzn$3C|*IpL_OWk?gb{Wt?2WkiTum zwj>xaBAKI_R%ONrqo~Y_Pac2>WwRRU@AUf*OCgCgu4r`G=N6!ja6-j1w#6OcFzUZ& z;9?&UP*rM@mJdNv&J1NnxWlhQZz}RUKV90MUy;Nv>*jc{BNrj3Z57} zH`z(B#P~Ydd`(%laa9WkN(x6KW#LS3RT1NC*Xjjqo&u-Ycfb5{AFJSP5KwUhn#6$? z5`Y#GfEE&f7Scl{D)V>-SEby^u-|Ppx4{xwmP>%rX~^HpE&`$J3D39V>>H`Bv#+N- z>;9=L$8-4CFDjEi+y3a=zjKg*)vF(esdp!HW6=tfiPoz#mYJ79CZvq>lE*7h#i2i~ zx8k62K@*x^*C}6X1*uCu>?_ATlgjx({^d_!8e|`+H5c%o6NWV@DOJ(uXYrolqFfw%Jjkm^?jm{OTC@8&9IU#66>|pTX z+wb{47PhWZ59?c>8;!AD>`O=BSHzQa~=pfY<2+>+hB=$EDW;f zq8HTS`dp#A^Aqg&@TF6iW0kyRQl+2SKXfHx-|S4l34~EFZW_TFM4G)vEvpELYk8SUrdcUL6uvOHhwDhHZ z{6HAi|7K8i=a)PV1Z2E*Fqbmn)8Fb=-OF&$o>fC*LoRK7Ns~{B=aW()PtCFSCq9S; z?Vbg8^5l~*JlL#2Cn@e#&?;L?1!j8p1Kp2nF_rEV%Yzx=dC=l)e~gvHf0_EQ zDpFlk1#ldAP{}&g3Kyx++bDO0vTDG~0Y^&gb8`wMq&0-#2c^egne6|&_^YZ*>O@JE z8iOya!nfaN_y!qphXQgNDheJCTv=ufkerH*0$^7zU#2B9H9kOjvXgez{X*r3wCO9 z=GsinEgem3>BDmB*RBHSwlby|aIIDJk>T?~A6|Dkfy;rG-KXF_vHhqDSnB%p^#nny zJ>YEGemfm1U=SjdfdNmFqy%fnLFTd~H|bwk4{h;g6hz*P%)kmsWdYY7^fbMRo!om}KLg0~P2%|GxEI5adL!9(EC z&^)lH3KIf{h87_3#_$%Qq4^6Q8XB69;Gv
- + +
+
diff --git a/spiro/webui.py b/spiro/webui.py index d6760bb..7ecf249 100644 --- a/spiro/webui.py +++ b/spiro/webui.py @@ -335,6 +335,17 @@ def nextposition(): return redirect(url_for('index')) +@not_while_running +@app.route('/motor/off') +def motor_off(): + lock.acquire() + try: + hw.motorOn(False) + finally: + lock.release() + return redirect(url_for('index')) + + @not_while_running @app.route('/findstart') @app.route('/findstart/') From 7af4e60cd1fb6eea5c0cab89cb8e902eed33c9b3 Mon Sep 17 00:00:00 2001 From: Kirby Kalbaugh Date: Wed, 1 Apr 2026 16:58:24 -0400 Subject: [PATCH 3/6] setup for octomania --- spiro/config.py | 1 + spiro/experimenter.py | 6 +-- spiro/hwcontrol.py | 2 + spiro/static/rotate.svg | 73 ++++++++++++++++++++++++++ spiro/static/spiro.css | 24 ++++++++- spiro/static/spiro.js | 17 ++++++ spiro/templates/index.html | 6 ++- spiro/templates/settings.html | 99 +++++++++++++++++++++++++++++++++-- spiro/timeutils.py | 69 ++++++++++++++++++++++++ spiro/webui.py | 67 ++++++++++++++++++++++-- 10 files changed, 350 insertions(+), 14 deletions(-) create mode 100644 spiro/static/rotate.svg create mode 100644 spiro/timeutils.py diff --git a/spiro/config.py b/spiro/config.py index a7bad29..7eea0a4 100644 --- a/spiro/config.py +++ b/spiro/config.py @@ -53,6 +53,7 @@ class Config(object): 'nightshutter': 10, # night exposure time 'nightiso': 400, # night iso 'name': 'spiro', # the name of this spiro instance + 'timezone': 'America/New_York', # user display/save timezone; device clock remains UTC 'debug': True, # debug logging 'rotated_camera': True # rotated camera house } diff --git a/spiro/experimenter.py b/spiro/experimenter.py index 5f0a568..0092757 100644 --- a/spiro/experimenter.py +++ b/spiro/experimenter.py @@ -8,11 +8,11 @@ import numpy as np from PIL import Image from io import BytesIO -from datetime import date from statistics import mean from collections import deque from spiro.config import Config from spiro.logger import log, debug +from spiro.timeutils import dated_folder_prefix, filename_timestamp class Experimenter(threading.Thread): def __init__(self, hw=None, cam=None): @@ -59,7 +59,7 @@ def stop(self): def getDefName(self): '''returns a default experiment name''' - today = date.today().strftime('%Y.%m.%d') + today = dated_folder_prefix(self.cfg.get('timezone')) return today + ' ' + self.cfg.get('name') @@ -264,7 +264,7 @@ def runExperiment(self): # wait for the cube to stabilize time.sleep(1.5) - now = time.strftime("%Y%m%d-%H%M%S", time.localtime()) + now = filename_timestamp(self.cfg.get('timezone')) name = os.path.join("plate" + str(i + 1), "plate" + str(i + 1) + "-" + now) self.takePicture(name, i) diff --git a/spiro/hwcontrol.py b/spiro/hwcontrol.py index 8290edd..7c72717 100644 --- a/spiro/hwcontrol.py +++ b/spiro/hwcontrol.py @@ -32,6 +32,7 @@ def __init__(self): 'stdby' : self.cfg.get('stdby') } self.led = False + self.motor = False # defer actual GPIO setup into a separate robust initializer self.GPIOInit() @@ -169,6 +170,7 @@ def halfStep(self, steps, delay, keep_motor_on=False): def motorOn(self, value): if self._valid_pin(self.pins.get('stdby')): gpio.output(self.pins['stdby'], value) + self.motor = bool(value) # turns on and off led def LEDControl(self, value): diff --git a/spiro/static/rotate.svg b/spiro/static/rotate.svg new file mode 100644 index 0000000..2a3b2a9 --- /dev/null +++ b/spiro/static/rotate.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/spiro/static/spiro.css b/spiro/static/spiro.css index 98621b5..f57a915 100644 --- a/spiro/static/spiro.css +++ b/spiro/static/spiro.css @@ -109,6 +109,28 @@ div.tool { font-family: 'Saira Condensed', sans-serif; font-size: 14pt; } +.settings-form { + max-width: 760px; +} +.settings-form .pure-control-group input { + width: 100%; + max-width: 360px; +} +.settings-form .pure-control-group label { + width: 180px; +} +.settings-actions { + display: flex; + gap: 20px; + align-items: center; + flex-wrap: wrap; +} +.settings-help { + max-width: 560px; +} +.settings-help .pure-form-message-inline { + padding-left: 0; +} div.expinfo { color: lightgrey; } @@ -224,7 +246,7 @@ legend.label { color: white; } a.passchange { - margin-top: 20px; + margin-top: 0; } ul.flashes { margin-left: 0px; diff --git a/spiro/static/spiro.js b/spiro/static/spiro.js index 77f2eea..360d8e1 100644 --- a/spiro/static/spiro.js +++ b/spiro/static/spiro.js @@ -63,3 +63,20 @@ function tryCalibration() { calib = document.getElementById('calibration'); bgGet('/findstart/' + calib.value); } + +function toggleMotor() { + var btn = document.getElementById('motorbtn'); + if (btn.dataset.state === 'on') { + bgGet('/motor/off'); + btn.dataset.state = 'off'; + btn.textContent = 'Motor Off'; + btn.classList.remove('red'); + btn.title = 'Enable stepper motors'; + } else { + bgGet('/motor/on'); + btn.dataset.state = 'on'; + btn.textContent = 'Motor On'; + btn.classList.add('red'); + btn.title = 'Disable stepper motors'; + } +} diff --git a/spiro/templates/index.html b/spiro/templates/index.html index 3871ae6..9466766 100644 --- a/spiro/templates/index.html +++ b/spiro/templates/index.html @@ -47,7 +47,11 @@
diff --git a/spiro/templates/settings.html b/spiro/templates/settings.html index 8857342..880cfe4 100644 --- a/spiro/templates/settings.html +++ b/spiro/templates/settings.html @@ -26,19 +26,64 @@
-
+
Settings -
+
- -
-
+ +
+
+ + + +{% for value, label in timezone_choices %} + +{% endfor %} + +
+ +
+Used for saved filenames and displayed dates. Device stays on UTC. Default is EST via America/New_York. +
+Time +
+
+Preferred timezone: +
+
+{{ timezone_name }} +
+
+
+
+Current local time: +
+
+{{ current_time }} +
+
+
+
+Device UTC time: +
+
+{{ current_utc_time }} +
+
+
+
+Time sync: +
+ +
System info
@@ -108,4 +153,48 @@
+ {% endblock %} diff --git a/spiro/timeutils.py b/spiro/timeutils.py new file mode 100644 index 0000000..1922f8b --- /dev/null +++ b/spiro/timeutils.py @@ -0,0 +1,69 @@ +from datetime import datetime, timezone +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + +DEFAULT_TIMEZONE = 'America/New_York' +TIMEZONE_ALIASES = { + 'EST': 'America/New_York', + 'EDT': 'America/New_York', + 'ET': 'America/New_York', + 'CST': 'America/Chicago', + 'CDT': 'America/Chicago', + 'CT': 'America/Chicago', + 'MST': 'America/Denver', + 'MDT': 'America/Denver', + 'MT': 'America/Denver', + 'PST': 'America/Los_Angeles', + 'PDT': 'America/Los_Angeles', + 'PT': 'America/Los_Angeles', + 'UTC': 'UTC', + 'GMT': 'UTC', +} +COMMON_TIMEZONES = [ + ('America/New_York', 'Eastern (EST/EDT)'), + ('America/Chicago', 'Central (CST/CDT)'), + ('America/Denver', 'Mountain (MST/MDT)'), + ('America/Los_Angeles', 'Pacific (PST/PDT)'), + ('UTC', 'UTC'), +] + + +def normalize_timezone_name(name): + if not name: + return DEFAULT_TIMEZONE + normalized = str(name).strip() + if not normalized: + return DEFAULT_TIMEZONE + return TIMEZONE_ALIASES.get(normalized.upper(), normalized) + + +def validate_timezone_name(name): + normalized = normalize_timezone_name(name) + try: + return ZoneInfo(normalized).key + except ZoneInfoNotFoundError as exc: + raise ValueError(f'Unknown timezone: {name}') from exc + + +def get_timezone(name=None): + return ZoneInfo(validate_timezone_name(name or DEFAULT_TIMEZONE)) + + +def now_in_timezone(name=None): + return datetime.now(timezone.utc).astimezone(get_timezone(name)) + + +def format_timestamp(timestamp, name=None, fmt='%Y-%m-%d %H:%M:%S %Z'): + return datetime.fromtimestamp(timestamp, timezone.utc).astimezone(get_timezone(name)).strftime(fmt) + + +def format_now(name=None, fmt='%Y-%m-%d %H:%M:%S %Z'): + return now_in_timezone(name).strftime(fmt) + + +def filename_timestamp(name=None): + return now_in_timezone(name).strftime('%Y%m%d-%H%M%S') + + +def dated_folder_prefix(name=None): + return now_in_timezone(name).strftime('%Y.%m.%d') \ No newline at end of file diff --git a/spiro/webui.py b/spiro/webui.py index 7ecf249..008b877 100644 --- a/spiro/webui.py +++ b/spiro/webui.py @@ -19,6 +19,7 @@ from spiro.config import Config from spiro.logger import log, debug from spiro.experimenter import Experimenter +from spiro.timeutils import COMMON_TIMEZONES, format_timestamp, now_in_timezone, validate_timezone_name app = Flask(__name__) app.jinja_env.trim_blocks = True @@ -192,7 +193,7 @@ def index(): if restarting: return render_template('restarting.html', refresh='60; url=/', message="Rebooting system...") # pass saved zoom settings to template so UI reflects persisted state - return render_template('index.html', live=livestream, focus=cfg.get('focus'), led=hw.led, name=cfg.get('name'), zoom=cfg.get('zoom')) + return render_template('index.html', live=livestream, focus=cfg.get('focus'), led=hw.led, motor=hw.motor, name=cfg.get('name'), zoom=cfg.get('zoom')) @app.route('/empty') @@ -346,6 +347,17 @@ def motor_off(): return redirect(url_for('index')) +@not_while_running +@app.route('/motor/on') +def motor_on(): + lock.acquire() + try: + hw.motorOn(True) + finally: + lock.release() + return redirect(url_for('index')) + + @not_while_running @app.route('/findstart') @app.route('/findstart/') @@ -491,9 +503,10 @@ def experiment(): diskspace = round(df.free / 1024 ** 3, 1) diskreq = round(experimenter.nshots * experimenter.position_count * 8 / 1024, 1) + timezone_name = cfg.get('timezone') return render_template('experiment.html', running=experimenter.running, directory=experimenter.dir, - starttime=time.ctime(experimenter.starttime), delay=experimenter.delay, - endtime=time.ctime(experimenter.endtime), diskspace=diskspace, duration=experimenter.duration, + starttime=format_timestamp(experimenter.starttime, timezone_name), delay=experimenter.delay, + endtime=format_timestamp(experimenter.endtime, timezone_name), diskspace=diskspace, duration=experimenter.duration, status=experimenter.status, nshots=experimenter.nshots + 1, diskreq=diskreq, position_count=experimenter.position_count, name=cfg.get('name'), defname=experimenter.getDefName()) @@ -619,10 +632,56 @@ def settings(): if request.method == 'POST': if request.form.get('name'): cfg.set('name', request.form.get('name')) + timezone_name = request.form.get('timezone') + if timezone_name: + try: + cfg.set('timezone', validate_timezone_name(timezone_name)) + flash('Timezone preference saved. Device time remains UTC.') + except ValueError as exc: + flash(str(exc)) ssid, passwd = hostapd.get_ssid() + timezone_name = cfg.get('timezone') + current_time = now_in_timezone(timezone_name) + current_utc_time = now_in_timezone('UTC') + timezone_choices = list(COMMON_TIMEZONES) + if not any(choice[0] == timezone_name for choice in timezone_choices): + timezone_choices.append((timezone_name, timezone_name)) return render_template('settings.html', name=cfg.get('name'), running=experimenter.running, version=cfg.version, debug=cfg.get('debug'), ip_addr=get_external_ip(), hotspot_ready=hostapd.is_ready(), - hotspot_enabled=hostapd.is_enabled(), ssid=ssid, passwd=passwd, rotation=cfg.get('rotated_camera')) + hotspot_enabled=hostapd.is_enabled(), ssid=ssid, passwd=passwd, rotation=cfg.get('rotated_camera'), + timezone_name=timezone_name, timezone_choices=timezone_choices, + current_time=current_time.strftime('%Y-%m-%d %H:%M:%S %Z'), + current_time_utc_iso=current_utc_time.isoformat(), + current_utc_time=current_utc_time.strftime('%Y-%m-%d %H:%M:%S %Z')) + + +@app.route('/sync-time') +def sync_time(): + commands = [ + ['sudo', 'timedatectl', 'set-timezone', 'UTC'], + ['sudo', 'chronyc', '-a', 'makestep'], + ['sudo', 'ntpdate', '-u', 'pool.ntp.org'], + ['sudo', 'sntp', '-sS', 'pool.ntp.org'], + ] + errors = [] + + timezone_result = subprocess.run(commands[0], capture_output=True, text=True) + if timezone_result.returncode != 0: + errors.append(timezone_result.stderr.strip() or timezone_result.stdout.strip()) + + for command in commands[1:]: + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode == 0: + flash('System time synchronized with a public NTP server. Device timezone remains UTC.') + return redirect(url_for('settings')) + errors.append(result.stderr.strip() or result.stdout.strip()) + + details = '; '.join(error for error in errors if error) + if details: + flash('Time sync failed: ' + details) + else: + flash('Time sync failed.') + return redirect(url_for('settings')) @not_while_running From 699a71607278d7b7cdc509f62c5c6634b0e417f2 Mon Sep 17 00:00:00 2001 From: Kirby Kalbaugh Date: Thu, 2 Apr 2026 12:23:15 -0400 Subject: [PATCH 4/6] fixes --- bin/spiro | 3 +- setup.py | 16 +- spiro/__init__.py | 6 + spiro/_static_version.py | 8 + spiro/_version.py | 21 ++ spiro/camera.py | 89 +++++++- spiro/config.py | 37 +++- spiro/experimenter.py | 214 +++++++++++++----- spiro/failsafe.py | 14 +- spiro/hostapd.py | 15 +- spiro/hwcontrol.py | 188 ++++++++++++++-- spiro/logger.py | 11 +- spiro/spiro.py | 18 +- spiro/static/spiro.css | 120 ++++++++++- spiro/static/spiro.js | 237 +++++++++++++++++++- spiro/templates/calibrate.html | 24 ++- spiro/templates/index.html | 39 +++- spiro/timeutils.py | 14 ++ spiro/webui.py | 382 +++++++++++++++++++++++++++++---- 19 files changed, 1297 insertions(+), 159 deletions(-) diff --git a/bin/spiro b/bin/spiro index 7fa9bb2..4983068 100644 --- a/bin/spiro +++ b/bin/spiro @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -# -# small shell script wrapper for spiro +"""Thin executable wrapper that forwards to the packaged SPIRO entry point.""" import spiro.spiro spiro.spiro.main() diff --git a/setup.py b/setup.py index 64d7a43..c2d952c 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,17 @@ +"""Package metadata and installation configuration for SPIRO. + +The setup script reads version information directly from the local version +helper instead of importing the full package. That keeps installation safe on +systems where Raspberry Pi hardware libraries are unavailable at build time. +""" + from setuptools import setup, find_packages -# from miniver def get_version_and_cmdclass(package_path): - """Load version.py module without importing the whole package. + """Load the version helper without importing the full package tree. - Template code from miniver + SPIRO imports hardware-related modules at runtime, so setup must avoid a + normal package import here. """ import os from importlib.util import module_from_spec, spec_from_file_location @@ -16,7 +23,8 @@ def get_version_and_cmdclass(package_path): version, cmdclass = get_version_and_cmdclass("spiro") -#version = "0.1.0" +# Preserve the current packaging behavior by leaving custom build command +# overrides disabled unless they are intentionally re-enabled later. cmdclass = {} setup(name = 'spiro', diff --git a/spiro/__init__.py b/spiro/__init__.py index 7df9f7a..ffab215 100644 --- a/spiro/__init__.py +++ b/spiro/__init__.py @@ -1 +1,7 @@ +"""SPIRO package namespace. + +Only lightweight package metadata is exposed here so callers can query the +installed version without importing the camera or GPIO subsystems. +""" + from ._version import __version__ \ No newline at end of file diff --git a/spiro/_static_version.py b/spiro/_static_version.py index 5557f9b..830c019 100644 --- a/spiro/_static_version.py +++ b/spiro/_static_version.py @@ -1,4 +1,12 @@ # -*- coding: utf-8 -*- +"""Static version markers used by the bundled miniver helper. + +In a live git checkout this file usually keeps the ``__use_git__`` sentinel so +the runtime version can be derived from tags. When a source or wheel +distribution is built, setup rewrites the file with a fixed version string so +the installed package no longer depends on git metadata being present. +""" + # This file is part of 'miniver': https://github.com/jbweston/miniver # # This file will be overwritten by setup.py when a source or binary diff --git a/spiro/_version.py b/spiro/_version.py index eb807f4..3bf4c7e 100644 --- a/spiro/_version.py +++ b/spiro/_version.py @@ -1,4 +1,10 @@ # -*- coding: utf-8 -*- +"""Version resolution helpers vendored from miniver. + +SPIRO uses these utilities to produce a stable package version from git tags in +development and from frozen metadata in release artifacts. +""" + # This file is part of 'miniver': https://github.com/jbweston/miniver # from collections import namedtuple @@ -28,6 +34,11 @@ def get_version(version_file=STATIC_VERSION_FILE): + """Resolve the current package version as a string. + + The lookup order is: static version file, live git metadata, archive + placeholders, and finally an ``unknown`` fallback. + """ version_info = get_static_version_info(version_file) version = version_info["version"] if version == "__use_git__": @@ -42,6 +53,7 @@ def get_version(version_file=STATIC_VERSION_FILE): def get_static_version_info(version_file=STATIC_VERSION_FILE): + """Load the static version helper into an isolated namespace.""" version_info = {} with open(os.path.join(package_root, version_file), "rb") as f: exec(f.read(), {}, version_info) @@ -49,10 +61,12 @@ def get_static_version_info(version_file=STATIC_VERSION_FILE): def version_is_from_git(version_file=STATIC_VERSION_FILE): + """Return whether version resolution is delegated to git metadata.""" return get_static_version_info(version_file)["version"] == "__use_git__" def pep440_format(version_info): + """Convert a ``Version`` tuple into a PEP 440 compliant version string.""" release, dev, labels = version_info version_parts = [release] @@ -70,6 +84,7 @@ def pep440_format(version_info): def get_version_from_git(): + """Derive a version from ``git describe`` when running from a checkout.""" try: p = subprocess.Popen( ["git", "rev-parse", "--show-toplevel"], @@ -145,6 +160,7 @@ def get_version_from_git(): # pointing to, or its hash (with no version info) # if it is not tagged. def get_version_from_git_archive(version_info): + """Derive a version from ``git archive`` placeholders when available.""" try: refnames = version_info["refnames"] git_hash = version_info["git_hash"] @@ -176,6 +192,7 @@ def get_version_from_git_archive(version_info): def _write_version(fname): + """Write the resolved version into the generated static version file.""" # This could be a hard link, so try to delete it first. Is there any way # to do this atomically together with opening? try: @@ -190,12 +207,16 @@ def _write_version(fname): class _build_py(build_py_orig): + """Build hook that injects a fixed static version into build outputs.""" + def run(self): super().run() _write_version(os.path.join(self.build_lib, package_name, STATIC_VERSION_FILE)) class _sdist(sdist_orig): + """sdist hook that writes frozen version metadata into release archives.""" + def make_release_tree(self, base_dir, files): super().make_release_tree(base_dir, files) if _package_root_inside_src: diff --git a/spiro/camera.py b/spiro/camera.py index 8609ec4..8b257ba 100644 --- a/spiro/camera.py +++ b/spiro/camera.py @@ -1,16 +1,30 @@ +"""Camera wrapper used by the rest of the SPIRO application. + +The web UI and experiment runner both depend on a small, stable API for live +streaming, still capture, focus, exposure, and digital zoom. This wrapper hides +the Picamera2/libcamera details and keeps enough state to reapply view controls +after camera mode switches. +""" + import time from spiro.logger import log, debug from spiro.config import Config class NewCamera: + """Thin stateful adapter around Picamera2 for SPIRO's workflows.""" + def __init__(self): debug('Libcamera detected.') self.camera = Picamera2() self.type = 'libcamera' self.streaming = False self.stream_output = None + self.current_zoom = None + self.current_focus = None # self.preview_mode = None + # SPIRO uses a high-resolution still pipeline for saved images and a + # smaller video pipeline for the MJPEG live view. self.still_config = self.camera.create_still_configuration(main={"size": (4656, 3496)}, lores={"size": (320, 240)}, raw = None) self.video_config = self.camera.create_video_configuration(main={"size": (1640,1232)}) # original 1024,768 @@ -33,6 +47,7 @@ def __init__(self): "AfMode": controls.AfModeEnum.Manual, "LensPosition": (self.lens_limits[2] if self.lens_limits and len(self.lens_limits) > 2 else 0) }) + self.current_focus = (self.lens_limits[2] if self.lens_limits and len(self.lens_limits) > 2 else 0) except Exception: # be lenient if controls or enums are not available pass @@ -42,13 +57,49 @@ def __init__(self): except Exception: pass + def _apply_saved_view_controls(self): + """Reapply the last requested crop and focus controls to the camera. + + Picamera2 can lose these controls when switching between video and still + configurations, so the wrapper keeps its own copy instead of assuming + the camera will preserve them internally. + """ + controls_to_apply = {} + if self.current_zoom is not None: + try: + x, y, w, h = self.current_zoom + (resx, resy) = self.camera.camera_properties.get('PixelArraySize', (0, 0)) + controls_to_apply['ScalerCrop'] = (int(x * resx), int(y * resy), int(w * resx), int(h * resy)) + except Exception: + debug('Failed to prepare saved zoom', exc_info=True) + if self.current_focus is not None: + controls_to_apply['LensPosition'] = self.current_focus + + if controls_to_apply: + try: + self.camera.set_controls(controls_to_apply) + except Exception: + debug('Failed to restore saved view controls', exc_info=True) + + def _reapply_view_controls_after_mode_switch(self, settle_seconds=0.08): + """Reapply controls twice after a mode switch to work around racey writes.""" + # Picamera2 can ignore the first control write immediately after switch_mode. + # Apply controls twice with a short settle delay to make crop/focus stick. + self._apply_saved_view_controls() + time.sleep(settle_seconds) + self._apply_saved_view_controls() + def start_stream(self, output): + """Switch into video mode and start the MJPEG live stream.""" log('Starting stream.') try: + if self.streaming: + self.stop_stream() self.stream_output = output - self.streaming = True self.camera.switch_mode(self.video_config) + self._reapply_view_controls_after_mode_switch() self.camera.start_recording(MJPEGEncoder(), FileOutput(output)) + self.streaming = True except Exception: debug('Failed to start stream', exc_info=True) @@ -77,13 +128,19 @@ def start_stream(self, output): def stop_stream(self): - # intentionally a no-op for libcamera in this design - pass + """Stop the MJPEG stream if it is currently active.""" + if not self.streaming: + return + try: + self.camera.stop_recording() + except Exception: + debug('Failed to stop stream', exc_info=True) + finally: + self.streaming = False @property def zoom(self): - # not implemented for libcamera wrapper - return None + return self.current_zoom @zoom.setter def zoom(self, value): @@ -94,30 +151,42 @@ def zoom(self, value): raise ValueError('zoom setter expects a tuple (x, y, w, h)') try: + self.current_zoom = (x, y, w, h) (resx, resy) = self.camera.camera_properties.get('PixelArraySize', (0, 0)) self.camera.set_controls({"ScalerCrop": (int(x * resx), int(y * resy), int(w * resx), int(h * resy))}) except Exception: debug('Failed to set zoom', exc_info=True) def auto_exposure(self, value): + """Enable or disable the camera's automatic exposure loop.""" try: self.camera.set_controls({'AeEnable': value}) except Exception: debug('Failed to set auto_exposure', exc_info=True) def capture(self, obj, format='png'): + """Capture a full-resolution still image into the provided file-like object. + + Live recording is stopped before entering still mode because switching + libcamera pipelines while the MJPEG encoder is active can fail or leave + the encoder in a bad state. + """ stream = self.streaming + output = self.stream_output log('Capturing image.') try: + if stream: + self.stop_stream() self.camera.switch_mode(self.still_config) + self._reapply_view_controls_after_mode_switch() self.camera.capture_file(obj, format=format) log('Ok.') except Exception: debug('Capture failed', exc_info=True) if stream: - self.start_stream(self.stream_output) + self.start_stream(output) @property def shutter_speed(self): @@ -148,20 +217,25 @@ def iso(self, value): debug('Failed to set iso', exc_info=True) def close(self): + """Close the underlying camera device.""" try: self.camera.close() except Exception: pass def still_mode(self): + """Switch to the still-photo configuration and restore crop/focus.""" try: self.camera.switch_mode(self.still_config) + self._reapply_view_controls_after_mode_switch() except Exception: pass def video_mode(self): + """Switch to the live-view configuration and restore crop/focus.""" try: self.camera.switch_mode(self.video_config) + self._reapply_view_controls_after_mode_switch() except Exception: pass @@ -195,7 +269,9 @@ def awb_gains(self, gains): pass def focus(self, val): + """Set manual lens position and remember it for later mode switches.""" try: + self.current_focus = val self.camera.set_controls({'LensPosition': val}) except Exception: debug('Failed to set focus', exc_info=True) @@ -206,4 +282,5 @@ def focus(self, val): from picamera2.encoders import MJPEGEncoder from libcamera import controls +# The rest of the application imports a module-level camera singleton. cam = NewCamera() diff --git a/spiro/config.py b/spiro/config.py index 7eea0a4..095376b 100644 --- a/spiro/config.py +++ b/spiro/config.py @@ -1,6 +1,8 @@ -# config.py - -# handles getting/setting of persistent spiro config values -# +"""Persistent configuration storage for SPIRO. + +Settings are stored in ``~/.config/spiro/spiro.conf`` as JSON so both the web +UI and command-line entry points can share the same durable configuration. +""" import json import os @@ -9,6 +11,7 @@ from ._version import __version__ def log(msg): + """Write config-related warnings directly to stderr.""" sys.stderr.write(msg + '\n') sys.stderr.flush() @@ -31,8 +34,30 @@ def _resolve_version(): class Config(object): + """Load, cache, and persist SPIRO configuration values. + + ``defaults`` holds built-in values for a fresh install. ``config`` contains + only user overrides read from disk. + """ + defaults = { 'calibration' : 8, # number of steps taken after positional sensor is lit + 'motor_step_delay': 0.08, # seconds between motor half-steps; larger values rotate slower + # `motor_secondary_step_delay` controls the half-step delay used during the + # secondary (fine) stop-finding phase of homing. The homing routine first + # performs a coarse search using `motor_step_delay` to move the stage until + # the positional sensor is approached. If `secondary_stop_steps` is enabled + # (non-zero), the controller will then back off and perform a finer-grained + # search to precisely locate the mechanical stop using smaller, slower + # steps. This setting is measured in seconds per half-step (same units as + # `motor_step_delay`). Increasing the value makes the fine search slower + # and more tolerant of sensor bounce or mechanical slop; decreasing it + # makes the fine search faster but reduces tolerance for missed triggers. + # Typical usage: set `motor_secondary_step_delay` slightly larger than + # `motor_step_delay` (for example 1.25×–1.5×) so the fine-pass is calmer + # and more reliable. The default value (0.12) is a practical compromise. + # Note: this parameter is only used when `secondary_stop_steps` is > 0. + 'motor_secondary_step_delay': 0.12, 'position_count': 8, # number of capture positions around the stage 'secondary_stop_steps': 25, # steps between the coarse and fine stop trigger; set to 0 to disable 'secondary_home_steps': 25, # backwards-compatible alias for older configs @@ -61,6 +86,7 @@ class Config(object): config = {} def __init__(self): + """Initialize file locations, runtime version, and cached config state.""" self.cfgdir = os.path.expanduser("~/.config/spiro") self.cfgfile = os.path.join(self.cfgdir, "spiro.conf") self.version = _resolve_version() @@ -73,6 +99,7 @@ def __init__(self): def read(self): + """Read the JSON config file from disk if it exists.""" os.makedirs(self.cfgdir, exist_ok=True) if os.path.exists(self.cfgfile): try: @@ -83,6 +110,7 @@ def read(self): def write(self): + """Persist the current config using a temporary file then atomic replace.""" try: with open(self.cfgfile + ".tmp", 'w') as f: json.dump(self.config, f, indent=4) @@ -91,6 +119,7 @@ def write(self): log("Failed to write config file: " + e.strerror) def get(self, key): + """Return a config value, reloading from disk if another process updated it.""" if os.path.exists(self.cfgfile): st = os.stat(self.cfgfile) mt = st.st_mtime @@ -102,11 +131,13 @@ def get(self, key): def set(self, key, value): + """Store a user override and immediately persist it to disk.""" self.config[key] = value self.write() def unset(self, key): + """Remove a persisted override so future reads fall back to defaults.""" if key in self.config: del self.config[key] self.write() diff --git a/spiro/experimenter.py b/spiro/experimenter.py index 0092757..5887990 100644 --- a/spiro/experimenter.py +++ b/spiro/experimenter.py @@ -1,6 +1,9 @@ -# experimenter.py - -# this file handles running the actual experiments -# +"""Experiment scheduling and image capture workflow for SPIRO. + +The Experimenter thread owns long-running capture loops, creates output +directories, rotates the imaging stage, and ensures still images use the same +framing rules as the interactive live view. +""" import os import time @@ -15,6 +18,8 @@ from spiro.timeutils import dated_folder_prefix, filename_timestamp class Experimenter(threading.Thread): + """Background worker that executes imaging runs across all plate positions.""" + def __init__(self, hw=None, cam=None): self.hw = hw self.cam = cam @@ -41,16 +46,19 @@ def __init__(self, hw=None, cam=None): def _get_position_count(self): + """Return the configured number of capture positions around the stage.""" return max(1, int(self.cfg.get('position_count') or 8)) def _reset_position_buffers(self): + """Resize preview and last-capture buffers when position count changes.""" self.position_count = self._get_position_count() self.last_captured = [''] * self.position_count self.preview = [''] * self.position_count def stop(self): + """Request that the currently running experiment stop at the next safe point.""" self.status = "Stopping" self.next_status = '' self.stop_experiment = True @@ -80,12 +88,20 @@ def isDaytime(self): # XXX: clean this up self.cam.shutter_speed = 1000000 // self.cfg.get('dayshutter') time.sleep(1.0) - output = self.cam.camera.capture_array('lores') + try: + output = self.cam.camera.capture_array('lores') + except RuntimeError as e: + debug(f"Falling back to main stream for daytime estimation: {e}") + try: + output = self.cam.camera.capture_array('main') + except RuntimeError: + output = self.cam.camera.capture_array() debug("Daytime estimation mean value: " + str(output.mean())) return output.mean() > 60 def setWB(self): + """Let auto white balance settle, then freeze the chosen gains.""" debug("Determining white balance.") self.cam.awb_mode = "auto" time.sleep(2) @@ -94,67 +110,171 @@ def setWB(self): self.cam.awb_gains = g - def takePicture(self, name, plate_no, output = None): - filename = "" + def _apply_view_crop(self, im, view_state, capture_tag='still'): + """Apply the live-view zoom/pan framing to a full-resolution still image. + + libcamera can drop hardware crop state during a mode change, so SPIRO + applies one deterministic software crop here to keep preview and saved + stills aligned with the operator's live view. + """ + if not view_state: + debug(f"[{capture_tag}] crop skipped: no view_state") + return im + + zoom_state = view_state.get('zoom') or {} + try: + # Match live-view crop semantics from ZoomObject.apply(), which maps + # left/top as (y, x) for camera zoom control. + cx = float(zoom_state.get('y', 0.5)) + cy = float(zoom_state.get('x', 0.5)) + roi = float(zoom_state.get('roi', 1.0)) + except Exception: + debug(f"[{capture_tag}] crop skipped: invalid zoom state {zoom_state}") + return im + + requested = {'x': cx, 'y': cy, 'roi': roi} + roi = max(0.2, min(1.0, roi)) + cx = max(roi / 2.0, min(1.0 - roi / 2.0, cx)) + cy = max(roi / 2.0, min(1.0 - roi / 2.0, cy)) + + width, height = im.size + crop_w = max(1, int(round(width * roi))) + crop_h = max(1, int(round(height * roi))) + + left = int(round((cx - roi / 2.0) * width)) + top = int(round((cy - roi / 2.0) * height)) + left = max(0, min(width - crop_w, left)) + top = max(0, min(height - crop_h, top)) + + focus_value = view_state.get('focus') if isinstance(view_state, dict) else None + debug( + f"[{capture_tag}] crop calc " + f"req(x={requested['x']:.6f}, y={requested['y']:.6f}, roi={requested['roi']:.6f}) " + f"norm(x={cx:.6f}, y={cy:.6f}, roi={roi:.6f}) " + f"img={width}x{height} box=({left},{top})-({left + crop_w},{top + crop_h}) " + f"size={crop_w}x{crop_h} focus={focus_value}" + ) + + # Crop first, then resize back to the original output dimensions so the + # saved image keeps the expected resolution while matching live framing. + cropped = im.crop((left, top, left + crop_w, top + crop_h)) + if crop_w == width and crop_h == height: + debug(f"[{capture_tag}] crop result: full-frame (no resize)") + return im + + resampling = getattr(getattr(Image, 'Resampling', Image), 'LANCZOS', Image.LANCZOS) + return cropped.resize((width, height), resample=resampling) + + + def capture_processed_still(self, view_state=None, capture_tag='still'): + """Capture a still image with experiment settings and optional live framing. + + The method decides day vs night exposure, optionally restores the live + focus value, captures a full-resolution frame, applies the deterministic + crop, and finally rotates the image when the camera housing is mounted + sideways. + """ stream = BytesIO() prev_daytime = self.daytime self.daytime = self.isDaytime() - + + if view_state: + try: + focus_value = view_state.get('focus') + if focus_value is not None: + self.hw.focusCam(int(focus_value)) + except Exception as e: + debug(f"Failed to apply live view state to still capture: {e}") + if self.daytime: + # Day images use ambient light and the daytime exposure profile. time.sleep(0.5) self.cam.shutter_speed = 1000000 // self.cfg.get('dayshutter') self.cam.iso = self.cfg.get('dayiso') self.cam.color_effects = None - filename = os.path.join(self.dir, name + "-day.png") else: - # turn on led + # Night images turn on the LED and use the longer exposure profile. self.hw.LEDControl(True) time.sleep(1.0) self.cam.shutter_speed = 1000000 // self.cfg.get('nightshutter') self.cam.iso = self.cfg.get('nightiso') - filename = os.path.join(self.dir, name + "-night.png") - + if prev_daytime != self.daytime and self.daytime and self.cam.awb_mode != "off": - # if there is a daytime shift, AND it is daytime, AND white balance was not previously set, - # set the white balance to a fixed value. - # thus, white balance will only be fixed for the first occurence of daylight. self.setWB() - debug("Capturing %s." % filename) self.cam.exposure_mode = "off" if self.cam.type == 'legacy': self.cam.capture(stream, format='rgb') + if self.cam.resolution[0] == 3280: + raw_res = (3296, 2464) + elif self.cam.resolution[0] == 2592: + raw_res = (2592, 1952) + else: + debug('Camera has unsupported resolution ' + str(self.cam.resolution) + '! This may lead to crashes or corrupted images.') + raw_res = tuple(self.cam.resolution) + stream.seek(0) + im = Image.frombytes('RGB', raw_res, stream.read()).crop(box=(0,0)+self.cam.resolution) else: - # XXX - stream.write(self.cam.camera.capture_array('main')) + # Capture full-frame stills and apply one deterministic software crop + # below. This avoids still-mode crop drift across libcamera + # reconfiguration and makes preview/test logs reproducible. + original_zoom = getattr(self.cam, 'current_zoom', None) + try: + if hasattr(self.cam, 'current_zoom'): + self.cam.current_zoom = None + self.cam.capture(stream, format='png') + finally: + if hasattr(self.cam, 'current_zoom'): + self.cam.current_zoom = original_zoom + stream.seek(0) + im = Image.open(stream).convert('RGB') + + im = self._apply_view_crop(im, view_state, capture_tag=capture_tag) - # turn off LED immediately after capture if not self.daytime: self.hw.LEDControl(False) - # convert to PNG using PIL - # saving as 'RGB' using picamera adds a border which needs to be cropped away - # the raw capture size depends on the type of camera used - # we only support 5 MP (OV5647) and 8 MP (IMX219) cameras, for now at least - if self.cam.resolution[0] == 3280: - raw_res = (3296, 2464) - elif self.cam.resolution[0] == 2592: - raw_res = (2592, 1952) - else: - # unsupported resolution, try to make the best of it - debug('Camera has unsupported resolution ' + str(self.cam.resolution) + '! This may lead to crashes or corrupted images.') - raw_res = tuple(self.cam.resolution) - stream.seek(0) - im = Image.frombytes('RGB', raw_res, stream.read()).crop(box=(0,0)+self.cam.resolution) - - # if configured, rotate saved stills clockwise 90 degrees (do not affect camera config) try: if self.cfg.get('rotated_camera'): - im = im.transpose(Image.ROTATE_270) # clockwise 90° + im = im.transpose(Image.ROTATE_270) debug('Rotated still image 90° clockwise before saving.') except Exception as e: debug(f'Failed to rotate still image: {e}') + finally: + self.cam.color_effects = None + self.cam.shutter_speed = 0 + self.cam.exposure_mode = "auto" + + return im, self.daytime + + + def takePicture(self, name, plate_no, output = None): + """Capture, save, and thumbnail one plate image for the current cycle.""" + filename = "" + view_state = None + if hasattr(self, 'custom_settings'): + # custom_settings stores the camera tuple used by the live-view code. + # Convert it back into the normalized center/roi structure expected + # by the still-capture crop helper. + view_state = { + 'zoom': { + 'x': self.custom_settings['initial_zoom'][1] + self.custom_settings['initial_zoom'][2] / 2.0, + 'y': self.custom_settings['initial_zoom'][0] + self.custom_settings['initial_zoom'][2] / 2.0, + 'roi': self.custom_settings['initial_zoom'][2], + } + } + if 'initial_focus' in self.custom_settings: + view_state['focus'] = self.custom_settings['initial_focus'] + tag = f"experiment-plate{plate_no + 1}" + im, daytime = self.capture_processed_still(view_state=view_state, capture_tag=tag) + + if daytime: + filename = os.path.join(self.dir, name + "-day.png") + else: + filename = os.path.join(self.dir, name + "-night.png") + + debug("Capturing %s." % filename) im.save(filename) @@ -167,10 +287,6 @@ def takePicture(self, name, plate_no, output = None): im.close() self.last_captured[plate_no] = filename - self.cam.color_effects = None - self.cam.shutter_speed = 0 - # we leave the cam in auto exposure mode to improve daytime assessment performance - self.cam.exposure_mode = "auto" def run(self): @@ -200,15 +316,15 @@ def runExperiment(self): self.running = True ######################## if hasattr(self, 'custom_settings'): -# if 'initial_focus' in self.custom_settings: -# try: -# focus_val = self.custom_settings['initial_focus'] -# self.cam.focus(0) -# time.sleep(0.5) -# self.cam.focus(focus_val) -# self.cam.focus(focus_val) -# except Exception as e: -# debug(f"Failed to restore focus: {e}") + # Restore the operator-selected focus and zoom before the first + # capture so automated runs match the framing seen in the live UI. + if 'initial_focus' in self.custom_settings: + try: + focus_val = self.custom_settings['initial_focus'] + self.hw.focusCam(int(focus_val)) + debug(f"Restored focus to: {focus_val}") + except Exception as e: + debug(f"Failed to restore focus: {e}") if 'initial_zoom' in self.custom_settings: try: zoom_val = self.custom_settings['initial_zoom'] @@ -277,6 +393,8 @@ def runExperiment(self): if self.status != "Stopping": self.status = "Waiting" if self.idlepos > 0: + # Spread resting time across positions so the stage does not + # always sit at the same stop between capture rounds. # alternate between resting positions during idle using sensed stops self.hw.motorOn(True) for i in range(self.idlepos): diff --git a/spiro/failsafe.py b/spiro/failsafe.py index 5c64d23..c289964 100644 --- a/spiro/failsafe.py +++ b/spiro/failsafe.py @@ -1,6 +1,8 @@ -# failsafe.py - -# start debug web interface if main interface fails -# +"""Fallback web interface shown when the main UI fails to start. + +The failsafe app exposes the traceback and recent journal logs so a broken +deployment can still be diagnosed remotely from the browser. +""" import signal import traceback @@ -22,24 +24,28 @@ @app.route('/') def index(): + """Show the captured startup traceback in a lightweight diagnostic page.""" return render_template('failsafe_index.html', trace=''.join(traceback.format_tb(err.__traceback__)), version=cfg.version) @app.route('/log') def get_log(): + """Stream recent service logs for browser-based debugging.""" p = subprocess.Popen(['/bin/journalctl', '--user-unit=spiro', '-n', '1000'], stdout=subprocess.PIPE) return Response(stream_popen(p), mimetype='text/plain') @app.route('/shutdown') def shutdown(): + """Shut the device down from the failsafe UI.""" subprocess.run(['sudo', 'shutdown', '-h', 'now']) return render_template('shutdown.html') @app.route('/reboot') def reboot(): + """Reboot the device from the failsafe UI.""" global restarting restarting = True subprocess.Popen(['sudo', 'shutdown', '-r', 'now']) @@ -48,11 +54,13 @@ def reboot(): @app.route('/exit') def exit(): + """Ask the process manager to restart the main web UI process.""" signal.alarm(1) return render_template('restarting.html', refresh=10, message="Restarting web UI...") def start(e=None): + """Start the fallback UI and retain the triggering exception for display.""" global err err = e serve(app, listen="*:8080", threads=2, channel_timeout=20) diff --git a/spiro/hostapd.py b/spiro/hostapd.py index 3e9cc13..7ae65a9 100644 --- a/spiro/hostapd.py +++ b/spiro/hostapd.py @@ -1,7 +1,9 @@ -# hostapd.py - -# configure system as an AP directing all client queries to the web ui -# uses NetworkManager (nmcli) on Ubuntu Bookworm -# +"""Manage the optional SPIRO Wi-Fi hotspot using NetworkManager. + +This module configures a captive-portal-style access point that routes clients +to the local web UI without requiring the older standalone hostapd/dnsmasq +service stack. +""" import os import re @@ -197,6 +199,7 @@ def config_dnsmasq(ap_ip): def start_ap(): + """Create, configure, and activate the SPIRO hotspot profile.""" log("Setting up dependencies...") ok, err = install_reqs() if not ok: @@ -220,7 +223,8 @@ def start_ap(): _run_privileged(["nmcli", "connection", "modify", _CONNECTION_NAME, "connection.autoconnect", "no"]) - # Recreate profile each time with fallback subnets to avoid stale/bad state. + # Recreate the profile on each start so stale NetworkManager state or a bad + # subnet choice does not permanently wedge hotspot activation. activation_attempts = list(_AP_IP_CANDIDATES) started = False last_ssid = None @@ -286,6 +290,7 @@ def start_ap(): def stop_ap(): + """Deactivate the SPIRO hotspot without deleting the saved profile.""" log("Disabling hotspot...") _run_privileged_passthrough(["nmcli", "connection", "modify", _CONNECTION_NAME, "connection.autoconnect", "no"]) diff --git a/spiro/hwcontrol.py b/spiro/hwcontrol.py index 7c72717..6ab2405 100644 --- a/spiro/hwcontrol.py +++ b/spiro/hwcontrol.py @@ -1,11 +1,8 @@ -# hwcontrol.py - -# various functions for controlling the spiro hardware -# -# Hardware: -# - Raspberry Pi GPIO pins for motor control, LED, and positional switch -# - Stepper motor with 200 steps per revolution (1.8 degree step angle) -# - Adafruit DRV8833 DC/Stepper Motor Driver Breakout Board -# - ArduCam motorized focus camera (optional) +"""Low-level hardware control for the SPIRO platform. + +This module owns GPIO access for the LED, positional switch, and stepper motor, +and it also bridges UI focus values to the attached camera's manual focus API. +""" @@ -17,6 +14,8 @@ from spiro.logger import log, debug class HWControl: + """Hardware abstraction layer for motor, LED, switch, and focus control.""" + def __init__(self): gpio.setmode(gpio.BCM) self.cfg = Config() @@ -33,13 +32,36 @@ def __init__(self): } self.led = False self.motor = False + self.sensor_active = False + self.sensor_last_trip_time = 0.0 + self.sensor_trip_count = 0 + self.sensor_event_enabled = False # defer actual GPIO setup into a separate robust initializer self.GPIOInit() + def _step_delay(self): + """Return the configured coarse homing delay between half-steps.""" + value = self.cfg.get('motor_step_delay') + try: + return max(0.01, float(value or 0.05)) + except Exception: + return 0.05 + + def _sensor_event(self, channel): + """Track edge-detected switch activity for UI status reporting.""" + del channel + active = self._sensor_active() + self.sensor_active = active + if active: + self.sensor_last_trip_time = time.time() + self.sensor_trip_count += 1 + def _valid_pin(self, pin): + """Return whether a config value looks like a usable BCM pin number.""" return pin is not None and pin != '' and isinstance(pin, int) def GPIOInit(self): + """Configure GPIO pins and put outputs into a safe default state.""" gpio.setwarnings(False) # Only setup pins that are valid (protect against missing config values) @@ -47,6 +69,18 @@ def GPIOInit(self): gpio.setup(self.pins['LED'], gpio.OUT) if self._valid_pin(self.pins.get('sensor')): gpio.setup(self.pins['sensor'], gpio.IN, pull_up_down=gpio.PUD_DOWN) + try: + gpio.remove_event_detect(self.pins['sensor']) + except Exception: + pass + try: + gpio.add_event_detect(self.pins['sensor'], gpio.BOTH, callback=self._sensor_event, bouncetime=25) + self.sensor_event_enabled = True + except RuntimeError as e: + # Some Raspberry Pi OS/kernel combinations can reject edge-detect registration. + # Fall back to polled sensor status so SPIRO can still start and show switch state. + self.sensor_event_enabled = False + log('Warning: sensor edge detection unavailable; using polling fallback. ' + str(e)) if self._valid_pin(self.pins.get('PWMa')): gpio.setup(self.pins['PWMa'], gpio.OUT) gpio.output(self.pins['PWMa'], True) @@ -73,34 +107,83 @@ def GPIOInit(self): # Ensure LED and motor are in known state (will be no-op if pin invalid) self.LEDControl(False) self.motorOn(False) + self.sensor_active = self._sensor_active() def cleanup(self): + """Release all GPIO resources held by the process.""" gpio.cleanup() def _sensor_active(self): + """Return the current raw state of the positional switch.""" if not self._valid_pin(self.pins.get('sensor')): return False return bool(gpio.input(self.pins['sensor'])) def _secondary_stop_steps(self): + """Return the configured number of fine-search steps after coarse homing.""" value = self.cfg.get('secondary_stop_steps') if value is None: value = self.cfg.get('secondary_home_steps') return max(0, int(value or 0)) + def _secondary_step_delay(self): + """Return the delay used during the fine stop-finding pass. + + This mirrors the meaning of ``motor_secondary_step_delay`` from the + config: the coarse search uses ``motor_step_delay``, then the optional + secondary pass advances more cautiously so the switch can be reacquired + with less overshoot and less sensitivity to mechanical bounce. + """ + value = self.cfg.get('motor_secondary_step_delay') + if value is None: + return max(self._step_delay() * 2.5, self._step_delay() + 0.04) + try: + return max(0.02, float(value)) + except Exception: + return max(self._step_delay() * 2.5, self._step_delay() + 0.04) + def _clear_sensor(self, deadline, delay): + """Step forward until the positional switch is no longer active.""" while self._sensor_active() and time.time() < deadline: self.halfStep(1, delay) return not self._sensor_active() - def _step_until_sensor(self, expected_state, deadline, delay): - while self._valid_pin(self.pins.get('sensor')) and self._sensor_active() != expected_state and time.time() < deadline: + def _step_until_sensor(self, expected_state, deadline, delay, stable_reads=1, return_steps=False): + """Step until the sensor reaches the requested state for enough reads. + + ``stable_reads`` helps reject brief switch chatter by requiring the + target state to be observed repeatedly before the movement is considered + complete. + """ + matched = 0 + steps_taken = 0 + while self._valid_pin(self.pins.get('sensor')) and time.time() < deadline: + if self._sensor_active() == expected_state: + matched += 1 + if matched >= max(1, stable_reads): + if return_steps: + return True, steps_taken + return True + time.sleep(0.005) + continue + matched = 0 self.halfStep(1, delay) - return self._sensor_active() == expected_state - - def _find_stop(self, calibration=0, timeout=60, label='stop position'): - first_stop_delay = 0.03 - second_stop_delay = 0.06 + steps_taken += 1 + result = self._sensor_active() == expected_state + if return_steps: + return result, steps_taken + return result + + def _find_stop(self, calibration=0, timeout=60, label='stop position', ignore_initial_retrigger_steps=0): + """Find a sensed stop using coarse motion and an optional fine pass. + + The search first clears any existing trigger, moves forward using the + normal step delay until the switch is hit, and then optionally performs + a slower secondary approach using ``secondary_stop_steps`` and + ``motor_secondary_step_delay`` for more repeatable alignment. + """ + first_stop_delay = self._step_delay() + second_stop_delay = self._secondary_step_delay() deadline = time.time() + timeout secondary_stop_steps = self._secondary_stop_steps() @@ -111,11 +194,33 @@ def _find_stop(self, calibration=0, timeout=60, label='stop position'): log(f"Timed out while clearing {label}! Images will be misaligned.") return False - if not self._step_until_sensor(True, deadline, first_stop_delay): - log(f"Timed out while finding {label}! Images will be misaligned.") - return False + while True: + found_stop, steps_to_stop = self._step_until_sensor( + True, + deadline, + first_stop_delay, + stable_reads=2, + return_steps=True, + ) + if not found_stop: + log(f"Timed out while finding {label}! Images will be misaligned.") + return False + if steps_to_stop >= max(0, int(ignore_initial_retrigger_steps or 0)): + break + debug( + f"Ignoring early {label} trigger after {steps_to_stop} steps; " + f"waiting for next stop beyond {ignore_initial_retrigger_steps} steps." + ) + if not self._clear_sensor(deadline, first_stop_delay): + log(f"Timed out while clearing {label}! Images will be misaligned.") + return False if secondary_stop_steps > 0: + # The secondary pass deliberately backs out of the trigger and then + # creeps forward more slowly so the final sensed position is less + # affected by momentum and switch chatter. + # Give the mechanical switch a brief moment to settle before moving away. + time.sleep(0.03) if not self._clear_sensor(deadline, first_stop_delay): log(f"Timed out while clearing {label}! Images will be misaligned.") return False @@ -124,14 +229,20 @@ def _find_stop(self, calibration=0, timeout=60, label='stop position'): if time.time() >= deadline: log(f"Timed out while finding {label}! Images will be misaligned.") return False - self.halfStep(1, first_stop_delay) + # If the secondary trigger is reached early, stop advancing immediately. + if self._sensor_active(): + break + self.halfStep(1, second_stop_delay) - if not self._step_until_sensor(True, deadline, second_stop_delay): + if not self._step_until_sensor(True, deadline, second_stop_delay, stable_reads=2): log(f"Timed out while finding {label}! Images will be misaligned.") return False if calibration: - self.halfStep(calibration, first_stop_delay) + if calibration < 0: + self.halfStepReverse(abs(calibration), first_stop_delay) + else: + self.halfStep(calibration, first_stop_delay) return True def findStart(self, calibration=None): @@ -141,7 +252,14 @@ def findStart(self, calibration=None): def findNextStop(self): """rotates the imaging stage until the next positional stop is activated""" - return self._find_stop(label='next stop position') + calibration = int(self.cfg.get('calibration') or 0) + ignore_initial_retrigger_steps = 0 + if calibration < 0: + ignore_initial_retrigger_steps = abs(calibration) + 2 + return self._find_stop( + label='next stop position', + ignore_initial_retrigger_steps=ignore_initial_retrigger_steps, + ) # sets the motor pins as element in sequence def setStepper(self, M_seq, i): @@ -166,14 +284,39 @@ def halfStep(self, steps, delay, keep_motor_on=False): self.seqNumb = 0 time.sleep(delay) + # steps the stepper motor in reverse using half steps + def halfStepReverse(self, steps, delay): + time.sleep(0.005) + for i in range(0, steps): + self.seqNumb -= 1 + if self.seqNumb < 0: + self.seqNumb = 7 + self.setStepper(self.halfstep_seq, self.seqNumb) + time.sleep(delay) + + def get_sensor_status(self): + """Return the live switch state along with simple trip diagnostics.""" + active = self._sensor_active() + if active and not self.sensor_active: + self.sensor_last_trip_time = time.time() + self.sensor_trip_count += 1 + self.sensor_active = active + return { + 'active': active, + 'trip_count': self.sensor_trip_count, + 'last_trip_time': self.sensor_last_trip_time, + } + # sets motor standby status def motorOn(self, value): + """Enable or disable the motor driver standby pin.""" if self._valid_pin(self.pins.get('stdby')): gpio.output(self.pins['stdby'], value) self.motor = bool(value) # turns on and off led def LEDControl(self, value): + """Turn the illumination LED on or off.""" if self._valid_pin(self.pins.get('LED')): gpio.output(self.pins['LED'], value) self.led = value @@ -181,6 +324,7 @@ def LEDControl(self, value): # focuses the ArduCam motorized focus camera # code is from ArduCam GitHub repo def focusCam(self, val): + """Map the UI focus value into the active camera backend's focus control.""" if getattr(cam, 'type', None) == 'legacy': # focuses the ArduCam motorized focus camera # code is from ArduCam GitHub repo diff --git a/spiro/logger.py b/spiro/logger.py index 5b1691a..7aa358c 100644 --- a/spiro/logger.py +++ b/spiro/logger.py @@ -1,6 +1,9 @@ -# logger.py - -# simple functions for logging -# +"""Minimal logging helpers used throughout the SPIRO codebase. + +The project logs to stderr because the primary deployment target is a systemd +user service, where stderr is captured by journald and can be viewed from the +web UI or with journalctl. +""" import sys from spiro.config import Config @@ -8,10 +11,12 @@ cfg = Config() def log(msg): + """Write an unconditional log line to stderr.""" sys.stderr.write(msg + '\n') sys.stderr.flush() def debug(msg): + """Write a debug log line when the persistent debug flag is enabled.""" if cfg.get('debug'): sys.stderr.write(msg + '\n') sys.stderr.flush() diff --git a/spiro/spiro.py b/spiro/spiro.py index a32074a..5513632 100644 --- a/spiro/spiro.py +++ b/spiro/spiro.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 -# -# spiro.py - -# primary entry point for the spiro control software -# +"""Primary command-line entry point for SPIRO. + +Running without flags starts the web UI and hardware controllers. Command-line +flags perform one-shot maintenance tasks such as resetting configuration, +toggling debug logging, or managing the optional hotspot service. +""" import os import textwrap @@ -57,6 +59,7 @@ def installService(): + """Install a user-level systemd service that launches SPIRO on login.""" try: os.makedirs(os.path.expanduser('~/.config/systemd/user'), exist_ok=True) except OSError as e: @@ -82,6 +85,7 @@ def installService(): def terminate(sig, frame): + """Handle shutdown signals and tear down the camera, web UI, and GPIO safely.""" global shutdown if sig == signal.SIGALRM: # force shutdown @@ -129,6 +133,7 @@ def terminate(sig, frame): # start here. def main(): + """Execute requested CLI actions or launch the main web UI.""" global cam if options.reset: print("Clearing all configuration values.") @@ -164,7 +169,8 @@ def main(): options.show_version]): sys.exit() - # no options given, go ahead and start web ui + # No one-shot CLI options were requested, so continue into the long-running + # hardware-backed web application mode. try: os.chdir(os.path.expanduser('~')) from spiro.camera import cam @@ -176,4 +182,6 @@ def main(): except Exception as e: global failed failed = True + # If startup fails after process launch, fall back to the diagnostic UI + # so users can still inspect logs and the captured traceback remotely. failsafe.start(e) \ No newline at end of file diff --git a/spiro/static/spiro.css b/spiro/static/spiro.css index f57a915..86bf45c 100644 --- a/spiro/static/spiro.css +++ b/spiro/static/spiro.css @@ -93,6 +93,30 @@ div.tool { font-weight: bold; color: grey; } +.sensor-tool { + gap: 4px; +} +.sensor-label { + font-size: 10pt; + color: #cfcfcf; +} +.sensor-indicator { + width: 16px; + height: 16px; + border-radius: 50%; + display: inline-block; + background-color: #6a6a6a; + border: 1px solid #2f2f2f; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.6) inset; +} +.sensor-indicator.active { + background-color: #3fd768; + box-shadow: 0 0 10px rgba(63, 215, 104, 0.95); +} +.sensor-indicator.trip-flash { + background-color: #ffe26f; + box-shadow: 0 0 14px rgba(255, 226, 111, 1); +} .pure-button { font-family: 'Saira Condensed', sans-serif; font-weight: bold; @@ -105,6 +129,18 @@ div.tool { line-height: 1.1; padding: 0.35em 0.25em; } +.calibration-status-row { + display: flex; + align-items: center; + gap: 12px; + margin-top: 12px; + flex-wrap: wrap; +} +.calibration-motor-status { + font-family: 'Saira Condensed', sans-serif; + font-size: 12pt; + color: #d6d6d6; +} .pure-form label { font-family: 'Saira Condensed', sans-serif; font-size: 14pt; @@ -165,6 +201,39 @@ img.liveview { border-radius: 20px; box-shadow: 0px 0px 2px black; } +.still-preview-panel { + margin-top: 18px; + margin-right: 10px; + padding: 14px; + border-radius: 20px; + background-color: rgba(0, 0, 0, 0.35); +} +.still-preview-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} +.still-preview-title { + font-family: 'Saira Condensed', sans-serif; + font-size: 20pt; + font-weight: bold; + color: white; +} +.still-preview-status { + margin-top: 8px; + margin-bottom: 12px; + color: #d2d2d2; + font-size: 11pt; +} +.still-preview-image { + width: 100%; + max-height: 55vh; + object-fit: contain; + border-radius: 12px; + background-color: rgba(255, 255, 255, 0.05); +} div.toolbar { width: 70px; border-radius: 20px; @@ -271,26 +340,59 @@ div.pan { position: relative; } input.zoom { - transform:rotate(270deg); - width: 70px; - top: 55px; - left: 0px; - position: absolute; + width: 100%; + position: static; + transform: none; background-color: transparent; + margin-top: 10px; } div.zoom { - height: 90px; + width: 120px; position: relative; } div.focus { + width: 120px; position: relative; } +div.live-save-tool { + min-width: 110px; +} +div.live-control-group { + display: flex; + align-items: center; + gap: 6px; +} +input#zoomnumber { + width: 72px; + margin-top: 0; +} +.focus #focusnumber { + width: 72px; + margin-top: 0; +} +.live-adjust-btn { + min-width: 28px; + padding: 0.25em 0.45em; + font-size: 12pt; + line-height: 1; +} +.live-save-status { + margin-top: 8px; + font-size: 10pt; + color: #cfcfcf; + max-width: 110px; +} +.live-save-status.saved { + color: hsl(99, 75%, 64%); +} input.focusslider { position: relative; - left: -1px; - transform: rotate(90deg); - height: 90px; + left: 0; + transform: none; + width: 100%; + height: auto; background-color: transparent; + margin-top: 10px; } div.pan-left { position: absolute; diff --git a/spiro/static/spiro.js b/spiro/static/spiro.js index 360d8e1..e7804b8 100644 --- a/spiro/static/spiro.js +++ b/spiro/static/spiro.js @@ -14,15 +14,150 @@ function toggleLED(value) { } function updateFocusNumber(value) { - focusnumber = document.getElementById('focusnumber'); + var focusnumber = document.getElementById('focusnumber'); + var focusslider = document.getElementById('focusslider'); focusnumber.value = value; + if (focusslider) { + focusslider.value = value; + } bgGet('/focus/' + value); + markLiveSettingsDirty(); } function updateFocusSlider(value) { - focusslider = document.getElementById('focusslider'); + var focusslider = document.getElementById('focusslider'); + var focusnumber = document.getElementById('focusnumber'); focusslider.value = value; + if (focusnumber) { + focusnumber.value = value; + } bgGet('/focus/' + value); + markLiveSettingsDirty(); +} + +function updateZoomNumber(value) { + var zoomnumber = document.getElementById('zoomnumber'); + var zoomslider = document.getElementById('zoom'); + zoomnumber.value = value; + if (zoomslider) { + zoomslider.value = value; + } + bgGet('/zoom/' + value); + markLiveSettingsDirty(); +} + +function updateZoomSlider(value) { + var zoomslider = document.getElementById('zoom'); + var zoomnumber = document.getElementById('zoomnumber'); + zoomslider.value = value; + if (zoomnumber) { + zoomnumber.value = value; + } + bgGet('/zoom/' + value); + markLiveSettingsDirty(); +} + +function markLiveSettingsDirty() { + var status = document.getElementById('live-save-status'); + if (!status) { + return; + } + status.textContent = 'Unsaved view changes'; + status.classList.remove('saved'); +} + +function nudgeFocus(delta) { + var focusnumber = document.getElementById('focusnumber'); + var nextValue = Math.min(1000, Math.max(10, parseInt(focusnumber.value || '250', 10) + delta)); + updateFocusSlider(nextValue); + return false; +} + +function nudgeZoom(delta) { + var zoomnumber = document.getElementById('zoomnumber'); + var current = parseFloat(zoomnumber.value || '100'); + var nextValue = Math.min(100, Math.max(20, current + delta)); + updateZoomSlider(nextValue.toFixed(1)); + return false; +} + +function saveLiveViewSettings() { + var focusnumber = document.getElementById('focusnumber'); + var zoomnumber = document.getElementById('zoomnumber'); + var status = document.getElementById('live-save-status'); + var params = new URLSearchParams({ + focus: focusnumber.value, + zoom: zoomnumber.value + }); + + fetch('/live/settings/save?' + params.toString(), { cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (!status) { + return; + } + status.textContent = 'Saved focus ' + data.focus + ' / zoom ' + data.zoom + '%'; + status.classList.add('saved'); + window.setTimeout(function () { + status.classList.remove('saved'); + }, 1500); + }) + .catch(function () { + if (status) { + status.textContent = 'Save failed'; + status.classList.remove('saved'); + } + }); + + return false; +} + +function captureStillPreview() { + var status = document.getElementById('preview-still-status'); + var image = document.getElementById('live-preview-still'); + var focusnumber = document.getElementById('focusnumber'); + var focusslider = document.getElementById('focusslider'); + var zoomnumber = document.getElementById('zoomnumber'); + var zoomslider = document.getElementById('zoom'); + + if (status) { + status.textContent = 'Capturing still preview...'; + } + + fetch('/live-preview-still/capture', { cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (image) { + image.src = '/live-preview-still.png?t=' + data.timestamp; + } + if (focusnumber && data.focus !== null && data.focus !== undefined) { + focusnumber.value = data.focus; + } + if (focusslider && data.focus !== null && data.focus !== undefined) { + focusslider.value = data.focus; + } + if (zoomnumber && data.zoom !== null && data.zoom !== undefined) { + zoomnumber.value = data.zoom; + } + if (zoomslider && data.zoom !== null && data.zoom !== undefined) { + zoomslider.value = data.zoom; + } + if (status) { + status.textContent = 'Captured ' + data.mode + ' still preview' + (data.rotated ? ' with 90 degree clockwise rotation.' : '.'); + } + var liveStatus = document.getElementById('live-save-status'); + if (liveStatus && data.focus !== null && data.focus !== undefined && data.zoom !== null && data.zoom !== undefined) { + liveStatus.textContent = 'Saved focus ' + data.focus + ' / zoom ' + data.zoom + '%'; + liveStatus.classList.add('saved'); + } + }) + .catch(function () { + if (status) { + status.textContent = 'Still preview capture failed.'; + } + }); + + return false; } function updateShutter(value) { @@ -60,8 +195,12 @@ function calcDiskSpace() { } function tryCalibration() { - calib = document.getElementById('calibration'); - bgGet('/findstart/' + calib.value); + var calib = document.getElementById('calibration'); + fetch('/calibrate/try/' + calib.value, { cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(applyCalibrationMotorStatus) + .catch(function () { /* keep current state */ }); + return false; } function toggleMotor() { @@ -80,3 +219,93 @@ function toggleMotor() { btn.title = 'Disable stepper motors'; } } + +function applyCalibrationMotorStatus(data) { + var btn = document.getElementById('calibration-motorbtn'); + var status = document.getElementById('calibration-motor-status'); + if (!btn || !status || !data) { + return; + } + + var isOn = !!data.active; + btn.dataset.state = isOn ? 'on' : 'off'; + btn.textContent = isOn ? 'Motor On' : 'Motor Off'; + btn.classList.toggle('red', isOn); + + if (data.calibration_hold_active) { + status.textContent = 'Holding position for ' + data.calibration_hold_remaining + 's'; + } else if (isOn) { + status.textContent = 'Motor holding position'; + } else { + status.textContent = 'Motor released'; + } +} + +function toggleCalibrationMotor() { + var btn = document.getElementById('calibration-motorbtn'); + if (!btn) { + return false; + } + + var url = btn.dataset.state === 'on' ? '/calibrate/motor/off' : '/calibrate/motor/on'; + fetch(url, { cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(applyCalibrationMotorStatus) + .catch(function () { /* leave last known state */ }); + return false; +} + +function initCalibrationMotorStatus() { + var btn = document.getElementById('calibration-motorbtn'); + if (!btn) { + return; + } + + function pollMotor() { + fetch('/motor-status', { cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(applyCalibrationMotorStatus) + .catch(function () { /* keep polling */ }); + } + + pollMotor(); + window.setInterval(pollMotor, 1000); +} + +function initSensorIndicator() { + var indicator = document.getElementById('sensor-indicator'); + if (!indicator) { + return; + } + + var lastTripCount = null; + + function applyStatus(data) { + if (data.active) { + indicator.classList.add('active'); + } else { + indicator.classList.remove('active'); + } + + if (lastTripCount !== null && data.trip_count > lastTripCount) { + indicator.classList.add('trip-flash'); + window.setTimeout(function () { + indicator.classList.remove('trip-flash'); + }, 250); + } + lastTripCount = data.trip_count; + } + + function pollSensor() { + fetch('/sensor-status', { cache: 'no-store' }) + .then(function (r) { return r.json(); }) + .then(applyStatus) + .catch(function () { /* keep polling */ }); + } + + pollSensor(); + window.setInterval(pollSensor, 100); +} + +document.addEventListener('DOMContentLoaded', initSensorIndicator); +document.addEventListener('DOMContentLoaded', initCalibrationMotorStatus); diff --git a/spiro/templates/calibrate.html b/spiro/templates/calibrate.html index 505af84..4fbc438 100644 --- a/spiro/templates/calibrate.html +++ b/spiro/templates/calibrate.html @@ -23,11 +23,31 @@
Set up start position - -Try value + +Try value +Negative values apply a slight reverse adjustment.
+
+{{ 'Motor On' if motor else 'Motor Off' }} + +{% if calibration_hold_active %} +Holding position for {{ calibration_hold_remaining }}s +{% elif motor %} +Motor holding position +{% else %} +Motor released +{% endif %} + +
+
+Microswitch + +
diff --git a/spiro/templates/index.html b/spiro/templates/index.html index 9466766..d9fbc3d 100644 --- a/spiro/templates/index.html +++ b/spiro/templates/index.html @@ -24,6 +24,16 @@
+
+
+
+Still Preview + +
+
Capture a still preview to inspect the rotated high-resolution experiment image.
+Still preview image +
+
@@ -53,33 +63,50 @@ onclick="toggleMotor()" class="pure-button motoroff-btn{{ ' red' if motor else '' }}">{{ 'Motor On' if motor else 'Motor Off' }}
+
+Switch + +
- +
- +
- +
- +
- +
+ + + +
+
+
+ - + +
+
+
+Save View +
Saved focus {{ saved_focus }} / zoom {{ '%.1f' % saved_zoom_percent }}%
+
diff --git a/spiro/timeutils.py b/spiro/timeutils.py index 1922f8b..ab2f55f 100644 --- a/spiro/timeutils.py +++ b/spiro/timeutils.py @@ -1,3 +1,9 @@ +"""Timezone-aware helpers for UI display, filenames, and folder naming. + +SPIRO keeps the device clock in UTC, then converts timestamps into the user's +preferred timezone only for presentation and naming. +""" + from datetime import datetime, timezone from zoneinfo import ZoneInfo, ZoneInfoNotFoundError @@ -29,6 +35,7 @@ def normalize_timezone_name(name): + """Normalize friendly abbreviations and empty values to canonical names.""" if not name: return DEFAULT_TIMEZONE normalized = str(name).strip() @@ -38,6 +45,7 @@ def normalize_timezone_name(name): def validate_timezone_name(name): + """Validate a timezone name and return the canonical ZoneInfo key.""" normalized = normalize_timezone_name(name) try: return ZoneInfo(normalized).key @@ -46,24 +54,30 @@ def validate_timezone_name(name): def get_timezone(name=None): + """Return a ZoneInfo object for the provided or default timezone.""" return ZoneInfo(validate_timezone_name(name or DEFAULT_TIMEZONE)) def now_in_timezone(name=None): + """Return the current time converted from UTC into the requested timezone.""" return datetime.now(timezone.utc).astimezone(get_timezone(name)) def format_timestamp(timestamp, name=None, fmt='%Y-%m-%d %H:%M:%S %Z'): + """Format a POSIX timestamp for display in the selected timezone.""" return datetime.fromtimestamp(timestamp, timezone.utc).astimezone(get_timezone(name)).strftime(fmt) def format_now(name=None, fmt='%Y-%m-%d %H:%M:%S %Z'): + """Format the current time for display in the selected timezone.""" return now_in_timezone(name).strftime(fmt) def filename_timestamp(name=None): + """Return a filesystem-friendly timestamp for captured image names.""" return now_in_timezone(name).strftime('%Y%m%d-%H%M%S') def dated_folder_prefix(name=None): + """Return the date prefix used for default experiment folder names.""" return now_in_timezone(name).strftime('%Y.%m.%d') \ No newline at end of file diff --git a/spiro/webui.py b/spiro/webui.py index 008b877..0d25631 100644 --- a/spiro/webui.py +++ b/spiro/webui.py @@ -1,6 +1,10 @@ -# webui.py - -# main web ui controller for spiro -# +"""Main Flask web interface for SPIRO. + +This module coordinates camera live view, still-preview capture, experiment +control, calibration helpers, and file browsing. It also bridges between the +browser's normalized zoom/focus state and the hardware abstractions used by the +camera and motion-control layers. +""" import io import os @@ -10,10 +14,10 @@ import shutil import signal import hashlib -from threading import Thread, Lock, Condition +from threading import Thread, Lock, Condition, Timer from waitress import serve -from flask import Flask, render_template, Response, request, redirect, url_for, session, flash, abort +from flask import Flask, render_template, Response, request, redirect, url_for, session, flash, abort, jsonify import spiro.hostapd as hostapd from spiro.config import Config @@ -25,7 +29,122 @@ app.jinja_env.trim_blocks = True app.jinja_env.lstrip_blocks = True +CALIBRATION_MOTOR_HOLD_SECONDS = 5 * 60 + + +def _cancel_calibration_motor_hold(): + """Cancel the temporary calibration hold timer and clear its deadline.""" + global calibration_motor_hold_timer, calibration_motor_hold_until + if calibration_motor_hold_timer is not None: + calibration_motor_hold_timer.cancel() + calibration_motor_hold_timer = None + calibration_motor_hold_until = 0.0 + + +def _motor_off_from_calibration_hold(): + """Timer callback that releases the motor after a calibration hold expires.""" + global calibration_motor_hold_timer, calibration_motor_hold_until + lock.acquire() + try: + hw.motorOn(False) + calibration_motor_hold_timer = None + calibration_motor_hold_until = 0.0 + finally: + lock.release() + + +def _enable_calibration_motor_hold(duration=CALIBRATION_MOTOR_HOLD_SECONDS): + """Keep the motor driver energized during calibration adjustments. + + Calibration often requires several small manual retries, so this helper + prevents the motor from being de-energized between requests for a limited + window. + """ + global calibration_motor_hold_timer, calibration_motor_hold_until + duration = max(1, int(duration or CALIBRATION_MOTOR_HOLD_SECONDS)) + lock.acquire() + try: + hw.motorOn(True) + _cancel_calibration_motor_hold() + calibration_motor_hold_until = time.time() + duration + calibration_motor_hold_timer = Timer(duration, _motor_off_from_calibration_hold) + calibration_motor_hold_timer.daemon = True + calibration_motor_hold_timer.start() + finally: + lock.release() + + +def _disable_calibration_motor_hold(): + """Immediately end calibration motor hold mode and release the motor.""" + lock.acquire() + try: + _cancel_calibration_motor_hold() + hw.motorOn(False) + finally: + lock.release() + + +def _get_motor_status(): + """Return current motor and calibration-hold status for the UI.""" + remaining = 0 + if calibration_motor_hold_until: + remaining = max(0, int(calibration_motor_hold_until - time.time())) + return { + 'active': bool(hw and hw.motor), + 'calibration_hold_active': remaining > 0, + 'calibration_hold_remaining': remaining, + } + + +def _get_saved_zoom_state(): + """Read persisted zoom data and normalize legacy tuple formats.""" + saved_zoom = cfg.get('zoom') + if isinstance(saved_zoom, dict): + return { + 'x': saved_zoom.get('x', 0.5), + 'y': saved_zoom.get('y', 0.5), + 'roi': saved_zoom.get('roi', 1.0), + } + if isinstance(saved_zoom, (list, tuple)) and len(saved_zoom) == 4: + top, left, height, width = saved_zoom + del width + return { + 'x': left + height / 2.0, + 'y': top + height / 2.0, + 'roi': height, + } + return {'x': 0.5, 'y': 0.5, 'roi': 1.0} + + +def _get_current_live_view_state(): + """Snapshot the live-view zoom and focus so still capture can reuse them.""" + return { + 'zoom': { + 'x': zoomer.x, + 'y': zoomer.y, + 'roi': zoomer.roi, + }, + 'focus': current_focus_value, + } + + +def _apply_live_view_state(view_state): + """Restore zoom and focus after a still capture temporarily changed modes.""" + zoom_state = (view_state or {}).get('zoom') or {'x': 0.5, 'y': 0.5, 'roi': 1.0} + zoomer.set(x=zoom_state['x'], y=zoom_state['y'], roi=zoom_state['roi'], persist=False) + + focus_value = (view_state or {}).get('focus') + if focus_value is not None: + hw.focusCam(int(focus_value)) + + return { + 'focus': int(focus_value) if focus_value is not None else None, + 'zoom': round(zoom_state['roi'] * 100.0, 1), + } + class Rotator(Thread): + """Background worker that jogs the stage a requested number of half-steps.""" + def __init__(self, value): Thread.__init__(self) self.value = value @@ -35,7 +154,7 @@ def run(self): try: hw.motorOn(True) time.sleep(0.5) - hw.halfStep(self.value, 0.03) + hw.halfStep(self.value, hw._step_delay()) time.sleep(0.5) finally: hw.motorOn(False) @@ -43,6 +162,8 @@ def run(self): class StopFinder(Thread): + """Background worker that advances the stage to the next sensed stop.""" + def run(self): lock.acquire() try: @@ -73,12 +194,15 @@ def run(self): # return self.buffer.write(buf) class StreamingOutput(io.BufferedIOBase): + """Buffer object that breaks an MJPEG byte stream into individual frames.""" + def __init__(self): self.frame = None self.buffer = io.BytesIO() self.condition = Condition() def write(self, buf): + """Store JPEG data and publish completed frames to waiting clients.""" if buf.startswith(b'\xff\xd8'): self.buffer.truncate() with self.condition: @@ -101,6 +225,8 @@ def write(self, buf): class ZoomObject(object): + """Track and apply normalized live-view pan and zoom state.""" + def __init__(self): self.roi = 1 self.x = 0.5 @@ -119,7 +245,8 @@ def set(self, x=None, y=None, roi=None, persist=True): def apply(self, persist=True): '''checks and applies zoom/pan values on camera object''' - # constrain roi and pan to valid ranges + # Clamp the requested region so the crop window always remains fully + # inside the sensor bounds before sending it to the camera backend. self.roi = max(min(self.roi, 1.0), 0.2) limits = (self.roi / 2.0, 1 - self.roi / 2.0) self.x = max(min(self.x, limits[1]), limits[0]) @@ -193,7 +320,24 @@ def index(): if restarting: return render_template('restarting.html', refresh='60; url=/', message="Rebooting system...") # pass saved zoom settings to template so UI reflects persisted state - return render_template('index.html', live=livestream, focus=cfg.get('focus'), led=hw.led, motor=hw.motor, name=cfg.get('name'), zoom=cfg.get('zoom')) + sensor_status = hw.get_sensor_status() + return render_template( + 'index.html', + live=livestream, + focus=current_focus_value, + led=hw.led, + motor=hw.motor, + name=cfg.get('name'), + zoom={'x': zoomer.x, 'y': zoomer.y, 'roi': zoomer.roi}, + sensor_active=sensor_status['active'], + saved_focus=cfg.get('focus'), + saved_zoom_percent=round(((cfg.get('zoom') or {}).get('roi', 1.0)) * 100.0, 1), + ) + + +@app.route('/motor-status') +def motor_status(): + return jsonify(_get_motor_status()) @app.route('/empty') @@ -254,10 +398,13 @@ def newpass(): @not_while_running -@app.route('/zoom/') +@app.route('/zoom/') def zoom(value): - roi_val = float(value / 100) - zoomer.set(roi=roi_val) + try: + roi_val = float(value) / 100.0 + except ValueError: + abort(404) + zoomer.set(roi=roi_val, persist=False) try: log(f"Zoom request: roi={roi_val:.4f} -> zoom now x={zoomer.x:.4f}, y={zoomer.y:.4f}, roi={zoomer.roi:.4f}") except Exception: @@ -270,9 +417,9 @@ def zoom(value): def pan(dir, value): delta = float(value) if dir == 'x': - zoomer.set(y = zoomer.y + delta) + zoomer.set(y=zoomer.y + delta, persist=False) elif dir == 'y': - zoomer.set(x = zoomer.x + delta) + zoomer.set(x=zoomer.x + delta, persist=False) try: log(f"Pan request: dir={dir}, delta={delta:.4f} -> zoom now x={zoomer.x:.4f}, y={zoomer.y:.4f}, roi={zoomer.roi:.4f}") except Exception: @@ -294,6 +441,7 @@ def switch_live(value): def setLive(val): + """Enable or disable the MJPEG live stream and camera preview mode.""" global livestream prev = livestream if val == 'on' and livestream != True: @@ -339,11 +487,7 @@ def nextposition(): @not_while_running @app.route('/motor/off') def motor_off(): - lock.acquire() - try: - hw.motorOn(False) - finally: - lock.release() + _disable_calibration_motor_hold() return redirect(url_for('index')) @@ -352,27 +496,73 @@ def motor_off(): def motor_on(): lock.acquire() try: + _cancel_calibration_motor_hold() hw.motorOn(True) finally: lock.release() return redirect(url_for('index')) +@not_while_running +@app.route('/calibrate/motor/on') +def calibrate_motor_on(): + _enable_calibration_motor_hold() + return jsonify(_get_motor_status()) + + +@not_while_running +@app.route('/calibrate/motor/off') +def calibrate_motor_off(): + _disable_calibration_motor_hold() + return jsonify(_get_motor_status()) + + @not_while_running @app.route('/findstart') -@app.route('/findstart/') +@app.route('/findstart/') def findstart(value=None): - hw.motorOn(True) - if not value: - hw.findStart() - elif value > 0 and value < 400: - hw.findStart(calibration=value) - time.sleep(0.5) - hw.motorOn(False) + lock.acquire() + try: + _cancel_calibration_motor_hold() + hw.motorOn(True) + if value is None: + hw.findStart() + else: + try: + calibration = int(value) + except ValueError: + abort(404) + # Allow slight reverse adjustment while keeping users within a safe range. + calibration = max(-40, min(calibration, 399)) + hw.findStart(calibration=calibration) + time.sleep(0.5) + hw.motorOn(False) + finally: + lock.release() return redirect(url_for('index')) +@not_while_running +@app.route('/calibrate/try/') +def calibrate_try(value): + try: + calibration = int(value) + except ValueError: + abort(404) + calibration = max(-40, min(calibration, 399)) + + _enable_calibration_motor_hold() + lock.acquire() + try: + hw.findStart(calibration=calibration) + time.sleep(0.5) + finally: + lock.release() + return jsonify(_get_motor_status()) + + def liveGen(): + """Yield multipart MJPEG frames for the browser live-view endpoint.""" while True: with liveoutput.condition: got_frame = liveoutput.condition.wait(timeout=0.1) @@ -389,6 +579,12 @@ def liveStream(): return Response(liveGen(), mimetype='multipart/x-mixed-replace; boundary=frame') +@not_while_running +@app.route('/sensor-status') +def sensor_status(): + return jsonify(hw.get_sensor_status()) + + @app.route('/nightstill.png') def nightStill(): if nightstill.seek(0, io.SEEK_END) == 0: @@ -437,13 +633,70 @@ def preview(num): def takePicture(obj): + """Capture a PNG still into an in-memory buffer for exposure previews.""" obj.truncate() obj.seek(0) camera.capture(obj, format="png") obj.seek(0) +@app.route('/live-preview-still.png') +def live_preview_still_image(): + """Serve the most recently captured still-preview image.""" + livepreviewstill_lock.acquire() + try: + if livepreviewstill.seek(0, io.SEEK_END) == 0: + return redirect(url_for('static', filename='empty.png')) + livepreviewstill.seek(0) + return Response(livepreviewstill.read(), mimetype="image/png") + finally: + livepreviewstill_lock.release() + + +@not_while_running +@app.route('/live-preview-still/capture') +def capture_live_preview_still(): + """Capture a high-resolution preview still using experiment settings. + + The route snapshots the current live-view framing, asks the experimenter to + capture a processed still with that framing, stores the PNG in memory for + the browser preview pane, and then reapplies the live controls because the + still capture path may reset them during mode switches. + """ + lock.acquire() + try: + view_state = _get_current_live_view_state() + debug( + "[preview] live state snapshot " + f"zoom(x={view_state['zoom']['x']:.6f}, y={view_state['zoom']['y']:.6f}, roi={view_state['zoom']['roi']:.6f}) " + f"focus={view_state.get('focus')}" + ) + image, daytime = experimenter.capture_processed_still(view_state=view_state, capture_tag='preview') + livepreviewstill_lock.acquire() + try: + livepreviewstill.seek(0) + livepreviewstill.truncate() + image.save(livepreviewstill, format='PNG') + livepreviewstill.seek(0) + finally: + livepreviewstill_lock.release() + image.close() + restored_view = _apply_live_view_state(view_state) + finally: + lock.release() + + return jsonify({ + 'captured': True, + 'mode': 'day' if daytime else 'night', + 'rotated': bool(cfg.get('rotated_camera')), + 'timestamp': int(time.time() * 1000), + 'focus': restored_view['focus'], + 'zoom': restored_view['zoom'], + }) + + def grabExposure(time): + """Capture a day or night exposure sample for the settings page.""" global dayshutter, nightshutter if time in ['day', 'night']: if time == 'day': @@ -462,12 +715,43 @@ def grabExposure(time): @not_while_running @app.route('/focus/') def focus(value): + global current_focus_value value = min(1000, max(10, value)) hw.focusCam(value) - cfg.set('focus', value) + current_focus_value = value return redirect(url_for('index')) +@not_while_running +@app.route('/live/settings/save') +def save_live_settings(): + """Persist the current live-view focus and zoom as defaults for future runs.""" + global current_focus_value + try: + focus_value = int(request.args.get('focus', cfg.get('focus') or 250)) + except (TypeError, ValueError): + abort(400) + + try: + zoom_value = float(request.args.get('zoom', zoomer.roi * 100.0)) / 100.0 + except (TypeError, ValueError): + abort(400) + + focus_value = min(1000, max(10, focus_value)) + zoom_value = min(1.0, max(0.2, zoom_value)) + + hw.focusCam(focus_value) + current_focus_value = focus_value + cfg.set('focus', focus_value) + zoomer.set(roi=zoom_value, persist=True) + + return jsonify({ + 'saved': True, + 'focus': focus_value, + 'zoom': round(zoom_value * 100.0, 1), + }) + + @app.route('/experiment', methods=['GET', 'POST']) def experiment(): if request.method == 'POST': @@ -483,10 +767,8 @@ def experiment(): else: experimenter.dir = os.path.expanduser('~') setLive('off') # zoomer.set(roi=1.0) - current_zoom_tuple = ( zoomer.y - zoomer.roi/2.0, zoomer.x - zoomer.roi/2.0, zoomer.roi, zoomer.roi) -# current_focus = cfg.get("focus") -# experimenter.custom_settings = {'initial_zoom': current_zoom_tuple, 'initial_focus':current_focus} - experimenter.custom_settings = {'initial_zoom': current_zoom_tuple} + current_zoom_tuple = (zoomer.y - zoomer.roi/2.0, zoomer.x - zoomer.roi/2.0, zoomer.roi, zoomer.roi) + experimenter.custom_settings = {'initial_zoom': current_zoom_tuple, 'initial_focus': current_focus_value} log("Starting new experiment.") experimenter.next_status = 'run' experimenter.status_change.set() @@ -514,6 +796,7 @@ def experiment(): @not_while_running def exposureMode(time): + """Apply the requested exposure profile to the live camera.""" if time == 'day': camera.iso = cfg.get('dayiso') camera.shutter_speed = 1000000 // cfg.get('dayshutter') @@ -594,12 +877,21 @@ def calibrate(): value = request.form.get('calibration') if value: value = int(value) - value = max(0, min(value, 399)) + value = max(-40, min(value, 399)) cfg.set('calibration', value) + _disable_calibration_motor_hold() flash("New value for start position: " + str(value)) exposureMode('auto') setLive('on') - return render_template('calibrate.html', calibration=cfg.get('calibration'), name=cfg.get('name')) + motor_state = _get_motor_status() + return render_template( + 'calibrate.html', + calibration=cfg.get('calibration'), + name=cfg.get('name'), + motor=motor_state['active'], + calibration_hold_active=motor_state['calibration_hold_active'], + calibration_hold_remaining=motor_state['calibration_hold_remaining'], + ) @not_while_running @@ -825,7 +1117,7 @@ def set_rotated_camera(value): def get_external_ip(): - """returns the IPv4 address of eth0""" + """Return the first IPv4 address found on the primary wired or Wi-Fi NICs.""" for iface in ['eth0', 'wlan0']: p = subprocess.Popen(['/sbin/ip', '-4', '-o', 'a', 'show', iface], stdout=subprocess.PIPE, text=True) data = p.stdout.read() @@ -853,9 +1145,14 @@ def set_hotspot(value): #liveoutput = StillOutput() nightstill = io.BytesIO() daystill = io.BytesIO() +livepreviewstill = io.BytesIO() zoomer = ZoomObject() cfg = Config() +current_focus_value = int(cfg.get('focus') or 250) lock = Lock() +calibration_motor_hold_timer = None +calibration_motor_hold_until = 0.0 +livepreviewstill_lock = Lock() experimenter = None nightshutter = None @@ -867,7 +1164,13 @@ def set_hotspot(value): livestream = False def start(cam, myhw): - global camera, hw, experimenter + """Start the web UI, initialize saved state, and serve requests. + + Startup restores the user's last saved zoom and focus after live view begins + because some camera backends ignore crop/focus writes until streaming has + started and settled. + """ + global camera, hw, experimenter, current_focus_value camera = cam hw = myhw experimenter = Experimenter(hw=hw, cam=cam) @@ -882,7 +1185,9 @@ def start(cam, myhw): camera.rotation = 90 setLive('on') - # restore persisted zoom/pan after stream starts (retry to ensure camera accepts it) + # Restore persisted zoom/pan after streaming starts. Retries are used + # because libcamera sometimes ignores the first crop write during early + # initialization. saved_zoom = cfg.get('zoom') if saved_zoom: try: @@ -912,12 +1217,14 @@ def start(cam, myhw): except Exception as e: debug(f"Failed to restore saved zoom: {e}") - # restore persisted focus (retry while camera finishes initializing) + # Restore persisted focus with retries for the same reason: the lens may + # not accept manual writes until the camera has fully initialized. saved_focus = cfg.get('focus') if saved_focus is not None: log(f"Restoring saved focus: {saved_focus}") for attempt in range(1, 6): hw.focusCam(saved_focus) + current_focus_value = int(saved_focus) time.sleep(1) if getattr(camera, 'type', None) != 'libcamera' or getattr(camera, 'lens_limits', None): log(f"Focus applied (attempt {attempt}).") @@ -931,6 +1238,7 @@ def start(cam, myhw): stop() def stop(): + """Request experiment shutdown and wake the worker thread if needed.""" experimenter.stop() experimenter.quit = True experimenter.status_change.set() From 85691053533ec5fd5e7f9b9bfff9bd0c3c711661 Mon Sep 17 00:00:00 2001 From: Kirby Kalbaugh Date: Thu, 2 Apr 2026 16:24:10 -0400 Subject: [PATCH 5/6] updates --- spiro/config.py | 4 +- spiro/hwcontrol.py | 295 +++++++++++++++++++++++++++++---- spiro/static/spiro.css | 149 +++++++++++++++++ spiro/static/spiro.js | 291 ++++++++++++++++++++++++++++++-- spiro/templates/calibrate.html | 165 +++++++++++++++++- spiro/webui.py | 261 ++++++++++++++++++++++++++++- 6 files changed, 1099 insertions(+), 66 deletions(-) diff --git a/spiro/config.py b/spiro/config.py index 095376b..d9e8c1b 100644 --- a/spiro/config.py +++ b/spiro/config.py @@ -41,7 +41,7 @@ class Config(object): """ defaults = { - 'calibration' : 8, # number of steps taken after positional sensor is lit + 'calibration' : 8, # final offset applied after the second/final trigger at every position 'motor_step_delay': 0.08, # seconds between motor half-steps; larger values rotate slower # `motor_secondary_step_delay` controls the half-step delay used during the # secondary (fine) stop-finding phase of homing. The homing routine first @@ -58,6 +58,8 @@ class Config(object): # and more reliable. The default value (0.12) is a practical compromise. # Note: this parameter is only used when `secondary_stop_steps` is > 0. 'motor_secondary_step_delay': 0.12, + 'stop_stable_reads': 2, # consecutive sensor-active reads required before a stop is accepted + 'secondary_stop_settle_time': 0.03, # pause after clearing the coarse trigger before the fine-pass begins 'position_count': 8, # number of capture positions around the stage 'secondary_stop_steps': 25, # steps between the coarse and fine stop trigger; set to 0 to disable 'secondary_home_steps': 25, # backwards-compatible alias for older configs diff --git a/spiro/hwcontrol.py b/spiro/hwcontrol.py index 6ab2405..ba1742e 100644 --- a/spiro/hwcontrol.py +++ b/spiro/hwcontrol.py @@ -4,6 +4,7 @@ and it also bridges UI focus values to the attached camera's manual focus API. """ +import copy import RPi.GPIO as gpio @@ -36,17 +37,49 @@ def __init__(self): self.sensor_last_trip_time = 0.0 self.sensor_trip_count = 0 self.sensor_event_enabled = False + self.forward_step_count = 0 + self.reverse_step_count = 0 + self.last_stop_report = None + self.last_repeatability_test = None # defer actual GPIO setup into a separate robust initializer self.GPIOInit() - def _step_delay(self): + def _motion_setting(self, key, overrides=None, alias=None): + """Return a motion-tuning value, preferring temporary overrides.""" + if overrides and overrides.get(key) not in (None, ''): + return overrides.get(key) + if alias and overrides and overrides.get(alias) not in (None, ''): + return overrides.get(alias) + + value = self.cfg.get(key) + if value is None and alias: + value = self.cfg.get(alias) + return value + + def _step_delay(self, overrides=None): """Return the configured coarse homing delay between half-steps.""" - value = self.cfg.get('motor_step_delay') + value = self._motion_setting('motor_step_delay', overrides=overrides) try: return max(0.01, float(value or 0.05)) except Exception: return 0.05 + def _stop_stable_reads(self, overrides=None): + """Return how many consecutive active reads are required to accept a stop.""" + value = self._motion_setting('stop_stable_reads', overrides=overrides) + try: + return max(1, int(value or 2)) + except Exception: + return 2 + + def _secondary_settle_time(self, overrides=None): + """Return the dwell time between coarse trigger clear and fine search.""" + value = self._motion_setting('secondary_stop_settle_time', overrides=overrides) + try: + return max(0.0, float(value or 0.03)) + except Exception: + return 0.03 + def _sensor_event(self, channel): """Track edge-detected switch activity for UI status reporting.""" del channel @@ -119,14 +152,14 @@ def _sensor_active(self): return False return bool(gpio.input(self.pins['sensor'])) - def _secondary_stop_steps(self): + def _secondary_stop_steps(self, overrides=None): """Return the configured number of fine-search steps after coarse homing.""" - value = self.cfg.get('secondary_stop_steps') + value = self._motion_setting('secondary_stop_steps', overrides=overrides) if value is None: - value = self.cfg.get('secondary_home_steps') + value = self._motion_setting('secondary_home_steps', overrides=overrides) return max(0, int(value or 0)) - def _secondary_step_delay(self): + def _secondary_step_delay(self, overrides=None): """Return the delay used during the fine stop-finding pass. This mirrors the meaning of ``motor_secondary_step_delay`` from the @@ -134,19 +167,70 @@ def _secondary_step_delay(self): secondary pass advances more cautiously so the switch can be reacquired with less overshoot and less sensitivity to mechanical bounce. """ - value = self.cfg.get('motor_secondary_step_delay') + value = self._motion_setting('motor_secondary_step_delay', overrides=overrides) if value is None: - return max(self._step_delay() * 2.5, self._step_delay() + 0.04) + return max(self._step_delay(overrides) * 2.5, self._step_delay(overrides) + 0.04) try: return max(0.02, float(value)) except Exception: - return max(self._step_delay() * 2.5, self._step_delay() + 0.04) + return max(self._step_delay(overrides) * 2.5, self._step_delay(overrides) + 0.04) + + def _begin_stop_report(self, label, calibration, timeout, ignore_initial_retrigger_steps, + first_stop_delay, second_stop_delay, secondary_stop_steps, + stable_reads, settle_time): + """Create a diagnostics record for a stop-finding attempt.""" + return { + 'label': label, + 'started_at': time.time(), + 'timeout_seconds': float(timeout), + 'calibration': int(calibration or 0), + 'ignore_initial_retrigger_steps': max(0, int(ignore_initial_retrigger_steps or 0)), + 'primary_step_delay': first_stop_delay, + 'secondary_step_delay': second_stop_delay, + 'secondary_stop_steps': secondary_stop_steps, + 'stop_stable_reads': stable_reads, + 'secondary_stop_settle_time': settle_time, + 'initial_sensor_active': self._sensor_active(), + 'initial_clear_steps': 0, + 'coarse_steps_to_trigger': 0, + 'ignored_trigger_count': 0, + 'ignored_trigger_clear_steps': 0, + 'secondary_clear_steps': 0, + 'secondary_approach_steps': 0, + 'secondary_search_steps_to_trigger': 0, + 'final_adjustment_direction': 'none', + 'final_adjustment_steps': 0, + 'sequence_index_before': self.seqNumb, + '_forward_before': self.forward_step_count, + '_reverse_before': self.reverse_step_count, + } - def _clear_sensor(self, deadline, delay): + def _finalize_stop_report(self, report, success, failure_reason=''): + """Complete and store a stop-finding diagnostics record.""" + forward_before = report.pop('_forward_before', self.forward_step_count) + reverse_before = report.pop('_reverse_before', self.reverse_step_count) + report['completed_at'] = time.time() + report['duration_seconds'] = round(report['completed_at'] - report['started_at'], 4) + report['forward_steps_moved'] = self.forward_step_count - forward_before + report['reverse_steps_moved'] = self.reverse_step_count - reverse_before + report['net_steps_moved'] = report['forward_steps_moved'] - report['reverse_steps_moved'] + report['sequence_index_after'] = self.seqNumb + report['sensor_active_end'] = self._sensor_active() + report['success'] = bool(success) + report['failure_reason'] = failure_reason or '' + self.last_stop_report = report + return bool(success) + + def _clear_sensor(self, deadline, delay, return_steps=False): """Step forward until the positional switch is no longer active.""" + steps_taken = 0 while self._sensor_active() and time.time() < deadline: self.halfStep(1, delay) - return not self._sensor_active() + steps_taken += 1 + result = not self._sensor_active() + if return_steps: + return result, steps_taken + return result def _step_until_sensor(self, expected_state, deadline, delay, stable_reads=1, return_steps=False): """Step until the sensor reaches the requested state for enough reads. @@ -174,7 +258,8 @@ def _step_until_sensor(self, expected_state, deadline, delay, stable_reads=1, re return result, steps_taken return result - def _find_stop(self, calibration=0, timeout=60, label='stop position', ignore_initial_retrigger_steps=0): + def _find_stop(self, calibration=0, timeout=60, label='stop position', + ignore_initial_retrigger_steps=0, overrides=None): """Find a sensed stop using coarse motion and an optional fine pass. The search first clears any existing trigger, moves forward using the @@ -182,85 +267,221 @@ def _find_stop(self, calibration=0, timeout=60, label='stop position', ignore_in a slower secondary approach using ``secondary_stop_steps`` and ``motor_secondary_step_delay`` for more repeatable alignment. """ - first_stop_delay = self._step_delay() - second_stop_delay = self._secondary_step_delay() + first_stop_delay = self._step_delay(overrides=overrides) + second_stop_delay = self._secondary_step_delay(overrides=overrides) deadline = time.time() + timeout - secondary_stop_steps = self._secondary_stop_steps() + secondary_stop_steps = self._secondary_stop_steps(overrides=overrides) + stable_reads = self._stop_stable_reads(overrides=overrides) + settle_time = self._secondary_settle_time(overrides=overrides) + report = self._begin_stop_report( + label=label, + calibration=calibration, + timeout=timeout, + ignore_initial_retrigger_steps=ignore_initial_retrigger_steps, + first_stop_delay=first_stop_delay, + second_stop_delay=second_stop_delay, + secondary_stop_steps=secondary_stop_steps, + stable_reads=stable_reads, + settle_time=settle_time, + ) if not self._valid_pin(self.pins.get('sensor')): - return False + return self._finalize_stop_report(report, False, 'sensor-unavailable') - if not self._clear_sensor(deadline, first_stop_delay): + cleared, initial_clear_steps = self._clear_sensor(deadline, first_stop_delay, return_steps=True) + report['initial_clear_steps'] = initial_clear_steps + if not cleared: log(f"Timed out while clearing {label}! Images will be misaligned.") - return False + return self._finalize_stop_report(report, False, 'timeout-clearing-sensor') while True: found_stop, steps_to_stop = self._step_until_sensor( True, deadline, first_stop_delay, - stable_reads=2, + stable_reads=stable_reads, return_steps=True, ) + report['coarse_steps_to_trigger'] += steps_to_stop if not found_stop: log(f"Timed out while finding {label}! Images will be misaligned.") - return False + return self._finalize_stop_report(report, False, 'timeout-finding-coarse-trigger') if steps_to_stop >= max(0, int(ignore_initial_retrigger_steps or 0)): break + report['ignored_trigger_count'] += 1 debug( f"Ignoring early {label} trigger after {steps_to_stop} steps; " f"waiting for next stop beyond {ignore_initial_retrigger_steps} steps." ) - if not self._clear_sensor(deadline, first_stop_delay): + cleared, clear_steps = self._clear_sensor(deadline, first_stop_delay, return_steps=True) + report['ignored_trigger_clear_steps'] += clear_steps + if not cleared: log(f"Timed out while clearing {label}! Images will be misaligned.") - return False + return self._finalize_stop_report(report, False, 'timeout-clearing-ignored-trigger') if secondary_stop_steps > 0: # The secondary pass deliberately backs out of the trigger and then # creeps forward more slowly so the final sensed position is less # affected by momentum and switch chatter. # Give the mechanical switch a brief moment to settle before moving away. - time.sleep(0.03) - if not self._clear_sensor(deadline, first_stop_delay): + time.sleep(settle_time) + cleared, secondary_clear_steps = self._clear_sensor(deadline, first_stop_delay, return_steps=True) + report['secondary_clear_steps'] = secondary_clear_steps + if not cleared: log(f"Timed out while clearing {label}! Images will be misaligned.") - return False + return self._finalize_stop_report(report, False, 'timeout-clearing-secondary-trigger') for i in range(secondary_stop_steps): + del i if time.time() >= deadline: log(f"Timed out while finding {label}! Images will be misaligned.") - return False + return self._finalize_stop_report(report, False, 'timeout-during-secondary-approach') # If the secondary trigger is reached early, stop advancing immediately. if self._sensor_active(): break self.halfStep(1, second_stop_delay) + report['secondary_approach_steps'] += 1 - if not self._step_until_sensor(True, deadline, second_stop_delay, stable_reads=2): + found_secondary, secondary_search_steps = self._step_until_sensor( + True, + deadline, + second_stop_delay, + stable_reads=stable_reads, + return_steps=True, + ) + report['secondary_search_steps_to_trigger'] = secondary_search_steps + if not found_secondary: log(f"Timed out while finding {label}! Images will be misaligned.") - return False + return self._finalize_stop_report(report, False, 'timeout-finding-secondary-trigger') if calibration: if calibration < 0: + report['final_adjustment_direction'] = 'reverse' + report['final_adjustment_steps'] = abs(calibration) self.halfStepReverse(abs(calibration), first_stop_delay) else: + report['final_adjustment_direction'] = 'forward' + report['final_adjustment_steps'] = int(calibration) self.halfStep(calibration, first_stop_delay) - return True + return self._finalize_stop_report(report, True) + + def findStart(self, calibration=None, overrides=None): + """Find the start position and then apply the shared final offset.""" + if calibration is None: + calibration = self.cfg.get('calibration') + return self._find_stop(calibration=calibration, label='start position', overrides=overrides) - def findStart(self, calibration=None): - """rotates the imaging stage until the positional switch is activated""" - calibration = calibration or self.cfg.get('calibration') - return self._find_stop(calibration=calibration, label='start position') + def findNextStop(self, calibration=None, overrides=None): + """Find the next position and apply the same final offset used for start. - def findNextStop(self): - """rotates the imaging stage until the next positional stop is activated""" - calibration = int(self.cfg.get('calibration') or 0) + Each position is expected to present two sensor events: a coarse trigger + that announces an upcoming position, followed by the final trigger used + for precise alignment. The configured calibration offset is applied after + that final trigger for every position, not just the first one. + """ + calibration = int((self.cfg.get('calibration') if calibration is None else calibration) or 0) ignore_initial_retrigger_steps = 0 if calibration < 0: ignore_initial_retrigger_steps = abs(calibration) + 2 return self._find_stop( + calibration=calibration, label='next stop position', ignore_initial_retrigger_steps=ignore_initial_retrigger_steps, + overrides=overrides, ) + def get_step_counters(self): + """Return calibration-oriented motion counters for the current session.""" + return { + 'forward_steps': self.forward_step_count, + 'reverse_steps': self.reverse_step_count, + 'net_steps': self.forward_step_count - self.reverse_step_count, + 'sequence_index': self.seqNumb, + } + + def reset_calibration_counters(self): + """Clear session counters and the most recent calibration reports.""" + self.forward_step_count = 0 + self.reverse_step_count = 0 + self.sensor_trip_count = 0 + self.sensor_last_trip_time = 0.0 + self.last_stop_report = None + self.last_repeatability_test = None + + def get_last_stop_report(self): + """Return the most recent stop-finding diagnostics report.""" + return copy.deepcopy(self.last_stop_report) + + def get_last_repeatability_test(self): + """Return the most recent repeatability test summary.""" + return copy.deepcopy(self.last_repeatability_test) + + def jog(self, steps, delay=None): + """Jog the stage forward or reverse by a signed number of half-steps.""" + steps = int(steps or 0) + if delay is None: + delay = self._step_delay() + else: + delay = max(0.01, float(delay)) + + if steps > 0: + self.halfStep(steps, delay) + elif steps < 0: + self.halfStepReverse(abs(steps), delay) + + return { + 'requested_steps': steps, + 'delay': delay, + 'sensor_active': self._sensor_active(), + 'step_counters': self.get_step_counters(), + } + + def _summarize_report_field(self, reports, field): + """Return min/avg/max summary stats for a numeric report field.""" + values = [report.get(field) for report in reports if isinstance(report.get(field), (int, float))] + if not values: + return None + return { + 'min': min(values), + 'avg': round(sum(values) / len(values), 2), + 'max': max(values), + } + + def run_repeatability_test(self, mode='next', cycles=5, calibration=None, overrides=None): + """Run repeated homing cycles and summarize how consistent they are.""" + mode = 'start' if mode == 'start' else 'next' + cycles = max(1, min(25, int(cycles or 1))) + calibration = int((self.cfg.get('calibration') if calibration is None else calibration) or 0) + + reports = [] + for cycle in range(cycles): + if mode == 'start': + success = self.findStart(calibration=calibration, overrides=overrides) + else: + success = self.findNextStop(calibration=calibration, overrides=overrides) + + report = self.get_last_stop_report() or {} + report['cycle'] = cycle + 1 + reports.append(report) + + if not success: + break + + successful_reports = [report for report in reports if report.get('success')] + summary = { + 'mode': mode, + 'cycles_requested': cycles, + 'cycles_completed': len(reports), + 'success_count': len(successful_reports), + 'failure_count': len(reports) - len(successful_reports), + 'travel_steps': self._summarize_report_field(successful_reports, 'net_steps_moved'), + 'coarse_steps_to_trigger': self._summarize_report_field(successful_reports, 'coarse_steps_to_trigger'), + 'secondary_search_steps_to_trigger': self._summarize_report_field(successful_reports, 'secondary_search_steps_to_trigger'), + 'reports': reports, + } + self.last_repeatability_test = copy.deepcopy(summary) + return copy.deepcopy(summary) + # sets the motor pins as element in sequence def setStepper(self, M_seq, i): # write only to valid pins, protecting against None configs @@ -282,6 +503,7 @@ def halfStep(self, steps, delay, keep_motor_on=False): self.seqNumb += 1 if (self.seqNumb == 8): self.seqNumb = 0 + self.forward_step_count += 1 time.sleep(delay) # steps the stepper motor in reverse using half steps @@ -292,6 +514,7 @@ def halfStepReverse(self, steps, delay): if self.seqNumb < 0: self.seqNumb = 7 self.setStepper(self.halfstep_seq, self.seqNumb) + self.reverse_step_count += 1 time.sleep(delay) def get_sensor_status(self): diff --git a/spiro/static/spiro.css b/spiro/static/spiro.css index 86bf45c..a673d88 100644 --- a/spiro/static/spiro.css +++ b/spiro/static/spiro.css @@ -136,11 +136,149 @@ div.tool { margin-top: 12px; flex-wrap: wrap; } +.calibration-settings-form { + max-width: 1040px; +} +.calibration-settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px 18px; +} +.calibration-field { + display: flex; + flex-direction: column; + gap: 6px; +} +.calibration-field input, +.calibration-field select { + max-width: 100%; +} +.calibration-action-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 16px; +} +.calibration-note { + margin-top: 10px; + color: #cfcfcf; + font-size: 10.5pt; +} +.calibration-action-status { + margin-top: 14px; + padding: 10px 12px; + border-radius: 12px; + background-color: rgba(255, 255, 255, 0.08); + color: #e8e8e8; + font-family: 'Saira Condensed', sans-serif; + font-size: 13pt; +} +.calibration-action-status.error { + background-color: rgba(220, 20, 17, 0.2); + color: #ffd5d4; +} .calibration-motor-status { font-family: 'Saira Condensed', sans-serif; font-size: 12pt; color: #d6d6d6; } +.calibration-workspace { + display: flex; + gap: 18px; + align-items: flex-start; + margin-top: 18px; + flex-wrap: wrap; +} +.calibration-live-panel { + flex: 1 1 520px; + min-width: 320px; +} +.calibration-dashboard { + flex: 1 1 360px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 14px; + min-width: 320px; +} +.calibration-card { + padding: 14px; + border-radius: 18px; + background-color: rgba(0, 0, 0, 0.35); + box-shadow: 0 0 2px rgba(0, 0, 0, 0.4); +} +.calibration-card-wide { + grid-column: 1 / -1; +} +.calibration-card-title { + font-family: 'Saira Condensed', sans-serif; + font-size: 18pt; + color: white; + margin-bottom: 12px; +} +.calibration-stat-list { + display: grid; + gap: 8px; +} +.calibration-stat-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + font-family: 'Saira Condensed', sans-serif; + color: #d8d8d8; +} +.calibration-stat-row strong { + color: white; + text-align: right; +} +.calibration-card-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 14px; +} +.calibration-control-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 12px; +} +.calibration-inline-status { + font-family: 'Saira Condensed', sans-serif; + color: #f0f0f0; +} +.calibration-sensor-tool { + margin-top: 0; + margin-bottom: 10px; + align-items: center; + flex-direction: row; + gap: 8px; +} +.calibration-summary-text { + margin-top: 12px; + color: #d6d6d6; + font-size: 11pt; + line-height: 1.35; +} +.calibration-report-block { + margin-top: 18px; + padding: 14px; + border-radius: 18px; + background-color: rgba(0, 0, 0, 0.35); +} +.calibration-table-wrap { + overflow-x: auto; +} +.calibration-report-table { + width: 100%; + background-color: rgba(0, 0, 0, 0.2); +} +.calibration-report-table th, +.calibration-report-table td { + white-space: nowrap; +} +.calibration-report-row-failed td { + color: #ffb6b4; +} .pure-form label { font-family: 'Saira Condensed', sans-serif; font-size: 14pt; @@ -244,6 +382,7 @@ div.vcenter { margin-bottom: auto; } img.calibrate { + width: 100%; max-height: 80vh; } div.logo { @@ -496,6 +635,16 @@ input { iframe { color: white; } +@media (max-width: 960px) { + .calibration-workspace { + flex-direction: column; + } + .calibration-live-panel, + .calibration-dashboard { + min-width: 0; + width: 100%; + } +} input[type=range] { -webkit-appearance: none; width: 100%; diff --git a/spiro/static/spiro.js b/spiro/static/spiro.js index e7804b8..1aa3170 100644 --- a/spiro/static/spiro.js +++ b/spiro/static/spiro.js @@ -194,13 +194,185 @@ function calcDiskSpace() { } } +function setCalibrationActionStatus(message, isError) { + var status = document.getElementById('calibration-action-status'); + if (!status) { + return; + } + + status.textContent = message || 'Ready for calibration testing.'; + status.classList.toggle('error', !!isError); +} + +function collectCalibrationSettings() { + var params = new URLSearchParams(); + var fieldIds = [ + ['calibration', 'calibration'], + ['motor-step-delay', 'motor_step_delay'], + ['motor-secondary-step-delay', 'motor_secondary_step_delay'], + ['secondary-stop-steps', 'secondary_stop_steps'], + ['stop-stable-reads', 'stop_stable_reads'], + ['secondary-stop-settle-time', 'secondary_stop_settle_time'] + ]; + + fieldIds.forEach(function (entry) { + var element = document.getElementById(entry[0]); + if (element) { + params.set(entry[1], element.value); + } + }); + + return params; +} + +function postCalibrationRequest(url, params) { + return fetch(url, { + method: 'POST', + cache: 'no-store', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' + }, + body: params.toString() + }).then(function (response) { + return response.json().then(function (data) { + if (!response.ok) { + throw data; + } + return data; + }); + }); +} + +function updateSensorIndicatorDisplay(sensor) { + var indicator = document.getElementById('sensor-indicator'); + var stateText = document.getElementById('sensor-state-text'); + var tripCount = document.getElementById('sensor-trip-count'); + var lastTrip = document.getElementById('sensor-last-trip'); + + if (indicator) { + indicator.classList.toggle('active', !!sensor.active); + } + if (stateText) { + stateText.textContent = sensor.active ? 'Active' : 'Inactive'; + } + if (tripCount) { + tripCount.textContent = sensor.trip_count; + } + if (lastTrip) { + lastTrip.textContent = sensor.last_trip_text || 'Never'; + } +} + +function updateCalibrationStepCounters(steps) { + var mappings = { + 'step-forward-count': steps.forward_steps, + 'step-reverse-count': steps.reverse_steps, + 'step-net-count': steps.net_steps, + 'step-sequence-index': steps.sequence_index + }; + + Object.keys(mappings).forEach(function (id) { + var element = document.getElementById(id); + if (element) { + element.textContent = mappings[id]; + } + }); +} + +function formatAdjustmentText(report) { + if (!report || !report.final_adjustment_steps) { + return '—'; + } + return report.final_adjustment_direction + ' ' + report.final_adjustment_steps; +} + +function renderLastStopReport(report) { + var values = { + 'last-stop-label': report ? report.label : 'No stop run yet', + 'last-stop-result': report ? (report.success ? 'Success' : 'Failed') : '—', + 'last-stop-completed': report && report.completed_at_text ? report.completed_at_text : '—', + 'last-stop-coarse-steps': report ? report.coarse_steps_to_trigger : '—', + 'last-stop-secondary-clear': report ? report.secondary_clear_steps : '—', + 'last-stop-secondary-approach': report ? report.secondary_approach_steps : '—', + 'last-stop-secondary-search': report ? report.secondary_search_steps_to_trigger : '—', + 'last-stop-adjustment': formatAdjustmentText(report), + 'last-stop-net-steps': report ? report.net_steps_moved : '—', + 'last-stop-reason': report && report.failure_reason ? report.failure_reason : '—', + 'last-stop-duration': report && report.duration_seconds !== undefined ? report.duration_seconds + ' s' : '—' + }; + + Object.keys(values).forEach(function (id) { + var element = document.getElementById(id); + if (element) { + element.textContent = values[id]; + } + }); +} + +function formatSummaryRange(summary) { + if (!summary) { + return '—'; + } + return summary.min + ' / ' + summary.avg + ' / ' + summary.max; +} + +function renderRepeatabilityResults(summary) { + var summaryElement = document.getElementById('repeatability-summary'); + var body = document.getElementById('repeatability-results-body'); + + if (!summary || !summary.reports || !summary.reports.length) { + if (summaryElement) { + summaryElement.textContent = 'No repeatability test run yet.'; + } + if (body) { + body.innerHTML = ''; + var emptyRow = document.createElement('tr'); + var emptyCell = document.createElement('td'); + emptyCell.colSpan = 8; + emptyCell.textContent = 'No repeatability test run yet.'; + emptyRow.appendChild(emptyCell); + body.appendChild(emptyRow); + } + return; + } + + if (summaryElement) { + var modeLabel = summary.mode === 'start' ? 'Start position' : 'Next position'; + summaryElement.textContent = 'Mode: ' + modeLabel + '. Success ' + summary.success_count + '/' + summary.cycles_requested + '. Net steps min/avg/max: ' + formatSummaryRange(summary.travel_steps) + '. Coarse trigger min/avg/max: ' + formatSummaryRange(summary.coarse_steps_to_trigger) + '.'; + } + + if (!body) { + return; + } + + body.innerHTML = ''; + summary.reports.forEach(function (report) { + var row = document.createElement('tr'); + if (!report.success) { + row.classList.add('calibration-report-row-failed'); + } + + [ + report.cycle, + report.success ? 'Success' : 'Failed', + report.net_steps_moved, + report.coarse_steps_to_trigger, + report.secondary_search_steps_to_trigger, + formatAdjustmentText(report), + report.duration_seconds, + report.failure_reason || '—' + ].forEach(function (value) { + var cell = document.createElement('td'); + cell.textContent = value === undefined || value === null || value === '' ? '—' : value; + row.appendChild(cell); + }); + + body.appendChild(row); + }); +} + function tryCalibration() { - var calib = document.getElementById('calibration'); - fetch('/calibrate/try/' + calib.value, { cache: 'no-store' }) - .then(function (r) { return r.json(); }) - .then(applyCalibrationMotorStatus) - .catch(function () { /* keep current state */ }); - return false; + return tryCalibrationStart(); } function toggleMotor() { @@ -223,6 +395,8 @@ function toggleMotor() { function applyCalibrationMotorStatus(data) { var btn = document.getElementById('calibration-motorbtn'); var status = document.getElementById('calibration-motor-status'); + var state = document.getElementById('calibration-motor-state'); + var holdTimer = document.getElementById('calibration-hold-timer'); if (!btn || !status || !data) { return; } @@ -231,14 +405,109 @@ function applyCalibrationMotorStatus(data) { btn.dataset.state = isOn ? 'on' : 'off'; btn.textContent = isOn ? 'Motor On' : 'Motor Off'; btn.classList.toggle('red', isOn); + if (state) { + state.textContent = isOn ? 'Holding' : 'Released'; + } if (data.calibration_hold_active) { status.textContent = 'Holding position for ' + data.calibration_hold_remaining + 's'; + if (holdTimer) { + holdTimer.textContent = data.calibration_hold_remaining + 's'; + } } else if (isOn) { status.textContent = 'Motor holding position'; + if (holdTimer) { + holdTimer.textContent = 'Manual hold'; + } } else { status.textContent = 'Motor released'; + if (holdTimer) { + holdTimer.textContent = 'Off'; + } + } +} + +function renderCalibrationSnapshot(data) { + if (!data) { + return; + } + + if (data.motor) { + applyCalibrationMotorStatus(data.motor); + } + if (data.sensor) { + updateSensorIndicatorDisplay(data.sensor); } + if (data.steps) { + updateCalibrationStepCounters(data.steps); + } + renderLastStopReport(data.last_stop_report || null); + renderRepeatabilityResults(data.last_repeatability_test || null); + + if (data.message) { + setCalibrationActionStatus(data.message, data.success === false); + } +} + +function runCalibrationAction(mode) { + var params = collectCalibrationSettings(); + setCalibrationActionStatus('Running ' + mode + ' stop search...', false); + postCalibrationRequest('/calibrate/run/' + mode, params) + .then(renderCalibrationSnapshot) + .catch(function (error) { + setCalibrationActionStatus(error && error.error ? error.error : 'Calibration action failed.', true); + }); + return false; +} + +function tryCalibrationStart() { + return runCalibrationAction('start'); +} + +function tryCalibrationNext() { + return runCalibrationAction('next'); +} + +function jogCalibration(direction) { + var params = new URLSearchParams(); + var steps = document.getElementById('calibration-jog-steps'); + var delay = document.getElementById('calibration-jog-delay'); + params.set('steps', steps ? steps.value : '25'); + params.set('delay', delay ? delay.value : '0.08'); + + setCalibrationActionStatus('Jogging ' + direction + '...', false); + postCalibrationRequest('/calibrate/jog/' + direction, params) + .then(renderCalibrationSnapshot) + .catch(function (error) { + setCalibrationActionStatus(error && error.error ? error.error : 'Jog request failed.', true); + }); + return false; +} + +function runCalibrationRepeatabilityTest() { + var params = collectCalibrationSettings(); + var mode = document.getElementById('repeatability-mode'); + var cycles = document.getElementById('repeatability-cycles'); + params.set('mode', mode ? mode.value : 'next'); + params.set('cycles', cycles ? cycles.value : '5'); + + setCalibrationActionStatus('Running repeatability test...', false); + postCalibrationRequest('/calibrate/test/repeat', params) + .then(renderCalibrationSnapshot) + .catch(function (error) { + setCalibrationActionStatus(error && error.error ? error.error : 'Repeatability test failed.', true); + }); + return false; +} + +function resetCalibrationCounters() { + setCalibrationActionStatus('Resetting calibration counters...', false); + postCalibrationRequest('/calibrate/counters/reset', new URLSearchParams()) + .then(renderCalibrationSnapshot) + .catch(function (error) { + setCalibrationActionStatus(error && error.error ? error.error : 'Unable to reset counters.', true); + }); + return false; } function toggleCalibrationMotor() { @@ -261,15 +530,15 @@ function initCalibrationMotorStatus() { return; } - function pollMotor() { - fetch('/motor-status', { cache: 'no-store' }) + function pollCalibrationStatus() { + fetch('/calibrate/status', { cache: 'no-store' }) .then(function (r) { return r.json(); }) - .then(applyCalibrationMotorStatus) + .then(renderCalibrationSnapshot) .catch(function () { /* keep polling */ }); } - pollMotor(); - window.setInterval(pollMotor, 1000); + pollCalibrationStatus(); + window.setInterval(pollCalibrationStatus, 1000); } function initSensorIndicator() { diff --git a/spiro/templates/calibrate.html b/spiro/templates/calibrate.html index 4fbc438..3d4957b 100644 --- a/spiro/templates/calibrate.html +++ b/spiro/templates/calibrate.html @@ -1,5 +1,5 @@ {% extends "layout.html" %} -{% block title %}Main view{% endblock %} +{% block title %}Motor calibration{% endblock %} {% block content %}