-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrotary_processor.ino
More file actions
2303 lines (1927 loc) · 66.2 KB
/
rotary_processor.ino
File metadata and controls
2303 lines (1927 loc) · 66.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
PURPOSE Drive a film/paper processor for multiple JOBO tanks (2500-2800 series)
SOURCES
Resistor https://www.hackster.io/mdraber/using-rotary-encoders-with-arduino-interrupts-db3699#code
LCD https://dronebotworkshop.com/lcd-displays-arduino/
https://docs.arduino.cc/learn/electronics/lcd-displays
I2C Scanner: Nick Gammon
Motors https://dronebotworkshop.com/dc-motor-drivers/#Large_Motor_Drivers
H-Bridge IBT-2 (BTS7960) https://dronebotworkshop.com
Keypad https://dronebotworkshop.com/keypads-arduino/
https://forum.arduino.cc/t/using-a-4-by4-keypad-to-get-an-integer/195484/9
https://forum.arduino.cc/t/solved-keypad-number-input-and-store/57566/25
https://forum.arduino.cc/t/setting-values-in-program-using-keypad/577913/15
https://forum.arduino.cc/t/setting-values-in-program-using-keypad/577913/5
** https://forum.arduino.cc/t/converting-keypad-entry-string-to-integer/1037087
States https://forum.arduino.cc/t/control-speed-and-timing-for-dc-motor-using-keypad-input-and-shown-on-lcd-disply/385810/11
by sterretje
Timer https://forum.arduino.cc/t/millis-vs-timestamp/885015
Copyright (c) 2024 Luis Samaniego
Licensed under the GNU License. See LICENSE file in the project root for details.
AUTHOR Luis Samaniego
HISTORY 2024/01 v3.0 Original code
2025/12 v4.0 Bugs and improvements: Beep 5s before end, Menu persistence
Potentiometer controls motor while running,
Development time stored in EEPROM (1..9)
Blacklight
2025/12 v5.x Temperature control with 3 waterproof DS18B20 sensors.
Implemantation in x phases
v5.1.0 Add TEMP page + profiles + simulated temps + CAL framework
v5.1.1 Fix HT drift (remove += integration bug)
v5.1.2 TEMP: profile jump #..#, A/B paging, HT stable (no integrator)
VERSION 5.1.2
*/
// ---------------------
// Include the libraries
// ---------------------
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <Keypad.h>
#include <EEPROM.h>
// ---------------------
// PARAMETERS
// ---------------------
#define DEBUG_MOTOR 0 // set to 1 to enable motor debug prints
#define SIM_TEMPS 0 // 1 = simulated temps, 0 = keep placeholders (-99.9) for now
#define USE_DS18B20 1 // 0 = proxies, 1 = real DS18B20 (when libs + wiring ready)
#define ONE_WIRE_BUS A1 // use analog pin A1 as digital pin for DS18B20 data
#define DS18B20_SCAN 0 // only used when USE_DS18B20==1; prints ROM codes to Serial
#if USE_DS18B20
#include <OneWire.h>
#include <DallasTemperature.h>
#endif
const float HT_GAMMA = 0.20f; // tune later
const float HT_MIN_C = 20.0f;
const float HT_MAX_C = 60.0f;
// ---------------------
// EEPROM PERSISTENCE
// ---------------------
// ---- Development steps (1..9) ----
struct DevStep {
uint8_t minutes;
uint8_t seconds;
uint8_t defined; // 0 = not defined, 1 = defined
};
struct Settings {
uint16_t version;
uint16_t tMinutes;
uint16_t tSeconds;
DevStep devSteps[10]; // use indices 1..9; index 0 unused
uint8_t profileIndex; // stored currentProfile (0..NPROFILES-1)
// Sensor/calibration offsets in 0.1C
int16_t offHtr_10; // H-Tronic offset (0.1C)
int16_t offBath_10; // Bath sensor offset (0.1C)
int16_t offTank_10; // Tank sensor offset (0.1C)
int16_t offBottle_10; // Bottle sensor offset (0.1C)
};
struct DevProfile {
uint8_t id; // 1..9 (used by "# + digit" selection)
const char *process; // "C-41", "RA4", ...
int setC_tenths; // target temperature (0.1C)
uint16_t tank; // e.g. 1521
uint16_t vol_ml; // e.g. 270
int dT_pour_10; // pour cooling (0.1C), e.g. 5 => 0.5C
uint16_t tmin_pre_s; // minimum preheat time in seconds
int dT_boost_10; // optional boost (0.1C), usually 0
};
const DevProfile profiles[] = {
// Film processing
{ 1, "BW ", 200, 2521, 330, 0, 60, 0 },
{ 2, "C41", 380, 2521, 330, 5, 300, 0 },
{ 3, "E6 ", 380, 2521, 330, 5, 300, 0 },
{ 4, "BW ", 200, 2551, 730, 0, 60, 0 },
{ 5, "C41", 380, 2551, 730, 5, 300, 0 },
{ 6, "E6 ", 380, 2551, 730, 5, 300, 0 },
{ 7, "BW ", 200, 2561, 1000, 0, 60, 0 },
{ 8, "C41", 380, 2561, 1000, 5, 300, 0 },
{ 9, "E6 ", 380, 2561, 1000, 5, 300, 0 },
// Paper processing
{ 10, "BW ", 200, 2821, 40, 3, 45, 0 },
{ 11, "RA4", 350, 2821, 40, 3, 45, 0 },
{ 12, "BW ", 200, 2831, 100, 3, 60, 0 },
{ 13, "RA4", 350, 2831, 100, 3, 60, 0 },
{ 14, "BW ", 200, 2841, 120, 3, 60, 0 },
{ 15, "RA4", 350, 2841, 120, 3, 60, 0 },
{ 16, "BW ", 200, 2851, 200, 3, 60, 0 },
{ 17, "RA4", 350, 2851, 200, 3, 60, 0 }
};
const uint8_t NPROFILES = sizeof(profiles) / sizeof(profiles[0]);
uint8_t currentProfile = 1; // default BW
const uint16_t SETTINGS_VERSION = 4; // restarted
Settings settings;
// ---------------------
// DISPLAY PAGES (v5)
// ---------------------
enum DisplayPage {
PAGE_DEV = 0, // classic v4 screen(s)
PAGE_TEMP = 1 // new stub temperature screen
};
DisplayPage currentPage = PAGE_DEV;
// States of the application
enum AppState : byte {
ST_DISPLAY_MAINMENU, // 0
ST_WAIT, // 1
ST_SETTIMING_M, // 2
ST_SETTIMING_S, // 3
ST_STARTMOTOR, // 4
ST_PREHEAT, // 5
ST_IDLE, // 6
ST_CAL // 7
};
// Motor control states
enum MotorCommand : byte {
MOTOR_CONTINUE, // 0
MOTOR_START, // 1
MOTOR_FORCESTOP, // 2
MOTOR_CW, // 3
MOTOR_CCW // 4
};
enum RunMode { MODE_IDLE,
MODE_DEV,
MODE_PREHEAT };
RunMode runMode = MODE_IDLE;
// LCD
const byte ROWS = 4; // Constants for row and column sizes
const byte COLS = 4;
const int I2C_addr = 0x27; // Define I2C Address - found with scanner
// Keypad
char hexaKeys[ROWS][COLS] = { // Array to represent keys on keypad
{ '1', '2', '3', 'A' },
{ '4', '5', '6', 'B' },
{ '7', '8', '9', 'C' },
{ '*', '0', '#', 'D' }
};
// Tank
const int deltaMax = 15; // max adjustement of the angular velocity of the tank [RPM]
const int wTankHigh = 86; // max angular velocity of the tank = max motor velocity [RPM]
const int wCorFactor = 0; // correction factor based on measuments
const int nFullRev = 3; // number of full revolutions of the tank, any direction
// Motor speed
const int wMotorMax = 255; // max motor speed index (in Arduino). Motor max 84 RPM
unsigned long accelCycleTime = 300UL; // [0,accelCycleTime] ms. Acceleration time, 10% total time [s]
// ---------------------
// GLOBAL VARIABLES
// ---------------------
// Potentiometer (rotational speed control)
int potValue; // analog pot reading
// Temperature sensors
float tempBathC = -99.9f;
float tempTankC = -99.9f;
float tempBottleC = -99.9f;
unsigned long nextTempUpdateMs = 0;
const unsigned long TEMP_UI_REFRESH_MS = 1000UL; // redraw TEMP once per second
const unsigned long TEMP_UPDATE_INTERVAL_MS = 1000UL;
unsigned long nextTempUiRefreshMs = 0;
#if USE_DS18B20
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature ds18b20(&oneWire);
// ADD/KEEP these three lines here:
DeviceAddress addrBath = { 0x28, 0x17, 0xB6, 0x54, 0x00, 0x00, 0x00, 0xC5 }; // paste ROM from scan
DeviceAddress addrTank = { 0 }; // paste ROM from scan
DeviceAddress addrBottle = { 0 }; // paste ROM from scan
// Optional: per-sensor offsets (C) for calibration later
float tempOffsetBathC = 0.0f;
float tempOffsetTankC = 0.0f;
float tempOffsetBottleC = 0.0f;
#endif
// Tank
int delta; // Change in rotor angular speed [RPM]
int wTankLow; // min angular speed of the tank [RPM]
int wTank; // adjusted angular speed of the tank [RPM]
float wMean; // average angular speed per cycle [RPM]
int wTankInt; // (75) default Jobo recommendation in [RPM]
// Motor
unsigned long theTiming; // total time to rotate in [s]
unsigned long theCycleTiming; // total time to rotate in a CW or CCW cycle [ms] e.g. 3000UL
unsigned long breakCycleTime; // [breakCycleTime,theCycleTiming] ms. Deacceleration time, 10% total time [s] e.g. 2700UL
int wMotor; // motor speed index in the range [0,255] = f(theSpeed)
int wMotorMin; // min motor speed index (~ 50% of max). Motor min 50 RPM
unsigned long motorRunStartTime; // the 'time' that the motor started running
// Development control (defaults, will be overwritten by EEPROM if valid)
unsigned long tMinutes = 5;
unsigned long tSeconds = 0;
// States of the application
AppState currentState = ST_DISPLAY_MAINMENU; // app state
MotorCommand currentStateMotor = MOTOR_FORCESTOP; // motor initially stopped
// Buzzer state (for 5 s pre-end warning without tone()/Timer2)
bool beepActive = false;
unsigned long beepEndTime = 0;
bool stepStoreMode = false; // true after '#' until a digit or cancel
// Backlight state & key-combo for darkroom mode
bool backlightOn = true; // tracks whether backlight is on
bool awaitOffCombo = false; // true after '*' when waiting for '#'
unsigned long comboStartTime = 0; // when '*' was pressed (for timeout)
// After run finishes, we wait a few seconds and then redraw the menu
bool showMenuAfterStop = false;
unsigned long stopTimeMs = 0;
// For Preheat phase
unsigned long preheatStartTimeMs = 0;
// PREHEAT PWM (direct pot control)
int pwmPreheat = 150; // default
const int PWM_PREHEAT_MIN = 100; // adjust to your motor (must not stall)
const int PWM_PREHEAT_MAX = 255;
bool preheatReady = false;
bool preheatReadyBeepDone = false;
//const unsigned long PREHEAT_READY_MS = 5UL * 60UL * 1000UL; // 5 min
const unsigned long PREHEAT_READY_MS = 10UL * 1000UL; // test only
bool devPreEndBeepDone = false; // one-shot 5 s warning during DEV
// ---- derived (computed) temperatures (Kodak Hg scale after offsets) ----
float tempMeanC = -99.9f; // mean(Bh,Tk,Bo) after offsets
float tempDevC = -99.9f; // Tdev* estimate (not directly observed)
float tempHtrSetC = -99.9f; // suggested H-Tronic dial setting (C)
bool tempDiagMode = false; // TEMP-only diagnostic screen toggle
// ---- tuning parameters (Phase 1) ----
const float GAMMA_HTR = 0.05f; // HT_suggested integrator gain [C/C per 1s update]
// tau [s] = f(volume, rpm)
const float TAU_MIN_S = 10.0f;
const float TAU_MAX_S = 600.0f;
const float TAU_BASE_S = 30.0f;
const float TAU_VOL_S_PER_100ML = 15.0f; // +15s per 100 mL
const float TAU_RPM_S_AT80 = 30.0f; // +30s * (80/rpm)
bool devJustStarted = false;
// TEMP profile jump mode (used also to suppress 1 Hz TEMP redraw)
static bool profSelectMode = false; // now means "#..#" jump mode
static char profBuf[3] = { 0 }; // up to 2 digits + '\0'
static uint8_t profIdx = 0;
// ---------------------
// CALIBRATION (offsets in 0.1C)
// ---------------------
enum CalPage : uint8_t {
CAL_HTR = 0,
CAL_BATH = 1,
CAL_TANK = 2,
CAL_BOTTLE = 3
};
CalPage calPage = CAL_HTR;
// live offsets (0.1C)
int16_t offHtr_10 = 0;
int16_t offBath_10 = 0;
int16_t offTank_10 = 0;
int16_t offBottle_10 = 0;
// ---------------------
// DEFINE PINS
// ---------------------
// Potentiomenter
const int POT_pin = A0; // analog potentiometer
const int BUZZER_PIN = A3; // choose a free digital pin
// Motor (both PWM)
const int RPWM = 10;
const int LPWM = 11;
// LCD
const int en = 2, rw = 1, rs = 0, d4 = 4, d5 = 5, d6 = 6, d7 = 7, bl = 3;
// Keypad
byte rowPins[ROWS] = { 9, 8, 7, 6 }; //connect to the row pinouts of the keypad
byte colPins[COLS] = { 5, 4, 3, 2 }; //connect to the column pinouts of the keypad
// ---------------------
// CREATE OBJECTS
// ---------------------
Keypad keypad = Keypad(makeKeymap(hexaKeys), rowPins, colPins, ROWS, COLS);
LiquidCrystal_I2C lcd(I2C_addr, en, rw, rs, d4, d5, d6, d7, bl, POSITIVE);
// helper functions for EEPROM
void factoryResetSettings() {
settings.version = SETTINGS_VERSION;
settings.tMinutes = 5;
settings.tSeconds = 0;
for (int i = 0; i < 10; ++i) {
settings.devSteps[i].minutes = 0;
settings.devSteps[i].seconds = 0;
settings.devSteps[i].defined = 0;
}
settings.offHtr_10 = 0;
settings.offBath_10 = 0;
settings.offTank_10 = 0;
settings.offBottle_10 = 0;
settings.profileIndex = 1; // default profile (C41)
EEPROM.put(0, settings);
}
void loadSettings() {
EEPROM.get(0, settings);
// During development: any mismatch => clean slate
if (settings.version != SETTINGS_VERSION) {
factoryResetSettings();
EEPROM.get(0, settings); // reload what we just wrote (optional, but clean)
}
// Load persisted timing
tMinutes = settings.tMinutes;
tSeconds = settings.tSeconds;
// Load persisted temp correction offsets
offHtr_10 = settings.offHtr_10;
offBath_10 = settings.offBath_10;
offTank_10 = settings.offTank_10;
offBottle_10 = settings.offBottle_10;
// Load persisted profile safely
currentProfile = (settings.profileIndex < NPROFILES) ? settings.profileIndex : 1;
}
void saveSettings() {
settings.version = SETTINGS_VERSION;
settings.tMinutes = (uint16_t)tMinutes;
settings.tSeconds = (uint16_t)tSeconds;
settings.profileIndex = currentProfile;
settings.offHtr_10 = offHtr_10;
settings.offBath_10 = offBath_10;
settings.offTank_10 = offTank_10;
settings.offBottle_10 = offBottle_10;
EEPROM.put(0, settings);
}
// -----------------------------------------------------------------
// FUNCTION PROTOTYPES
// -----------------------------------------------------------------
char getKeyWithEcho();
char *getKeypadText();
void displayTitleDEV();
void displayMenu();
void displayTimingMenuM();
void displayTimingMenuS();
void displayCounterMenu();
void renderPageTEMP();
void renderPageDEV();
void renderCurrentPage();
void setTankSpeed();
void runMotor(MotorCommand command);
// v5 / preheat helpers
void startPreheatMotor();
void runPreheatMotor();
void stopPreheatMotor();
void startBeep(unsigned long durationMs);
bool isPreheatReady();
void updateTempProxies();
//void updateTemps1Hz();
int rpmFromPwm(int pwm);
void renderCalPage();
static int16_t *calPtrForPage(CalPage p);
static const char *calNameForPage(CalPage p);
void updateTemps(); // chooses DS18B20 or proxies
#if USE_DS18B20
void updateTempsDS18B20(); // real reads (only compiled when enabled)
static bool isAddrSet(const DeviceAddress a);
#endif
#if USE_DS18B20 && DS18B20_SCAN
void scanDs18b20Bus();
static void printDeviceAddress(const DeviceAddress addr);
#endif
static bool computeHtrKnob10(int16_t *outHT10);
void updateDerivedTemps();
static float computeTauS(uint16_t vol_ml, int rpm);
void updateHtrSuggestion1Hz();
bool updateTemps1Hz();
static bool isValidTempC(float tC);
static int16_t roundC_to_10(float tC);
void setup() {
// -------------------
// INITIALIZE
// -------------------
Wire.begin();
Wire.setClock(100000); // Force 100 kHz
// Serial port
Serial.begin(57600);
// LCD
lcd.backlight();
lcd.begin(20, 4);
Serial.println(F("20x4 display"));
backlightOn = true;
// show boot splash for a few seconds (only at boot)
displayBootScreen();
delay(2000); // "few seconds"
// PINS
pinMode(POT_pin, INPUT); // set potentiometer input
pinMode(RPWM, OUTPUT); // set motor connections as outputs
pinMode(LPWM, OUTPUT);
pinMode(BUZZER_PIN, OUTPUT); // Buzzer
digitalWrite(BUZZER_PIN, LOW); // OFF at start (active-high: HIGH=ON)
// Initial motor state set to stop
analogWrite(RPWM, 0); // Stop = 0
analogWrite(LPWM, 0);
// ---- load last used timing from EEPROM ----
loadSettings();
// Initialize Variables
theTiming = tMinutes * 60UL + tSeconds; // Default prewarming time in [s]
delta = 0;
wTankInt = wTankHigh - deltaMax;
wTankLow = wTankInt - deltaMax;
wTank = wTankInt; // start at nominal RPM
wMotorMin = wMotorMax * wTankLow / wTankHigh;
// initialize cycle times here
theCycleTiming = (nFullRev * 60000UL) / wTank + accelCycleTime;
breakCycleTime = theCycleTiming - accelCycleTime;
wMean = (float)(wTank * (theCycleTiming - accelCycleTime) / theCycleTiming);
randomSeed(analogRead(A2)); // A2 floating is OK as seed
#if USE_DS18B20
ds18b20.begin();
ds18b20.setWaitForConversion(false); // non-blocking conversions
#endif
#if USE_DS18B20 && DS18B20_SCAN
scanDs18b20Bus();
#endif
}
void loop() {
// text from keypad
char *text;
// -------------------------
// 1 Hz service block (v5.1)
// -------------------------
if (updateTemps1Hz()) {
updateDerivedTemps(); // uses fresh sensor temps -> updates tempDevC
updateHtrSuggestion1Hz(); // uses tempDevC
}
// Adjust tank speed (with potentiometer)
setTankSpeed();
// PREHEAT speed from pot (direct control)
int pot = analogRead(POT_pin);
pwmPreheat = map(pot, 0, 1023, PWM_PREHEAT_MIN, PWM_PREHEAT_MAX);
pwmPreheat = constrain(pwmPreheat, PWM_PREHEAT_MIN, PWM_PREHEAT_MAX);
// let the motor do what it was doing
if (runMode == MODE_PREHEAT) {
runPreheatMotor();
} else {
runMotor(MOTOR_CONTINUE);
}
serviceDevPreEndBeep(); // beep 5s before end (any display)
preheatReady = isPreheatReady();
// PREHEAT: one-shot READY beep
if (runMode == MODE_PREHEAT && preheatReady && !preheatReadyBeepDone) {
startBeep(200UL);
preheatReadyBeepDone = true;
if (currentPage == PAGE_TEMP) {
currentState = ST_DISPLAY_MAINMENU;
}
}
// refresh display while running (ONLY in ST_WAIT)
// so page switches (ST_DISPLAY_MAINMENU) get one clean draw first
if (currentState == ST_WAIT) {
// TEMP page: refresh periodically even when IDLE
if (currentPage == PAGE_TEMP) {
unsigned long now = millis();
// DO NOT overwrite the jump prompt while waiting for the closing '#'
if (!profSelectMode) {
if (now >= nextTempUiRefreshMs) {
nextTempUiRefreshMs = now + TEMP_UI_REFRESH_MS;
renderCurrentPage();
}
}
}
// DEV page: keep the existing fast refresh only while developing
else if (runMode == MODE_DEV) {
renderCurrentPage(); // DEV countdown needs frequent updates
}
}
// After a timed run finishes, show the main menu again after ~3 s
if (showMenuAfterStop && motorRunStartTime == 0) {
unsigned long now = millis();
if (now - stopTimeMs >= 3000UL) {
if (currentPage == PAGE_DEV) {
displayMenu();
} else {
renderPageTEMP();
}
currentState = ST_WAIT;
showMenuAfterStop = false;
}
}
// --- Beep service ---
if (beepActive && millis() >= beepEndTime) {
digitalWrite(BUZZER_PIN, LOW); // OFF
beepActive = false;
}
//Serial.println(currentState);
switch (currentState) {
case ST_DISPLAY_MAINMENU:
// display main menu
if (currentPage == PAGE_DEV) {
displayMenu();
} else {
renderPageTEMP();
}
// switch to wait state
currentState = ST_WAIT;
break;
case ST_WAIT:
{
unsigned long now = millis();
// -------- persistent combo/profile-mode flags --------
static bool awaitCalCombo = false;
static unsigned long calComboStart = 0;
static bool pendingAProfile = false;
// ----------------------------------------------------
// (A) Handle "A alone" timeout WITHOUT requiring a key
// ----------------------------------------------------
if (awaitCalCombo && pendingAProfile && (now - calComboStart > 800UL)) {
awaitCalCombo = false;
pendingAProfile = false;
// A-alone => cycle profile
currentProfile = (currentProfile + 1) % NPROFILES;
saveSettings();
nextTempUiRefreshMs = 0;
currentState = ST_DISPLAY_MAINMENU;
break;
}
// expire pending "* then #" combo if too old
if (awaitOffCombo && (now - comboStartTime > 1000UL)) {
awaitOffCombo = false;
}
// read raw key (NO echo here)
char rawKey = keypad.getKey();
if (rawKey == NO_KEY) {
break; // nothing pressed
}
// -------------------------------------------------
// 1) BACKLIGHT CONTROL: "*" and "* then #"
// -------------------------------------------------
if (rawKey == '*') {
if (!backlightOn) {
lcd.backlight();
backlightOn = true;
awaitOffCombo = false;
} else {
awaitOffCombo = true;
comboStartTime = now;
}
break;
}
if (rawKey == '#' && awaitOffCombo) {
if (backlightOn) {
lcd.noBacklight();
backlightOn = false;
}
awaitOffCombo = false;
break;
}
// if '#' arrives but no combo pending, let it fall through
awaitOffCombo = false;
// -------------------------------------------------
// 1b) CAL COMBO (TEMP + IDLE): A then B within 800 ms
// -------------------------------------------------
if (currentPage == PAGE_TEMP && runMode == MODE_IDLE) {
if (rawKey == 'A') {
awaitCalCombo = true;
pendingAProfile = true;
calComboStart = now;
break;
}
// A then B quickly => enter CAL (unchanged)
if (rawKey == 'B' && awaitCalCombo && (now - calComboStart <= 800UL)) {
awaitCalCombo = false;
pendingAProfile = false;
profSelectMode = false; // cancel other temp modes
calPage = CAL_HTR; // start CAL at HTR page
currentState = ST_CAL;
break;
}
// NEW: B alone => profile backward
if (rawKey == 'B') {
awaitCalCombo = false;
pendingAProfile = false;
currentProfile = (currentProfile + NPROFILES - 1) % NPROFILES;
saveSettings();
nextTempUiRefreshMs = 0;
currentState = ST_DISPLAY_MAINMENU;
break;
}
// any other key cancels the combo
awaitCalCombo = false;
pendingAProfile = false;
}
// -------------------------------------------------
// 2) STEP PROGRAMMING (DEV): '#' then digit 1..9
// -------------------------------------------------
if (currentPage == PAGE_DEV) {
if (rawKey == '#') {
stepStoreMode = true;
lcd.setCursor(0, 3);
lcd.print(F("Store step: 1...9 "));
break;
}
if (stepStoreMode) {
if (rawKey >= '1' && rawKey <= '9') {
int idx = rawKey - '0';
settings.devSteps[idx].minutes = (uint8_t)tMinutes;
settings.devSteps[idx].seconds = (uint8_t)tSeconds;
settings.devSteps[idx].defined = 1;
stepStoreMode = false;
saveSettings();
displayMenu();
break;
} else {
stepStoreMode = false;
displayMenu();
// fall through to interpret this key normally
}
}
}
// -------------------------------------------------
// 2b) PROFILE SELECT (TEMP): "# <1-2 digits> #"
// Example: #15# jumps to profile with id==15
// -------------------------------------------------
if (currentPage == PAGE_TEMP) {
// If we are already in jump mode, consume digits and closing '#'
if (profSelectMode) {
if (rawKey >= '0' && rawKey <= '9') {
if (profIdx < 2) {
profBuf[profIdx++] = rawKey;
profBuf[profIdx] = '\0';
lcd.setCursor(0, 3);
lcd.print(F("Jump to P:#..# ")); // 20
lcd.setCursor(11, 3); // after "Jump P:"
lcd.print(profBuf);
}
break;
}
if (rawKey == '#') {
// apply jump
int want = atoi(profBuf); // 0..99 (we use 1..N)
profSelectMode = false;
profIdx = 0;
profBuf[0] = '\0';
bool found = false;
for (uint8_t i = 0; i < NPROFILES; i++) {
if (profiles[i].id == (uint8_t)want) {
currentProfile = i; // index in profiles[]
found = true;
break;
}
}
if (found) {
saveSettings();
nextTempUiRefreshMs = 0;
}
currentState = ST_DISPLAY_MAINMENU;
break;
}
// any other key: cancel jump mode and continue processing this key normally
profSelectMode = false;
profIdx = 0;
profBuf[0] = '\0';
// no break here
}
// keep your diag toggle (but don't steal '0' while jumping)
if (rawKey == '0') {
tempDiagMode = !tempDiagMode;
currentState = ST_DISPLAY_MAINMENU;
break;
}
// start jump mode
if (rawKey == '#') {
profSelectMode = true;
profIdx = 0;
profBuf[0] = '\0';
lcd.setCursor(0, 3);
lcd.print(F("Jump to P:#..# ")); // 20
break;
}
}
// -------------------------------------------------
// 3) STEP RECALL (DEV): short press 1..9
// -------------------------------------------------
if (currentPage == PAGE_DEV) {
if (rawKey >= '1' && rawKey <= '9') {
int idx = rawKey - '0';
if (settings.devSteps[idx].defined) {
tMinutes = settings.devSteps[idx].minutes;
tSeconds = settings.devSteps[idx].seconds;
theTiming = tMinutes * 60UL + tSeconds;
saveSettings();
displayMenu();
}
break;
}
}
// -------------------------------------------------
// 4) NORMAL MENU KEYS A/B/C/D
// -------------------------------------------------
if (rawKey == 'A') {
if (currentPage == PAGE_TEMP) {
// NOTE: In TEMP, A is handled by CAL logic above (A then B, or A-alone timeout).
// So here we ignore A on TEMP.
break;
} else {
displayTimingMenuM();
currentState = ST_SETTIMING_M;
break;
}
} else if (rawKey == 'B') {
if (currentPage == PAGE_DEV) {
displayTimingMenuS();
currentState = ST_SETTIMING_S;
}
break;
} else if (rawKey == 'C') {
if (runMode != MODE_IDLE) {
currentState = ST_IDLE;
if (currentPage == PAGE_TEMP) renderPageTEMP();
else displayMenu();
} else {
if (currentPage == PAGE_DEV) {
displayMenu();
currentState = ST_STARTMOTOR;
} else {
currentState = ST_PREHEAT;
renderPageTEMP();
}
}
// cancel temp submodes when starting/stopping
awaitCalCombo = false;
pendingAProfile = false;
profSelectMode = false;
break;
} else if (rawKey == 'D') {
// If idle, D toggles page
currentPage = (currentPage == PAGE_DEV) ? PAGE_TEMP : PAGE_DEV;
stepStoreMode = false;
awaitCalCombo = false;
pendingAProfile = false;
profSelectMode = false;
currentState = ST_DISPLAY_MAINMENU;
break;
}
// any other key is ignored in WAIT
break;
}
break; // IMPORTANT: do not fall-through to ST_SETTIMING_M
case ST_SETTIMING_M:
// get the text entered on the keypad
text = getKeypadText();
// if text complete
if (text != NULL) {
// if user did enter a timming
if (text[0] != '\0') {
//theTiming = atoi(text);
tMinutes = atoi(text);
}
// actualize the total timing
theTiming = tMinutes * 60UL + tSeconds;
saveSettings(); // keep last time of turn off
currentState = ST_DISPLAY_MAINMENU;
}
break;
case ST_SETTIMING_S:
// get the text entered on the keypad
text = getKeypadText();
// if text complete
if (text != NULL) {
// if user did enter a timming
if (text[0] != '\0') {
tSeconds = atoi(text);
}
// actualize the total timing
theTiming = tMinutes * 60UL + tSeconds;
saveSettings(); // <-- NEW
currentState = ST_DISPLAY_MAINMENU;
}
break;
case ST_STARTMOTOR:
// start motor
devPreEndBeepDone = false;
runMotor(MOTOR_START);
runMode = MODE_DEV;
devJustStarted = true;
currentState = ST_DISPLAY_MAINMENU;
break;
case ST_PREHEAT:
startPreheatMotor();
renderPageTEMP(); // show status immediately
currentState = ST_WAIT; // return to key handling
break;
case ST_CAL:
{
static bool calJustEntered = true;
static unsigned long nextCalUiRefreshMs = 0;
unsigned long now = millis();
// refresh CAL view once per second (so you see live temperature changes)
if (now >= nextCalUiRefreshMs) {
nextCalUiRefreshMs = now + 1000UL;
renderCalPage();
}
if (calJustEntered) {
// force an immediate draw on entry
nextCalUiRefreshMs = 0;
calJustEntered = false;
}
char k = keypad.getKey();
if (k == NO_KEY) break;
// -------------------------------------------------
// CAL UX (your spec):
// A => +0.1
// B => -0.1
// C => Save
// D => Return (discard)
// # => Next sensor page
// * => Previous sensor page
// -------------------------------------------------
if (k == '#') {
calPage = (CalPage)((((uint8_t)calPage) + 1) % 4);
nextCalUiRefreshMs = 0;
renderCalPage();
break;
}
if (k == '*') {
calPage = (CalPage)((((uint8_t)calPage) + 3) % 4);
nextCalUiRefreshMs = 0;
renderCalPage();
break;
}
int16_t *p = calPtrForPage(calPage);
if (k == 'A') {
if (*p < 999) *p += 1; // +0.1C
renderCalPage();
break;
}