-
Notifications
You must be signed in to change notification settings - Fork 0
/
describe_your_espresso.dsx
7836 lines (6805 loc) · 307 KB
/
describe_your_espresso.dsx
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
#######################################################################################################################
### A Decent DE1 app plugin for the DSx skin that improves the default logging / "describe your espresso"
### functionality in Insight and DSx.
###
### INSTALLATION:
### 1) Ensure you have DE1 app v1.33 stable (except for fontawesome symbols, which may need to be downloaded manually)
### or higher, and DSx version v4.39 or higher.
### 2) Copy this file "describe_your_espresso.dsx" to the "de1_plus/skins/DSx/DSx_Plugins" folder.
### 3) Restart the app with DSx as skin.
###
### Features:
### 1) "Describe your espresso" accesible from DSx home screen with a single click, for both next and last shots.
### 2) All main description data in a single screen for easier data entry.
### * Irrelevant options ("I weight my beans" / "I use a refractometer") are removed.
### 3) Facilitate data entry in the UI:
### * Numeric fields can be typed directly.
### * Keyboard return in non-multiline entries take you directly to the next field.
### * Choose categories fields (bean brand, type, grinder, etc) from a list of all previously typed values.
### * Star-rating system for Enjoyment
### * Mass-modify past entered categories values at once.
### 4) Description data from previous shot can now be retrieved and modified:
### * A summary is shown on the History Viewer page, below the profile on both the left and right shots.
### * When that summary is clicked, the describe page is open showing the description for the past shot,
### which can be modified.
### 5) Create a SQLite database of shot descriptions.
### * Populate on startup
### * User decides what is to be stored in the database.
### * Update whenever there are new shots or shot data changes
### * Update on startup when a shot file has been changed on disk (TODO using a simple/fast test, some cases
### may be undetected, review)
### * TBD Persist profiles too (as an option)
### 6) "Filter Shot History" page callable from the history viewer to restrict the shots being shown on both
### left and right listboxes.
### 7) TBD Add new description data: other equipment, beans details (country, variety), detailed coffee ratings like
## in cupping scoring sheets, etc.
### 8) Upload shot files to Miha's visualizer or other repositories with a button press.
### 9) Configuration page allows defining settings and launch database maintenance actions from within the app.
###
### Source code available in GitHub: https://github.com/ebengoechea/dye_de1app_dsx_plugin/
### This code is released under GPLv3 license. See LICENSE file under the DE1 source folder in github.
###
### By Enrique Bengoechea <enri.bengoechea@gmail.com>
### (with lots of copy/paste/tweak from Damian, John and Johanna's code!)
########################################################################################################################
package provide describe_your_espresso.dsx 1.17
package require sqlite3
package require http
package require tls
package require json
package require zipfs
variable version {1.17}
########################################################################################################################
### VARIABLES DECLARATIONS
namespace eval DYE {
variable plugin_version {1.17}
variable plugin_file "describe_your_espresso.dsx"
variable db_version 4
variable min_DSx_version {4.39}
variable min_de1app_version {1.34}
variable settings_path "[skin_directory]/DSx_User_Set/DYE_settings.tdb"
variable filename_clock_format "%Y%m%dT%H%M%S"
variable friendly_clock_format "%Y/%m/%d %H:%M"
variable debug_text {}
variable settings
array set settings {}
# DATA DICTIONARY CONVENTIONS:
# * The column name in the shot table or the lookup table must be identical to the array item key.
# * The shot_field is the variable name in the shot file, settings section. May not match the array item key in
# cases like 'other_equipment'.
# * desc_section has to be one of bean, bean_batch, equipment, extraction or people.
# * data_type has to be one of text, long_text, category, numeric or date.
variable field_lookup_whats {name name_plural short_name short_name_plural \
desc_section db_table lookup_table db_type_column1 db_type_column2 shot_field data_type \
min_value max_value n_decimals default_value small_increment big_increment
}
variable data_dictionary
array set data_dictionary {
profile_title {"Profile" "Profiles" "Profile" "Profiles" \
"" shot "" "" "" profile_title category 0 0 0}
bean_desc {"Beans" "Beans" "Beans" "Beans" \
bean V_shot "" "" "" bean_brand||bean_type category 0 0 0}
bean_brand {"Beans roaster" "Beans roasters" "Roaster" "Roasters" \
bean shot "" "" "" bean_brand category 0 0 0}
bean_type {"Beans type" "Beans types" "Name" "Names" \
bean shot "" "" "" bean_type category 0 50 0}
bean_notes {"Beans notes" "Beans notes" "Note" "Notes" \
bean_batch shot "" "" "" bean_notes long_text 0 1000 0}
roast_date {"Roast date" "Roast dates" "Date" "Dates" \
bean_batch shot "" "" "" roast_date date 0 0 0}
roast_level {"Roast level" "Roast levels" "Level" "Levels" \
bean_batch shot "" "" "" roast_level category 0 50 0}
grinder_model {"Grinder name" "Grinder names" "Grinder" "Grinders" \
equipment shot "" "" "" grinder_model category 0 100 0}
grinder_setting {"Grinder setting" "Grinder settings" "Setting" "Settings" \
equipment shot "" "" "" grinder_setting category 0 100 0}
grinder_dose_weight {"Dose weight" "Dose weights" "Dose" "Doses" \
extraction shot "" "" "" grinder_dose_weight numeric 0 30 1 18 0.1 1.0}
drink_weight {"Drink weight" "Drink weights" "Weight" "Weights" \
extraction shot "" "" "" drink_weight numeric 0 500 1 36 1.0 10.0}
drink_tds {"Total Dissolved Solids (TDS %)" "Total Dissolved Solids %" "TDS" "TDS" \
extraction shot "" "" "" drink_tds numeric 0 15 2 8 0.01 0.1}
drink_ey {"Extraction Yield (EY %)" "Extraction Yields %" "EY" "EYs" \
extraction shot "" "" "" drink_ey numeric 0 30 2 20 0.1 1.0}
espresso_enjoyment {"Enjoyment (0-100)" "Enjoyments" "Enjoyment" "Enjoyment" \
extraction shot "" "" "" espresso_enjoyment numeric 0 100 0 50 1 10}
espresso_notes {"Notes" "Notes" "Notes" "Notes" \
extraction shot "" "" "" espresso_notes long_text 0 1000 0}
my_name {"Barista" "Baristas" "Barista" "Baristas" \
people shot "" "" "" my_name category 0 100 0 people}
drinker_name {"Drinker" "Drinkers" "Drinker" "Drinkers" \
people shot "" "" "" drinker_name category 0 100 0}
}
# equipment_type {"Equipment type" "Equipment types" "Type" "Types" \
# equipment shot_equipment equipment_type "" "" other_equipment category 0 100 0}
# equipment_name {"Equipment name" "Equipment names" "Equipment" "Equipment" \
# equipment shot_equipment equipment equipment_type "" other_equipment category 0 100 0}
# equipment_setting {"Equipment setting" "Equipment settings" "Setting" "Settings" \
# equipment shot_equipment "" equipment_type equipment_name other_equipment category 0 100 0}
variable desc_text_fields {bean_brand bean_type roast_date roast_level bean_notes grinder_model grinder_setting \
espresso_notes my_name drinker_name skin repository_links}
# variable desc_text_fields {bean_brand bean_type roast_date roast_level bean_notes grinder_model grinder_setting \
# other_equipment espresso_notes my_name drinker_name skin repository_links}
variable desc_numeric_fields {grinder_dose_weight drink_weight drink_tds drink_ey espresso_enjoyment}
variable propagated_fields {bean_brand bean_type roast_date roast_level bean_notes grinder_model grinder_setting \
my_name drinker_name}
# variable propagated_fields {bean_brand bean_type roast_date roast_level bean_notes grinder_model grinder_setting \
# other_equipment my_name drinker_name}
variable last_shot_desc {}
variable next_shot_desc {}
variable past_shot_desc {}
variable past_shot_desc_one_line {}
variable past_shot_desc2 {}
variable past_shot_desc_one_line2 {}
}
### PLUGIN WORKFLOW ###################################################################################################
# Startup the Describe Your Espresso plugin.
proc ::DYE::main {} {
check_versions
load_settings
check_settings
DB::init
GUI::setup_aspect
foreach ns {DE IS NUME FSH CFG SEQ MODC MENU TXT GUI} { ::DYE::${ns}::setup_ui }
save_settings
if { [ifexists ::debugging 0] == 1 && $::android != 1 } {
ifexists ::debugging_window_title "Decent"
wm title . "$::debugging_window_title DYE v$::DYE::plugin_version"
}
}
# Verify the minimum required versions of DE1 app & DSx skin are used, otherwise prevents startup.
proc ::DYE::check_versions {} {
if { [package vcompare $::DSx_settings(version) $::DYE::min_DSx_version] < 0 } {
message_page "[translate {Plugin 'Describe Your Espreso'}] v$::DYE::plugin_version [translate requires]\
DSx v$::DYE::min_DSx_version [translate {or higher}]\r\r[translate {Current DSx version is}] $::DSx_settings(version)" \
[translate Ok]
}
if { [package vcompare [package version de1app] $::DYE::min_de1app_version] < 0 } {
message_page "[translate {Plugin 'Describe Your Espreso'}] v$::DYE::plugin_version [translate requires] \
DE1app v$::DYE::min_de1app_version [translate {or higher}]\r\r[translate {Current DE1app version is}] [package version de1app]" \
[translate Ok]
}
}
# Loads settings from DYE::settings.tbd file.
proc ::DYE::load_settings {} {
array set ::DYE::settings [encoding convertfrom utf-8 [read_binary_file $DYE::settings_path]]
}
# Ensure all settings values are defined, otherwise set them to their default values.
proc ::DYE::check_settings {} {
variable settings
if {[info exists settings(db_path)] == 0} {
set settings(db_path) "skins/DSx/DSx_User_Set/shots.db"
} elseif { ! [file exists "[homedir]/$settings(db_path)"] } {
set settings(db_path) "skins/DSx/DSx_User_Set/shots.db"
}
ifexists settings(calc_ey_from_tds) on
ifexists settings(show_shot_desc_on_home) 1
ifexists settings(shot_desc_font_color) $::DYE::GUI::default_shot_desc_font_color
ifexists settings(describe_from_sleep) 1
ifexists settings(date_format) "%d/%m/%Y"
ifexists settings(describe_icon) $DYE::GUI::symbol_cup
ifexists settings(propagate_previous_shot_desc) 1
ifexists settings(backup_modified_shot_files) 0
ifexists settings(db_persist_desc) 1
ifexists settings(db_persist_series) 0
ifexists settings(use_stars_to_rate_enjoyment) 1
ifexists settings(next_shot_DSx_home_coords) {500 1150}
ifexists settings(last_shot_DSx_home_coords) {2120 1150}
ifexists settings(github_latest_url) "https://api.github.com/repos/ebengoechea/dye_de1app_dsx_plugin/releases/latest"
# Propagation mechanism
ifexists settings(next_modified) 0
foreach field_name "$::DYE::propagated_fields espresso_notes" {
if { ! [info exists settings(next_$field_name)] } {
set settings(next_$field_name) {}
}
}
if { $settings(next_modified) == 0 } {
if { $settings(propagate_previous_shot_desc) == 1 } {
foreach field_name $::DYE::propagated_fields {
set settings(next_$field_name) $::settings($field_name)
}
set settings(next_espresso_notes) {}
} else {
foreach field_name "$::DYE::propagated_fields next_espresso_notes" {
set settings(next_$field_name) {}
}
}
}
ifexists settings(visualizer_url) "visualizer.coffee"
ifexists settings(visualizer_endpoint) "api/shots/upload"
if { ![info exists settings(visualizer_username)] } { set settings(visualizer_username) {} }
if { ![info exists settings(visualizer_password)] } { set settings(visualizer_password) {} }
if { ![info exists settings(last_visualizer_result)] } { set settings(last_visualizer_result) {} }
ifexists settings(auto_upload_to_visualizer) 0
ifexists settings(min_seconds_visualizer_auto_upload) 6
if { ![info exists settings(last_sync_clock)] } {
set settings(last_sync_clock) 0
foreach fn "analyzed inserted modified archived unarchived removed unremoved" {
set settings(last_sync_$fn) 0
}
}
set settings(version) $DYE::plugin_version
set settings(db_version) $DYE::db_version
# Ensure load_DSx_past_shot and load_DSx_past2_shot in DSx includes exactly all fields we need when they load the
# shots.
if { $::settings(skin) eq "DSx" } {
# clock drink_weight grinder_dose_weight - already included
set ::DSx_settings(extra_past_shot_fields) {bean_brand bean_type roast_date \
roast_level bean_notes grinder_model grinder_setting drink_tds drink_ey espresso_enjoyment \
espresso_notes my_name drinker_name scentone skin beverage_type final_desired_shot_weight repository_links}
}
# set ::DSx_settings(extra_past_shot_fields) {bean_brand bean_type roast_date \
#roast_level bean_notes grinder_model grinder_setting other_equipment drink_tds drink_ey espresso_enjoyment \
#espresso_notes my_name drinker_name scentone skin beverage_type final_desired_shot_weight repository_links}
}
# Save settings to the DYE::settings.tbd file.
proc ::DYE::save_settings {} {
msg "DYE: saving Describe Your Espresso settings"
::save_DSx_array_to_file DYE::settings $DYE::settings_path
}
# Returns a string with the summary description of the current (last) shot.
# Needs the { args } as this is being used in a trace add execution.
proc ::DYE::define_last_shot_desc { args } {
if { $::DYE::settings(show_shot_desc_on_home) == 1 } {
if { $::settings(history_saved) == 1 } {
set ::DYE::last_shot_desc [shot_description_summary $::settings(bean_brand) \
$::settings(bean_type) $::settings(roast_date) $::settings(grinder_model) \
$::settings(grinder_setting) $::settings(drink_tds) $::settings(drink_ey) \
$::settings(espresso_enjoyment) 3]
} else {
set ::DYE::last_shot_desc "\[ Shot not saved to history \]"
}
} else {
set ::DYE::last_shot_desc ""
}
}
# Hook executed after save_espresso_rating_to_history
proc ::DYE::save_espresso_to_history_hook { args } {
if { $::settings(history_saved) == 1 } {
msg "DYE: save_espresso_to_history_hook "
::DYE::define_last_shot_desc
if { $::DYE::settings(auto_upload_to_visualizer) == 1 && $::DYE::settings(visualizer_username) ne "" &&
$::DYE::settings(visualizer_password) ne "" } {
set bev_type [ifexists ::settings(beverage_type) "espresso"]
if {[espresso_elapsed range end end] >= $::DYE::settings(min_seconds_visualizer_auto_upload) &&
$bev_type ne "cleaning" && $bev_type ne "calibrate"} {
set repo_link [::DYE::upload_to_visualizer_and_save $::settings(espresso_clock)]
if { $::settings(repository_links) eq "" } {
set ::settings(repository_links) $repo_link
} elseif { $::settings(repository_links) ne $repo_link } {
lappend ::settings(repository_links) $repo_link
}
}
}
if { $::DYE::settings(db_persist_desc) == 1 || $::DYE::settings(db_persist_series) == 1 } {
# We need the shot data in DYE::DB::persist_shot in an array that is a bit different from ::settings,
# e.g. "clock" is "espresso_clock" in the settings, chart series are not in ::settings but in other vars,
# we miss the filename and the modification time, and we need to build some variables with a priority
# (like dose may come from DSx vars or from base vars). So, rather than replicate everything, we just read
# the just-written file, which is not highly efficient but it's very straightforward.
#set fn "[homedir]/history/[clock format $::settings(espresso_clock) -format $::DYE::filename_clock_format].shot"
array set shot [::DYE::load_shot $::settings(espresso_clock)]
::DYE::DB::persist_shot shot $::DYE::settings(db_persist_desc) $::DYE::settings(db_persist_series) 1
}
}
}
# Returns a string with the summary description of the shot selected on the left side of the DSx History Viewer.
# Needs the { args } as this is being used in a trace add execution.
proc ::DYE::define_past_shot_desc { args } {
if { $::settings(skin) eq "DSx" && [info exists ::DSx_settings(past_bean_brand)] } {
set ::DYE::past_shot_desc [shot_description_summary $::DSx_settings(past_bean_brand) \
$::DSx_settings(past_bean_type) $::DSx_settings(past_roast_date) $::DSx_settings(past_grinder_model) \
$::DSx_settings(past_grinder_setting) $::DSx_settings(past_drink_tds) $::DSx_settings(past_drink_ey) \
$::DSx_settings(past_espresso_enjoyment)]
set ::DYE::past_shot_desc_one_line [shot_description_summary $::DSx_settings(past_bean_brand) \
$::DSx_settings(past_bean_type) $::DSx_settings(past_roast_date) $::DSx_settings(past_grinder_model) \
$::DSx_settings(past_grinder_setting) $::DSx_settings(past_drink_tds) $::DSx_settings(past_drink_ey) \
$::DSx_settings(past_espresso_enjoyment) 1 ""]
} else {
set ::DYE::past_shot_desc ""
set ::DYE::past_shot_desc_one_line ""
}
}
# Returns a string with the summary description of the shot selected on the right side of the DSx History Viewer.
# Needs the { args } as this is being used in a trace add execution.
proc ::DYE::define_past_shot_desc2 { args } {
if { $::settings(skin) eq "DSx" } {
if {$::DSx_settings(history_godshots) == "history" && [info exists ::DSx_settings(past_bean_brand2)] } {
set ::DYE::past_shot_desc2 [shot_description_summary $::DSx_settings(past_bean_brand2) \
$::DSx_settings(past_bean_type2) $::DSx_settings(past_roast_date2) $::DSx_settings(past_grinder_model2) \
$::DSx_settings(past_grinder_setting2) $::DSx_settings(past_drink_tds2) $::DSx_settings(past_drink_ey2) \
$::DSx_settings(past_espresso_enjoyment2)]
set ::DYE::past_shot_desc_one_line2 [shot_description_summary $::DSx_settings(past_bean_brand2) \
$::DSx_settings(past_bean_type2) $::DSx_settings(past_roast_date2) $::DSx_settings(past_grinder_model2) \
$::DSx_settings(past_grinder_setting2) $::DSx_settings(past_drink_tds2) $::DSx_settings(past_drink_ey2) \
$::DSx_settings(past_espresso_enjoyment2) 1 ""]
} else {
set ::DYE::past_shot_desc2 ""
set ::DYE::past_shot_desc_one_line2 ""
}
} else {
set ::DYE::past_shot_desc2 ""
set ::DYE::past_shot_desc_one_line2 ""
}
}
# Returns a string with the summary description of the next shot.
# Needs the { args } as this is being used in a trace add execution.
proc ::DYE::define_next_shot_desc { args } {
if { $::DYE::settings(show_shot_desc_on_home) == 1 && [info exists ::DYE::settings(next_bean_brand)] } {
set desc [shot_description_summary $::DYE::settings(next_bean_brand) \
$::DYE::settings(next_bean_type) $::DYE::settings(next_roast_date) $::DYE::settings(next_grinder_model) \
$::DYE::settings(next_grinder_setting) {} {} {} 2 "\[Tap to describe the next shot\]" ]
if { $::DYE::settings(next_modified) == 1 } { append desc " *" }
set ::DYE::next_shot_desc $desc
} else {
set ::DYE::next_shot_desc ""
}
}
# Returns a 2 or 3-lines formatted string with the summary of a shot description.
proc ::DYE::shot_description_summary { {bean_brand {}} {bean_type {}} {roast_date {}} {grinder_model {}} \
{grinder_setting {}} {drink_tds 0} {drink_ey 0} {espresso_enjoyment 0} {lines 2} \
{default_if_empty "Tap to describe this shot" }} {
set shot_desc ""
set beans_items [list_remove_element [list $bean_brand $bean_type $roast_date] ""]
set grinder_items [list_remove_element [list $grinder_model $grinder_setting] ""]
set extraction_items {}
if {$drink_tds > 0} { lappend extraction_items "[translate TDS] $drink_tds\%" }
if {$drink_ey > 0} { lappend extraction_items "[translate EY] $drink_ey\%" }
if {$espresso_enjoyment > 0} { lappend extraction_items "[translate Enjoyment] $espresso_enjoyment" }
set each_line {}
if {[llength $beans_items] > 0} { lappend each_line [string trim [join $beans_items " "]] }
if {[llength $grinder_items] > 0} { lappend each_line [string trim [join $grinder_items " \@ "]] }
if {[llength $extraction_items] > 0} { lappend each_line [string trim [join $extraction_items ", "]] }
if { $lines == 1 } {
set shot_desc [join $each_line " \- "]
} elseif { $lines == 2 } {
if {[llength $each_line] == 3} {
set shot_desc "[lindex $each_line 0] \- [lindex $each_line 1]\n[lindex $each_line 2]"
} else {
set shot_desc [join $each_line "\n"]
}
} else {
set shot_desc [join $each_line "\n"]
}
if {$shot_desc eq ""} {
set shot_desc "\[[translate $default_if_empty]\]"
}
return $shot_desc
}
# Looks up fields metadata in the data dictionary. 'what' can be a list with multiple items, then a list is returned.
proc ::DYE::field_lookup { field {what name} } {
if { $field eq "" } return
if { ![info exists ::DYE::data_dictionary($field)] } {
msg "DYE: ERROR data field '$field' unmatched in proc field_lookup"
return {}
}
set result {}
foreach whatpart $what {
set match_idx [lsearch -all $::DYE::field_lookup_whats $whatpart]
if { $match_idx == -1 } {
msg "DYE: ERROR what item '$whatpart' unmatched in proc field_lookup"
lappend result {}
} else {
lappend result [lindex $::DYE::data_dictionary($field) $match_idx]
}
}
if { [llength $result] == 1 } { set result [lindex $result 0] }
return $result
}
# Loads from a shot file the data we use in the DYE plugin. Returns an array.
# Input can be a filename, with or without .shot extension, a clock value, or a full path to a shot file.
proc ::DYE::load_shot { filename } {
set path [get_shot_file_path $filename]
if { $path eq "" } return
msg "DYE: loading shot file $path"
array set shot_data {}
array set file_props [encoding convertfrom utf-8 [read_binary_file $path]]
if { [file tail [file dirname $path]] eq "history_archive" } {
set shot_data(comes_from_archive) 1
} else {
set shot_data(comes_from_archive) 0
}
set shot_data(path) $path
set shot_data(filename) [file rootname [file tail $path]]
set shot_data(file_modification_date) [file mtime $path]
set shot_data(clock) $file_props(clock)
set shot_data(date_time) [clock format $file_props(clock) -format {%a, %d %b %Y %I:%M%p}]
if {[llength [ifexists file_props(espresso_elapsed)]] > 0} {
set shot_data(espresso_elapsed) $file_props(espresso_elapsed)
set shot_data(extraction_time) [round_to_one_digits [expr ([lindex $file_props(espresso_elapsed) end]+0.05)]]
} else {
set shot_data(espresso_elapsed) {0.0}
set shot_data(extraction_time) 0.0
}
foreach field_name {espresso_pressure espresso_weight espresso_flow espresso_flow_weight \
espresso_temperature_basket espresso_temperature_mix espresso_flow_weight_raw espresso_water_dispensed \
espresso_temperature_goal espresso_pressure_goal espresso_flow_goal espresso_state_change } {
if { [info exists file_props($field_name)] } {
set shot_data($field_name) $file_props($field_name)
} else {
set shot_data($field_name) {0.0}
}
}
array set file_sets $file_props(settings)
set text_fields $::DYE::desc_text_fields
lappend text_fields profile_title skin beverage_type
foreach field_name $text_fields {
if { [info exists file_sets($field_name)] == 1 } {
set shot_data($field_name) [string trim $file_sets($field_name)]
} else {
set shot_data($field_name) {}
}
}
foreach field_name $::DYE::desc_numeric_fields {
if { [info exists file_sets($field_name)] == 1 && $file_sets($field_name) > 0 } {
set shot_data($field_name) $file_sets($field_name)
} else {
# We use {} instead of 0 to get DB NULLs and empty values in entry textboxes
set shot_data($field_name) {}
}
}
if { $shot_data(grinder_dose_weight) eq "" } {
if {[info exists file_sets(DSx_bean_weight)] == 1} {
set shot_data(grinder_dose_weight) $file_sets(DSx_bean_weight)
} elseif {[info exists file_sets(dsv4_bean_weight)] == 1} {
set shot_data(grinder_dose_weight) $file_sets(dsv4_bean_weight)
} elseif {[info exists file_sets(dsv3_bean_weight)] == 1} {
set shot_data(grinder_dose_weight) $file_sets(dsv3_bean_weight)
} elseif {[info exists file_sets(dsv2_bean_weight)] == 1} {
set shot_data(grinder_dose_weight) $file_sets(dsv2_bean_weight)
}
}
return [array get shot_data]
}
# Builds a full path to a filename and returns the path if the file exists, otherwise an empty string.
# If the filename happens to be an integer number, it is assumed it's a clock rather than a filename, and it is
# transformed to a shot filename.
# If the filename does not have ".shot" extension, adds it.
# If the filename is already a full path and the file exists, returns it. If it's just the filename, checks
# existence of file first in history folder, then in history_archive folder.
proc ::DYE::get_shot_file_path { filename } {
if { [string is integer $filename] } {
set filename "[clock format $filename -format $::DYE::filename_clock_format].shot"
} elseif { [string range $filename end-4 end] ne ".shot" } { append filename ".shot" }
if { [file dirname $filename] eq "." } {
if { [file exists "[homedir]/history/$filename"] } {
return "[homedir]/history/$filename"
} elseif { [file exists "[homedir]/history_archive/$filename"] } {
return "[homedir]/history_archive/$filename"
}
} elseif { [file exists $filename] } {
return $filename
}
return ""
}
# Reads a shot file, modifies the settings that are defined in arr_new_settings, and optionally backups the old file
# before modifying and updates the file in disk. Returns the string with the text that is/would be written to disk.
# This is normally called with write_file=1, but can be invoked with write_file=0 only to use the return string,
# for example for Visualizer uploads.
# Multivalued settings such as parts of "other_equipment" are flagged with a "~" initial character and are handled differently:
# ~equipment_X (X=type/name/setting): a 2-items list with the old and new equipment value to replace in the 'other_equipment' list.
proc ::DYE::modify_shot_file { path arr_new_settings { backup_file {} } { write_file 1 } } {
upvar $arr_new_settings new_settings
if { $backup_file eq {} } {
set backup_file $::DYE::settings(backup_modified_shot_files)
}
set path [get_shot_file_path $path]
array set past_props [encoding convertfrom utf-8 [read_binary_file $path]]
array set past_sets $past_props(settings)
array set past_mach $past_props(machine)
foreach key [array names new_settings] {
if { [string range $key 0 0] eq "~" } {
if { $key eq "~equipment_type" } {
if { [llength $new_settings($key)] == 2 && [info exists past_sets(other_equipment)] } {
#set old_value $past_sets(other_equipment)
set new_settings(other_equipment) [modify_other_equipment $past_sets(other_equipment) [string range $key 1 9999] \
[lindex $new_settings($key) 0] [lindex $new_settings($key) 1]]
set key other_equipment
} else {
msg "DYE: new_settings($key)='$new_settings($key)' malformed or other_equipment doesn't exist, when modifying shot file '[file tail $path]'"
continue
}
} elseif { $key eq "~equipment_name" } {
if { [llength $new_settings($key)] == 3 && [info exists past_sets(other_equipment)] } {
#set old_value $past_sets(other_equipment)
set new_settings(other_equipment) [modify_other_equipment $past_sets(other_equipment) [string range $key 1 9999] \
[lindex $new_settings($key) 0] [lindex $new_settings($key) 1] [lindex $new_settings($key) 2]]
set key other_equipment
} else {
msg "DYE: new_settings($key)='$new_settings($key)' malformed or other_equipment doesn't exist, when modifying shot file '[file tail $path]'"
continue
}
} elseif { $key eq "~equipment_settting" } {
if { [llength $new_settings($key)] == 4 && [info exists past_sets(other_equipment)] } {
#set old_value $past_sets(other_equipment)
set new_settings(other_equipment) [modify_other_equipment $past_sets(other_equipment) [string range $key 1 9999] \
[lindex $new_settings($key) 0] [lindex $new_settings($key) 1] [lindex $new_settings($key) 2] [lindex $new_settings($key) 3]]
set key other_equipment
} else {
msg "DYE: new_settings($key)='$new_settings($key)' malformed or other_equipment doesn't exist, when modifying shot file '[file tail $path]'"
continue
}
} else {
msg "DYE: key $key in new_settings not recognized when modifying shot file '[file tail $path]'"
continue
}
if { [info exists past_sets($key)] } {
msg "DYE: Modified $key from '$past_sets($key)' to '$new_settings($key)' in shot file '[file tail $path]'"
} else {
msg "DYE: Added new $key='$new_settings($key)' in shot file '[file tail $path]'"
}
} elseif { [info exists past_sets($key)] } {
#set old_value $past_sets($key)
msg "DYE: Modified $key from '$past_sets($key)' to '$new_settings($key)' in shot file '[file tail $path]'"
} else {
#set old_value {}
msg "DYE: Added new $key='$new_settings($key)' in shot file '[file tail $path]'"
}
set past_sets($key) $new_settings($key)
}
set espresso_data {}
# Sort the variables in the first part of the file exactly as in the original.
set default_pars {clock espresso_elapsed espresso_pressure espresso_weight espresso_flow espresso_flow_weight \
espresso_flow_weight_raw espresso_temperature_basket espresso_temperature_mix espresso_water_dispensed \
espresso_pressure_goal espresso_flow_goal espresso_temperature_goal}
set past_props_keys [array names past_props]
foreach k $default_pars {
if { [lsearch $past_props_keys $k] > -1 } {
set v $past_props($k)
append espresso_data [subst {[list $k] [list $v]\n}]
}
}
# Check if there's any variable in the first shot section not in our default list and add it afterwards.
set past_props_keys [list_remove_element $past_props_keys settings]
set past_props_keys [list_remove_element $past_props_keys machine]
foreach k $past_props_keys {
if { [lsearch $default_pars $k] == -1 } {
set v $past_props($k)
append espresso_data [subst {[list $k] [list $v]\n}]
}
}
append espresso_data "settings {\n"
foreach k [lsort -dictionary [array names past_sets]] {
set v $past_sets($k)
append espresso_data [subst {\t[list $k] [list $v]\n}]
}
append espresso_data "}\n"
append espresso_data "machine {\n"
foreach k [lsort -dictionary [array names past_mach]] {
set v $past_mach($k)
append espresso_data [subst {\t[list $k] [list $v]\n}]
}
append espresso_data "}\n"
if { $write_file == 1 && $backup_file == 1 } {
set backup_path [string range $path 0 end-5].bak
if {[file exists $backup_path]} { file delete $backup_path }
file rename $path $backup_path
}
if { $write_file == 1 } {
write_file $path $espresso_data
msg "DYE: Updated past espresso history file $path"
}
return $espresso_data
}
# Takes a list of other_equipment containing 3-tuples {{name} {type} {setting}} and modify the requested modify_type
# (which has to be one of 'equipment_type'/'type', 'equipment_name'/'equipment'/'name' or 'equipment_setting'/'setting') matching old_value to new_value.
# Returns the modified list.
proc ::DYE::modify_other_equipment { other_equipment modify_type old_value new_value {equipment_type {}} {equipment_name {}} } {
#msg "DYE DEBUG: CALL modify_other_equipment '$other_equipment' '$modify_type' '$old_value' '$new_value' '$equipment_type' '$equipment_name'"
if { [llength $other_equipment] < 3 } { return $other_equipment }
if { $modify_type eq "equipment" || $modify_type eq "equipment_name" || $modify_type eq "name"} {
set modify_type name
set start 0
} elseif { $modify_type eq "equipment_type" || $modify_type eq "type" } {
set modify_type type
set start 1
} elseif { $modify_type eq "equipment_setting" || $modify_type eq "setting" } {
set modify_type setting
set start 2
} else {
msg "DYE ERROR: modify_type $modify_type not recognized in DYE::modify_other_equipment"
return $other_equipment
}
for {set i $start} { $i < [llength $other_equipment] } {incr i 3} {
set value [lindex $other_equipment $i]
if { $value eq $old_value } {
if { $modify_type eq "type" } {
set other_equipment [lreplace $other_equipment $i $i $new_value]
} elseif { $modify_type eq "name" } {
if { [lindex $other_equiment [expr {$i+1}]] eq $equipment_type } {
set other_equipment [lreplace $other_equipment $i $i $new_value]
}
} elseif { $modify_type eq "setting" } {
if { [lindex $other_equiment [expr {$i-1}]] eq $equipment_type && \
[lindex $other_equiment [expr {$i-2}]] eq $equipment_name } {
set other_equipment [lreplace $other_equipment $i $i $new_value]
}
}
}
}
return $other_equipment
}
### GENERAL UTILITIES #################################################################################################
# Adds a named option "-option_name option_value" to a named argument list if the option doesn't exist in the list.
# Returns the option value.
proc ::DYE::args_add_option_if_not_exists { proc_args option_name option_value } {
upvar $proc_args largs
if { [string range $option_name 0 0] ne "-" } { set option_name "-$option_name" }
set opt_idx [lsearch -exact $largs $option_name]
if { $opt_idx == -1 } {
lappend largs $option_name $option_value
} else {
set option_value [lindex $largs [expr {$opt_idx+1}]]
}
return $option_value
}
# Removes the named option "-option_name" from the named argument list, if it exists.
proc ::DYE::args_remove_option { proc_args option_name } {
upvar $proc_args largs
if { [string range $option_name 0 0] ne "-" } { set option_name "-$option_name" }
set option_idx [lsearch -exact $largs $option_name]
if { $option_idx > -1 } {
if { $option_idx == [expr {[llength $largs]-1}] } {
set value_idx $option_idx
} else {
set value_idx [expr {$option_idx+1}]
}
set largs [lreplace $largs $option_idx $value_idx]
}
}
# Returns 1 if the named arguments list has a named option "-option_name".
proc ::DYE::args_has_option { proc_args option_name } {
if { [string range $option_name 0 0] ne "-" } { set option_name "-$option_name" }
set n [llength $proc_args]
set option_idx [lsearch -exact $proc_args $option_name]
return [expr {$option_idx > -1 && $option_idx < [expr {$n-1}]}]
}
# Returns the value of the named option in the named argument list
proc ::DYE::args_get_option { proc_args option_name {default_value {}} {rm_option 0} } {
upvar $proc_args largs
if { [string range $option_name 0 0] ne "-" } { set option_name "-$option_name" }
set n [llength $largs]
set option_idx [lsearch -exact $largs $option_name]
if { $option_idx > -1 && $option_idx < [expr {$n-1}] } {
set result [lindex $largs [expr {$option_idx+1}]]
if { $rm_option == 1 } {
set largs [lreplace $largs $option_idx [expr {$option_idx+1}]]
}
} else {
set result $default_value
}
return $result
}
# Extracts from args all pairs whose key start by the prefix. And returns the extracted named options in a new
# args list that contains the pairs, with the prefix stripped from the keys.
# For example, "-label_fill X" will return "-fill X" if prefix="-label_", and args will be emptied.
proc ::DYE::args_extract_prefixed { proc_args prefix } {
upvar $proc_args largs
set new_args {}
set n [expr {[string length $prefix]-1}]
set i 0
while { $i < [llength $largs] } {
if { [string range [lindex $largs $i] 0 $n] eq $prefix } {
lappend new_args "-[string range [lindex $largs $i] 7 9999]"
lappend new_args [lindex $largs [expr {$i+1}]]
set largs [lreplace $largs $i [expr {$i+1}]]
} else {
incr i 2
}
}
return $new_args
}
proc ::DYE::keypress_is_number_or_dot {keyvalue} {
# set ::DYE::debug_text "PRESSED \"$keyvalue\""
return [expr { [string is integer $keyvalue] || $keyvalue eq "period" } ]
}
proc ::DYE::keypress_is_number_or_slash {keyvalue} {
#set ::DYE::debug_text "PRESSED \"$keyvalue\""
return [expr { [string is integer $keyvalue] || $keyvalue eq "slash" } ]
}
proc ::DYE::return_blank_if_zero {in} {
if {$in == 0} { return {} }
return $in
}
# Replaces ::web_browser while John considers adding the code for it to work under windows.
proc ::DYE::web_browser {url} {
msg "Browser '$url'"
if { $::android == 1 } {
borg activity android.intent.action.VIEW $url text/html
} elseif { $::tcl_platform(platform) eq "windows" } {
eval exec [auto_execok start] $url
}
}
# A TEMPORAL COPY OF THE visualizer plugin upload proc, until it promotes to stable and can be invoked directly.
proc ::DYE::visualizer_upload {content} {
msg "uploading shot"
borg toast "Uploading Shot"
set ::DYE::settings(last_visualizer_result) {}
set content [encoding convertto utf-8 $content]
http::register https 443 [list ::tls::socket -servername $::DYE::settings(visualizer_url)]
set username $::DYE::settings(visualizer_username)
set password $::DYE::settings(visualizer_password)
set auth "Basic [binary encode base64 $username:$password]"
set boundary "--------[clock seconds]"
set type "multipart/form-data, charset=utf-8, boundary=$boundary"
set headerl [list Authorization "$auth"]
set url "https://$::DYE::settings(visualizer_url)/$::DYE::settings(visualizer_endpoint)"
set contentHeader "Content-Disposition: form-data; name=\"file\"; filename=\"file.shot\"\r\nContent-Type: application/octet-stream\r\n"
set body "--$boundary\r\n$contentHeader\r\n$content\r\n--$boundary--\r\n"
if {[catch {
set token [http::geturl $url -headers $headerl -method POST -type $type -query $body -timeout 30000]
set status [http::status $token]
set answer [http::data $token]
set returncode [http::ncode $token]
set returnfullcode [http::code $token]
} err] != 0} {
msg "Could not upload shot! $err"
borg toast "Upload failed!"
set ::DYE::settings(last_visualizer_result) "[translate {Upload failed}]: $err"
catch { http::cleanup $token }
return
}
msg "DYE Visualizer Upload: token: $token, status: $status, answer: $answer, returncode=$returncode, returnfullcode=$returnfullcode"
if {$returncode == 401} {
msg "DYE Visualizer Upload failed. Unauthorized"
borg toast [translate "Authentication failed: Please check username / password"]
set ::DYE::settings(last_visualizer_result) "[translate {Authentication failed}]: [translate {Please check username / password}]"
http::cleanup $token
return
}
if {[string length $answer] == 0 || $returncode != 200} {
msg "DYE Visualizer Upload failed: $returnfullcode, $answer"
borg toast [translate "Upload failed"]
set ::DYE::settings(last_visualizer_result) "[translate {Upload failed}]: $returnfullcode"
http::cleanup $token
return
}
borg toast "Upload successful"
if {[catch {
set response [::json::json2dict [http::data $token]]
set uploaded_id [dict get $response id]
} err] != 0} {
msg "Upload failed: Unexpected server answer $answer"
set ::DYE::settings(last_visualizer_result) "[translate {Upload failed}]: [translate {Unexpected server answer}]"
http::cleanup $token
return
}
set ::DYE::settings(last_visualizer_result) "[translate {Upload successful}]"
http::cleanup $token
return $uploaded_id
}
# Takes a shot (if the shot contents array is provided, use it, otherwise reads from disk from the filename parameter),
# uploads it to visualizer, changes its repository_links settings if necessary, and persists the change to disk.
# 'clock' can have any format supported by proc get_shot_file_path, though it is ignored if contents is provided.
# Returns the repository link if successful, empty string otherwise
proc ::DYE::upload_to_visualizer_and_save { clock { content {}} } {
if { $content eq "" } {
array set arr_changes {}
set content [::DYE::modify_shot_file $clock arr_changes 0 0]
}
set visualizer_id [::DYE::visualizer_upload $content]
if { $visualizer_id ne "" } {
set repo_link "Visualizer https://visualizer.coffee/shots/$visualizer_id"
if { [string match "*$repo_link*" $content] != 1 } {
set arr_changes(repository_links) $repo_link
::DYE::modify_shot_file $clock arr_changes
}
} else {
set repo_link {}
}
return $repo_link
}
# Queries GitHub repository for the latest released version and returns a 3-elements list
# { <tag (version number)> <release_url> <release_description> }.
# If there's an error or the data can't be donwloaded, returns {-1 {} <error_description> }
proc ::DYE::github_latest_release { { url {}} } {
if { $url eq "" } {
set url $::DYE::settings(github_latest_url)
if { $url eq "" } { return [list -1 "" [translate "No GitHub URL"]] }
}
::http::register https 443 ::tls::socket
if {[catch {
set token [::http::geturl $url -timeout 10000]
set status [::http::status $token]
set answer [::http::data $token]
set ncode [::http::ncode $token]
set code [::http::code $token]
::http::cleanup $token
} err] != 0} {
set my_err "Could not get latest release from GitHub"
msg "DYE: $my_err : $err"
say [translate "Download failed"] ""
catch { ::http::cleanup $token }
return [list -1 "" [translate $my_err]]
}
if { $status eq "ok" && $ncode == 200 } {
if {[catch {
set response [::json::json2dict $answer]
set release_url [dict get $response zipball_url]
set release_desc [dict get $response body]
set release_version [dict get $response tag_name]
} err] != 0} {
set my_err "Unexpected GitHub server answer"
msg "DYE: $my_err : $answer"
say [translate "Download failed"] ""
return [list -1 "" [translate $my_err]]
}
regsub "^v(.+)$" $release_version "\\1" release_version
return [list $release_version $release_url $release_desc]
} else {
set my_err "Could not get latest release from GitHub"
msg "DYE: $my_err : $code"
say [translate "Download failed"] ""
return [list -1 "" [translate $my_err]]
}
}
# Downloads a release ZIP file from GitHub (default to latest), extracts $plugin_file from it and copies it to the
# DSx plugin folders, replacing the current plugin file if it exists. Returns 1 if successful, 0 otherwise.
proc ::DYE::update_DSx_plugin_from_github { plugin_file { release_url {}} { save_backup 1 } } {
if { $release_url eq "" } {
set release [::DYE::github_latest_release]
if { [llength $release] == 3 } {
set release_url [lindex $release 1]
} else {
return 0
}
}
::http::register https 443 ::tls::socket
if {[catch {
set token [::http::geturl $release_url -timeout 30000]
set status [::http::status $token]
set meta [::http::meta $token]
set ncode [::http::ncode $token]
set code [::http::code $token]
::http::cleanup $token
} err] != 0} {
msg "DYE: Could not get latest release ZIP answer from GitHub! $err"
say [translate "Download failed"] ""
catch { ::http::cleanup $token }
return 0
}
set zip_url {}
if { $status eq "ok" && $ncode == 302} {
if {[catch {
set zip_url [dict get $meta Location]
} err] != 0} {
msg "DYE: Unexpected meta format from GitHub! $err"
say [translate "Download failed"] ""
return 0
}
} else {
msg "DYE: Could not get latest release from GitHub! $code"
say [translate "Download failed"] ""
return 0
}
set zip_path "[skin_directory]/DSx_User_Set/latest_plugin.zip"
set zip_fn [file tail $zip_path]
if { [file exists $zip_path] } {
file delete $zip_path
}
::decent_http_get_to_file $zip_url $zip_path
if { [file exists $zip_path] } {
if {[catch {
cd "[skin_directory]/DSx_User_Set"
set mnt_point [zipfs::mount $zip_path __zip]
} err] != 0} {
msg "DYE: Could not get uncompress ZIP or unexpected ZIP contents: $err"
say [translate "Download failed"] ""
catch { zipfs::unmount $zip_path }