Compare commits

..

132 Commits

Author SHA1 Message Date
2a49263797 Add graceful shutdown 2024-12-07 14:59:45 -05:00
4eed28046f Change logging for auger 2024-12-07 14:57:53 -05:00
292d66ca33 Flip exhaust for NC logic 2024-12-07 14:45:21 -05:00
51ea67b83b oop 2024-12-01 18:37:12 -05:00
2c1eb82f36 Switch v2 to emr 2024-12-01 18:34:08 -05:00
d0a721054c Stop the startup/shutdown subs 2024-12-01 18:11:17 -05:00
42374d094d v2 MQTT Ready to test 2024-12-01 17:55:38 -05:00
894c6a68fd MQTT v2 Testing 2024-12-01 17:38:51 -05:00
a92a71d5e7 MVP 2024-11-30 18:21:29 -05:00
5fadfd2000 tmp add debug log for state changes 2024-11-30 18:14:07 -05:00
de2e9e00ec Mostly working I think 2024-11-30 17:24:50 -05:00
5e20cfff6e Minor changes 2024-11-30 16:52:10 -05:00
a59dd3fe6f Changed interface switching to use .env 2024-11-30 16:19:47 -05:00
c01e18c644 stupid 2024-11-30 12:55:49 -05:00
518f45731a Better logging and add fun to panel 2024-11-30 12:48:26 -05:00
ee56a6e620 Changed logging to reduce clutter 2024-11-30 11:40:47 -05:00
4da7fd0fe8 Switch to prod gpio script 2024-11-30 11:35:14 -05:00
196de2fb74 Backend connect to self 2024-11-30 11:31:49 -05:00
7872054122 New MQTT Config 2024-11-30 11:30:23 -05:00
c3a33b6dac New MQTT config 2024-11-28 17:57:46 -05:00
66b9780792 Fix mqtt url 2024-11-25 21:33:12 -05:00
e56b5a2a31 Prettify 2024-11-24 19:32:56 -05:00
27ff026c85 Add auger restart function 2024-11-24 19:30:14 -05:00
bccbe3dad6 Improve panel 2024-11-24 19:20:44 -05:00
225d464ad6 Polished EMR mode and added control panel 2024-11-24 19:15:30 -05:00
743eb6cfcf Improve igniter scripts 2024-11-24 19:05:48 -05:00
3d14c7a60a Improved logging again 2024-11-24 19:01:07 -05:00
8c61879bb4 readd state 2024-11-24 18:23:14 -05:00
131f426b8f Temp remove state, will crash 2024-11-24 18:20:18 -05:00
7864721e3e ? idk anymore 2024-11-24 18:19:25 -05:00
e5ba2e52e5 Better logging 2024-11-24 18:18:03 -05:00
ee70f34179 Set up pinmap 2024-11-24 18:16:34 -05:00
caedbda8c2 ? 2024-11-24 18:13:03 -05:00
b374dd1d6c ?? 2024-11-24 16:42:00 -05:00
b801aec50e ? 2024-11-24 16:41:42 -05:00
0db9ff73d1 ? 2024-11-24 16:41:11 -05:00
55bcbb16eb Fix imports for EMR 2024-11-24 16:39:52 -05:00
7cb59cda01 Expand emr mode 2024-11-24 16:38:51 -05:00
a263a26c38 Update install method 2024-11-24 16:25:53 -05:00
8224161476 Add emergency auger loop 2024-11-24 15:20:13 -05:00
e7cf1d34f2 Testing auger cycle 2024-11-24 15:17:38 -05:00
e9c54210aa Better logging and naming 2024-11-24 15:17:23 -05:00
3f0be2d972 Update MQTT domain 2024-11-24 15:16:41 -05:00
f2b0f99941 Merge branch 'v2-back' of https://git.vfsh.dev/voidf1sh/hestia into v2-back 2024-11-11 20:35:23 -05:00
0a349d31e4 WIP Reworking startup flow 2024-11-11 20:35:21 -05:00
9a820741c0 . 2024-11-02 22:30:12 -04:00
038390061b . 2024-10-28 13:43:34 -04:00
91b87d566d Add KiCAD Files 2024-10-28 12:39:30 -04:00
5c278e9dd5 Better logging 2024-09-04 20:48:25 -04:00
94af045e0c semicolon 2024-09-02 13:16:52 -04:00
2a756065e7 Try new startup init 2024-09-02 13:16:21 -04:00
0a90a93269 Fix call for init 2024-09-02 12:11:51 -04:00
49d855e306 Change calls to pinMap 2024-09-02 11:53:12 -04:00
00e892c181 Add dev board pinout 2024-09-01 15:14:24 -04:00
ff9dfb1f43 Merge branch 'v2-back' of https://git.vfsh.dev/voidf1sh/hestia into v2-back 2024-09-01 13:22:47 -04:00
a7a736bf9c Updated pinmap 2024-09-01 13:22:07 -04:00
ae4f3c7507 stash the pinmap in the process 2024-08-23 21:17:04 -04:00
a0a0804754 Add states for commands 2024-08-22 22:15:33 -04:00
bdeb3152d2 Ready to test startup and shutdown 2024-08-22 22:08:10 -04:00
7fa0339791 Set prod 2024-08-22 20:59:04 -04:00
42b5576be5 Minor fixes 2024-08-22 20:34:17 -04:00
6276a51f05 Mostly working, reconnects after every detected change 2024-08-22 20:15:14 -04:00
236faaefbe Logic and comms ready for testing 2024-08-22 20:07:05 -04:00
0c1c1483b1 Add missing closing bracket 2024-08-22 19:17:38 -04:00
594d177348 Testing improved init logic 2024-08-22 19:16:15 -04:00
8a68c00f01 idk 2024-08-21 22:26:20 -04:00
a0b0d16c73 Boop 2024-08-21 22:18:38 -04:00
4e4bf9f7ca Change togglePin to Promise+fix calling of toggle 2024-08-21 22:16:32 -04:00
4f35efcf79 Fix events I hope 2024-08-21 22:11:57 -04:00
a987800cd7 Tentative test for control toggle 2024-08-21 22:02:23 -04:00
5fa3d5bb9c Comms working!! 2024-08-21 21:43:56 -04:00
ccc84173cb Fix import of config and pins 2024-08-21 21:19:46 -04:00
24470fe6f2 Reconfigure module 2024-08-21 21:18:43 -04:00
9e543989b8 Update config to match frontend 2024-08-21 21:16:48 -04:00
673aa2faf5 Logic and MQTT Basics added not tested 2024-08-21 21:01:55 -04:00
459008d372 Added more logic flow stuff. 2024-08-20 18:54:34 -04:00
f0687e4c2b Not really tested 2024-08-19 20:56:39 -04:00
178bc1e115 Merge branch 'v2-back' of https://git.vfsh.dev/voidf1sh/hestia into v2-back 2024-08-19 16:10:51 -04:00
fd4092dbd3 Add package-lock to gitignore 2024-08-19 16:10:08 -04:00
6f4d94c17b Logic flow started 2024-08-18 21:14:42 -04:00
4666d84905 GPIO working reliably. 2024-08-18 18:52:40 -04:00
c41016a055 Change exhaust default to off 2024-08-18 18:32:09 -04:00
f861b1a145 Another reference error 2024-08-18 14:39:07 -04:00
e7ada41977 Removing more vestigials 2024-08-18 14:37:59 -04:00
da06ebc49e derp 2024-08-18 14:37:19 -04:00
2a4393df73 Maybe fixed now 2024-08-18 14:36:49 -04:00
768e9faae5 Semi fixed... 2024-08-18 14:34:23 -04:00
1eb1dc8270 Implement custom logging 2024-08-18 14:26:28 -04:00
02b2f77d4d Polling working, remove vistgial stuff 2024-08-18 14:12:40 -04:00
4d6e058afb Maybe fixed polling? 2024-08-18 14:11:32 -04:00
38a110fa8f z 2024-08-18 14:07:07 -04:00
2759b0f9c0 Stop poll 2024-08-18 14:06:26 -04:00
992ae238eb Reimagined polling 2024-08-18 14:02:14 -04:00
c3e70ab9c7 Testing polling loop 2024-08-18 13:59:11 -04:00
486c3aaa62 Move away from forEach loop 2024-08-18 13:53:55 -04:00
1932913396 Didn't actually callback lol 2024-08-18 13:51:40 -04:00
f0ad09fadd Try not returning but calling? 2024-08-18 13:50:54 -04:00
27cc6bafe5 Add more detailed logging 2024-08-18 13:49:41 -04:00
fee1e0e88e Remove erroneous space 2024-08-18 13:46:51 -04:00
7afc662ba7 Move timeout to a promise 2024-08-18 13:46:10 -04:00
32893467bb 3.4 almost working 2024-08-18 13:19:29 -04:00
3f6f9c3e73 3.3 2024-08-18 13:18:54 -04:00
582c05cdc3 3.2 2024-08-18 13:15:44 -04:00
f16ccbeaa6 3.1 2024-08-18 13:14:57 -04:00
f596ea1bda 3 2024-08-18 13:14:28 -04:00
1413decbe0 2 2024-08-18 13:10:10 -04:00
170cbb7792 Restructure logic and test 2024-08-18 12:31:29 -04:00
09b524d35b Change pin numbering 2024-08-18 10:21:03 -04:00
00a52d0bf5 Fix file references 2024-08-16 20:53:12 -04:00
1ce82270f5 Testing better 2024-08-16 20:52:22 -04:00
9d084ee37a Testing gpio interface 2024-08-16 20:41:12 -04:00
c0fe29a204 Update todo 2024-08-15 12:09:55 -04:00
805e5e28ab Tentatively got State and Communicator working 2024-08-15 09:30:36 -04:00
bb3f0cfad5 Import classes 2024-08-15 08:40:20 -04:00
288d19e95b Blank mostly 2024-08-15 06:53:26 -04:00
c7ffc0c296 Update styling 2024-08-14 20:34:31 -04:00
470d00f468 Consolidate buttons 2024-08-14 20:31:22 -04:00
538775d726 Hoping and praying 2024-08-14 20:29:22 -04:00
6fab8e4101 7 2024-08-14 20:27:24 -04:00
1e55c058b9 6 2024-08-14 20:26:40 -04:00
b30caa1c60 Testing 5 2024-08-14 19:18:01 -04:00
ca30189c64 Testing 4 2024-08-14 19:15:55 -04:00
26c2f0a87d revert config 2024-08-14 19:12:49 -04:00
1ce103d39c silly brackets 2024-08-14 19:12:01 -04:00
8bea9b55e4 I want to scream 2024-08-14 19:11:09 -04:00
e24a70053d Add config.json, nothing is sensitive in it, use .env 2024-08-14 19:08:06 -04:00
76e66c8818 Fix mqtt import 2024-08-14 19:05:55 -04:00
6ba5766a30 Re-fix imports 2024-08-14 19:03:32 -04:00
b8038e7847 Fix import I hope 2024-08-14 19:02:03 -04:00
63ef0da043 Testing 2 2024-08-14 19:00:58 -04:00
180723e4f5 Testing 1 2024-08-14 16:25:15 -04:00
928b1d36da Init, blank 2024-08-14 14:32:46 -04:00
47 changed files with 103537 additions and 4925 deletions

BIN
.DS_Store vendored

Binary file not shown.

2
.gitignore vendored
View File

@ -9,11 +9,11 @@ lerna-debug.log*
.VSCodeCounter
.vscode
.vscode/*
config.json
log.txt
nohup.out
data/config.db
config.db
package-lock.json
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

2
.vscode/launch.json vendored
View File

@ -11,7 +11,7 @@
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/main.js"
"program": "${workspaceFolder}/src/main.js"
}
]
}

27
PCB Notes.md Normal file
View File

@ -0,0 +1,27 @@
# New Relay Layout
12VDC Power Supply
|
Exhaust--------SSR-40DA-|-----------------NC 3V Relay--------GPIO
|
Igniter--------SSR-40DA-|-----------------NO 3V Relay--------GPIO
|
Auger----------SSR-40DA-|-----------------NO 3V Relay--------GPIO
|
GND
# Power Supply
Flyback Diode (for DC coils)
Purpose: Protects other components from the inductive voltage spike generated when the relays coil is de-energized. This voltage spike can damage transistors or other switching components.
Type: A simple 1N4007 diode or similar can be used.
Placement: Connect the diode across the relay coil, with the cathode (striped side) to the positive voltage and the anode to the negative end of the coil.
Capacitor (for Noise Suppression)
Purpose: A capacitor can be added across the relay contacts to reduce arcing and noise caused by contact bounce, which can help reduce wear.
Type: Use a ceramic or film capacitor, typically around 0.1 µF, rated for the expected voltage.
Placement: Place the capacitor across the relays switching contacts. For high-voltage AC circuits, ensure the capacitor is rated accordingly (e.g., X2 type for AC line).

72
PCB/Dimensions.md Normal file
View File

@ -0,0 +1,72 @@
# Info
Auger on SSR for frequent switching
Exhaust on SSR for inductive load
Igniter on Songlei
# SRD-40-DC
Solid state relays
### Overall
```
W(x) - 45.75 mm
L(y) - 63.00 mm
D(z) - 23.75 mm
```
### Holes
Location: Centered on `W(x)`
```
Top: 4.25 x 7.25 mm (diameter by length)
Bottom: 4.25 mm (diameter)
Inside E2E: 42.00 mm <---> 53.50 mm
Outside E2E: 4.30 mm <---> 4.30 mm
```
# Relay Mobules
Amazon prepackaged baddies.
### Overall
```
W(x) - 70.30 mm
L(y) - 16.90 mm
D(z) - 18.25 mm
```
### Holes
Location: Four outside corners of `W(x)`/`L(y)`
```
Diameter: 2.50 mm
Outside E2E: 0.60 mm
```
# Terminal Block
Home Depot special, temporary until PCB.
### Overall
```
W(x) - 34.00 mm
L(y) - 135.25 mm
D(z) - 16.00 mm
```
### Holes
Location: Four outside corners of `W(x)`/`L(y)`
```
Diameter: - 5.40 mm
Outside E2E L(y) - 3.10 mm
Outside E2C L(y) - 5.80 mm
Inside E2E W(x) - 7.45 mm
Inside C2C W(x) - 12.85 mm
```
45.08-8.45=36.63
```
Top of terminal: 135.25 mm
L of RM: 16.9 mm
1/2: 8.45
Middle of top RM: 101.4375 (109.8875)
Mid of mid RM: 67.625 (76.075)
Mid of bot RM: 33.8125 (42.2625)
```

View File

@ -0,0 +1,2 @@
(kicad_pcb (version 20240108) (generator "pcbnew") (generator_version "8.0")
)

View File

@ -0,0 +1,83 @@
{
"board": {
"active_layer": 0,
"active_layer_preset": "",
"auto_track_width": true,
"hidden_netclasses": [],
"hidden_nets": [],
"high_contrast_mode": 0,
"net_color_mode": 1,
"opacity": {
"images": 0.6,
"pads": 1.0,
"tracks": 1.0,
"vias": 1.0,
"zones": 0.6
},
"selection_filter": {
"dimensions": true,
"footprints": true,
"graphics": true,
"keepouts": true,
"lockedItems": false,
"otherItems": true,
"pads": true,
"text": true,
"tracks": true,
"vias": true,
"zones": true
},
"visible_items": [
0,
1,
2,
3,
4,
5,
8,
9,
10,
11,
12,
13,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
32,
33,
34,
35,
36,
39,
40
],
"visible_layers": "fffffff_ffffffff",
"zone_display_mode": 0
},
"git": {
"repo_password": "",
"repo_type": "",
"repo_username": "",
"ssh_key": ""
},
"meta": {
"filename": "Hestia 2024.kicad_prl",
"version": 3
},
"project": {
"files": []
}
}

View File

@ -0,0 +1,392 @@
{
"board": {
"3dviewports": [],
"design_settings": {
"defaults": {},
"diff_pair_dimensions": [],
"drc_exclusions": [],
"rules": {},
"track_widths": [],
"via_dimensions": []
},
"ipc2581": {
"dist": "",
"distpn": "",
"internal_id": "",
"mfg": "",
"mpn": ""
},
"layer_presets": [],
"viewports": []
},
"boards": [],
"cvpcb": {
"equivalence_files": []
},
"erc": {
"erc_exclusions": [],
"meta": {
"version": 0
},
"pin_map": [
[
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
2
],
[
0,
2,
0,
1,
0,
0,
1,
0,
2,
2,
2,
2
],
[
0,
0,
0,
0,
0,
0,
1,
0,
1,
0,
1,
2
],
[
0,
1,
0,
0,
0,
0,
1,
1,
2,
1,
1,
2
],
[
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
2
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
2
],
[
1,
1,
1,
1,
1,
0,
1,
1,
1,
1,
1,
2
],
[
0,
0,
0,
1,
0,
0,
1,
0,
0,
0,
0,
2
],
[
0,
2,
1,
2,
0,
0,
1,
0,
2,
2,
2,
2
],
[
0,
2,
0,
1,
0,
0,
1,
0,
2,
0,
0,
2
],
[
0,
2,
1,
1,
0,
0,
1,
0,
2,
0,
0,
2
],
[
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2
]
],
"rule_severities": {
"bus_definition_conflict": "error",
"bus_entry_needed": "error",
"bus_to_bus_conflict": "error",
"bus_to_net_conflict": "error",
"conflicting_netclasses": "error",
"different_unit_footprint": "error",
"different_unit_net": "error",
"duplicate_reference": "error",
"duplicate_sheet_names": "error",
"endpoint_off_grid": "warning",
"extra_units": "error",
"global_label_dangling": "warning",
"hier_label_mismatch": "error",
"label_dangling": "error",
"lib_symbol_issues": "warning",
"missing_bidi_pin": "warning",
"missing_input_pin": "warning",
"missing_power_pin": "error",
"missing_unit": "warning",
"multiple_net_names": "warning",
"net_not_bus_member": "warning",
"no_connect_connected": "warning",
"no_connect_dangling": "warning",
"pin_not_connected": "error",
"pin_not_driven": "error",
"pin_to_pin": "warning",
"power_pin_not_driven": "error",
"similar_labels": "warning",
"simulation_model_issue": "ignore",
"unannotated": "error",
"unit_value_mismatch": "error",
"unresolved_variable": "error",
"wire_dangling": "error"
}
},
"libraries": {
"pinned_footprint_libs": [],
"pinned_symbol_libs": []
},
"meta": {
"filename": "Hestia 2024.kicad_pro",
"version": 1
},
"net_settings": {
"classes": [
{
"bus_width": 12,
"clearance": 0.2,
"diff_pair_gap": 0.25,
"diff_pair_via_gap": 0.25,
"diff_pair_width": 0.2,
"line_style": 0,
"microvia_diameter": 0.3,
"microvia_drill": 0.1,
"name": "Default",
"pcb_color": "rgba(0, 0, 0, 0.000)",
"schematic_color": "rgba(0, 0, 0, 0.000)",
"track_width": 0.2,
"via_diameter": 0.6,
"via_drill": 0.3,
"wire_width": 6
}
],
"meta": {
"version": 3
},
"net_colors": null,
"netclass_assignments": null,
"netclass_patterns": []
},
"pcbnew": {
"last_paths": {
"gencad": "",
"idf": "",
"netlist": "",
"plot": "",
"pos_files": "",
"specctra_dsn": "",
"step": "",
"svg": "",
"vrml": ""
},
"page_layout_descr_file": ""
},
"schematic": {
"annotate_start_num": 0,
"bom_export_filename": "",
"bom_fmt_presets": [],
"bom_fmt_settings": {
"field_delimiter": ",",
"keep_line_breaks": false,
"keep_tabs": false,
"name": "CSV",
"ref_delimiter": ",",
"ref_range_delimiter": "",
"string_delimiter": "\""
},
"bom_presets": [],
"bom_settings": {
"exclude_dnp": false,
"fields_ordered": [
{
"group_by": false,
"label": "Reference",
"name": "Reference",
"show": true
},
{
"group_by": true,
"label": "Value",
"name": "Value",
"show": true
},
{
"group_by": false,
"label": "Datasheet",
"name": "Datasheet",
"show": true
},
{
"group_by": false,
"label": "Footprint",
"name": "Footprint",
"show": true
},
{
"group_by": false,
"label": "Qty",
"name": "${QUANTITY}",
"show": true
},
{
"group_by": true,
"label": "DNP",
"name": "${DNP}",
"show": true
}
],
"filter_string": "",
"group_symbols": true,
"name": "Grouped By Value",
"sort_asc": true,
"sort_field": "Reference"
},
"connection_grid_size": 50.0,
"drawing": {
"dashed_lines_dash_length_ratio": 12.0,
"dashed_lines_gap_length_ratio": 3.0,
"default_line_thickness": 6.0,
"default_text_size": 50.0,
"field_names": [],
"intersheets_ref_own_page": false,
"intersheets_ref_prefix": "",
"intersheets_ref_short": false,
"intersheets_ref_show": false,
"intersheets_ref_suffix": "",
"junction_size_choice": 3,
"label_size_ratio": 0.375,
"operating_point_overlay_i_precision": 3,
"operating_point_overlay_i_range": "~A",
"operating_point_overlay_v_precision": 3,
"operating_point_overlay_v_range": "~V",
"overbar_offset_ratio": 1.23,
"pin_symbol_size": 25.0,
"text_offset_ratio": 0.15
},
"legacy_lib_dir": "",
"legacy_lib_list": [],
"meta": {
"version": 1
},
"net_format_name": "",
"page_layout_descr_file": "",
"plot_directory": "",
"spice_current_sheet_as_root": false,
"spice_external_command": "spice \"%I\"",
"spice_model_current_sheet_as_root": true,
"spice_save_all_currents": false,
"spice_save_all_dissipations": false,
"spice_save_all_voltages": false,
"subpart_first_id": 65,
"subpart_id_separator": 0
},
"sheets": [
[
"871df96a-ce20-41c9-b8a3-e7b5b9852509",
"Root"
]
],
"text_variables": {}
}

File diff suppressed because it is too large Load Diff

97966
PCB/Hestia 2024/fp-info-cache Normal file

File diff suppressed because it is too large Load Diff

114
README.md
View File

@ -2,22 +2,114 @@
Node.js Raspberry Pi Pellet Stove Controller, named after the Greek virgin goddess of the hearth.
# About
This project seeks to replace the OEM control panel on a Lennox Winslow PS40 pellet stove with a Raspberry Pi. I will be utilizing a Raspberry Pi Zero W, relays, and switches already installed on the pellet stove. I chose a Pi Zero W for its small form factor, the lack of pre-installed headers, its wireless connectivity, and familiarity with the platform. I had previously used an Arduino Nano for a much more rudimentary version of this project and found great difficulty in making adjustments to the code. Additionally the Raspberry Pi platform will allow for expansion in the future to include IoT controls and logging of usage utilizing networked databases. The project will be written using Node.js and the rpi-gpio Node module. I've chosen Node.js for its familiarity as well as ease of implementation of a web server for future expansion.
This project seeks to replace the OEM control panel on a Lennox Winslow PS40 pellet stove with a Raspberry Pi. I am utilizing a Raspberry Pi 3 Model B+, relays, and temperature snap switches installed on the pellet stove. I chose a Pi 3 for its wireless connectivity and familiarity with the platform. I had previously used an Arduino Nano for a much more rudimentary version of this project and found great difficulty in making adjustments to the code. Additionally the Raspberry Pi platform will allow for expansion in the future to include IoT controls and logging of usage utilizing networked databases. The project will be written using Node.js to handle high-level logic and communications, calling Python scripts to handle interacting with GPIO. I'm now in the process of rewriting this project to a more modern v2. Previously I had settled on using deprecated versions of Node.js to maintain compatibility with various Pi GPIO modules, now I've decided to split the GPIO controls off to a small Python interface script.
# Setting Up the Pi
```bash
# Update and upgrade the system
sudo apt update && sudo apt upgrade -y
# Install dependencies and other useful utilities
sudo apt install nodejs npm python3 python3-pip neofetch btop fish -y
# Install PM2 to manage the Node.js process
sudo npm install -g pm2
# Set up a new user to run the process
sudo useradd -m -s /usr/bin/fish hestia
# Set hestia home to /srv/hestia
sudo usermod -d /home/hestia hestia
# Create a runners group
sudo groupadd runners
# Add the new user to the runners group
sudo usermod -aG runners hestia
# Add hestia to the gpio group
sudo usermod -aG gpio hestia
# Give hestia ownership of the home directory
sudo chown hestia:runners /home/hestia -R
# Set permissions on the home directory
sudo chmod 775 /home/hestia -R
# Create a directory for the project
sudo mkdir /srv/hestia
# Change ownership of the directory to the new user
sudo chown hestia:runners /srv/hestia -R
# Set permissions on the directory
sudo chmod 775 /srv/hestia -R
# Change to the new user
sudo su hestia
# Pull the project
git clone https://git.vfsh.dev/voidf1sh/hestia.git /srv/hestia
# Change to the project directory
cd /srv/hestia
# Checkout the backend branch
git checkout v2-back
# Install the project dependencies
npm install
# Test run
node src/main.js
# Start with PM2
pm2 start src/main.js --name hestia
# Save the process list
pm2 save
# Setup PM2 daemon
pm2 startup
```
# Logic Flow
### Boot
* Server starts
* Call python to check pin states and reset them
* Establish connection to MQTT
* Listen for messages published to MQTT
### Auto-Start
* Check pin states for safety
* Power on igniter, 30 second pre-heat
* Power on exhaust
* Power on auger
* Wait X minutes
* Check Proof of Fire switch
* No Fire:
* Shutdown
* Alert
* Fire:
*Power off igniter
### Shutdown
* Check pin states
* Power off auger
* Wait X minutes
* Check Proof of Fire switch
* No Fire:
* Wait X minutes
* Power off exhaust
* Fire:
* Wait X minutes
* Repeat PoF switch check
# GPIO
Three GPIO pins are used along with a common ground to control three relays, supplying 120VAC power to the igniter and combustion blower when appropriate, and supplying power to the auger motor in pulses. Two more GPIO pins are used to detect open/closed status of a temperature-controlled snap switch and a vacuum switch. Another temperature-controlled snap switch is used to supply power to the convection motor when the pellet stove has reached a suitable temperature. A final temperature-controlled snap switch us used to interrupt the circuit for the auger motor to shut the stove off when an over-temperature condition is met. I will be utilizing a OneWire DS18B20 temperature sensor to detect the temperature of air exiting the stove vents.
| Pi Pin | Function | Direction | Wire Color |
| ------:| -------- | --------- | ---------- |
7 | Auger Relay | Out | Blue
13 | Igniter Relay | Out | Blue/White
15 | Combustion Blower Relay | Out | Orange
16 | Proof of Fire Switch | In | Orange/White
18 | OneWire Temp Sensor | In | Brown
22 | Vacuum Switch | In | Brown/White
4 | +5VDC for Switches | N/A | Green
6 | GND for Relays | N/A | Green/White
| Board Pin | BCM Pin | Function | Direction | Wire Color |
| ------:| -------- | -------- | --------- | ---------- |
7 | 4 | Auger Relay | Out | Or/W
13 | 27 | Igniter Relay | Out | Orange
15 | 22 | Exhaust Relay | Out | Gr/W
16 | 23 | Proof of Fire Switch | In | Blue
~~18~~ | ~~24~~ | ~~OneWire Temp Sensor~~ | ~~In~~ | ~~Blue/W~~
22 | 25 | Vacuum Switch | In | Green
4 | N/A | +5VDC for Switches | N/A | Br/W
6 | N/A | GND for Relays | N/A | Brown
### Dev Board Guide
8-Pin DIP:
1: Exhaust
2: Igniter
3: Auger
4: N/A
5: Vacuum
6: PoF
7: Passthrough to 220 Ohm DIP; 7-seg common cathode
8: Passthrough to 220 Ohm DIP; 7-seg common cathode
# Schematics
## The Current Setup
![Current Schematic](/assets/currentschem.png)

8
TODO
View File

@ -1,8 +0,0 @@
Logic Goal:
1. Start Script & Web Server
2. Await a command from the user.
3. Startup enables auger
4. If auger is enabled, run the cycle command.
5. Once per cycle, read then rewrite the config file for interoperability
6. Set feed rate based on config.

15
TODO.md Normal file
View File

@ -0,0 +1,15 @@
# In Progress
1. ~~Strip to bones~~
2. Better commenting
3. Add startup and shutdown logic (implemented, not tested)
4. Connect to MQTT
# Done
1. GPIO Interface
1. Duplicate and adapt `HestiaClasses` from branch `v2-front`
# Immediate To-Do
1. Connect to MQTT
# Roadmap
1. Add wiring and sensors for safeties

View File

@ -1,5 +0,0 @@
{
"database": {
"createConfigTable": ""
}
}

View File

@ -1,93 +0,0 @@
#!/bin/bash
#####################################################
# Interactive script for managing Hestia Web Portal #
#####################################################
# Formatting Tips:
# https://misc.flogisoft.com/bash/tip_colors_and_formatting
#
# Formatting:
# \e[1m - Bold
# \e[2m - Dim
# \e[8m - Hidden (passwords)
#
# Reset:
# \e[0m - Reset All Attributes
# \e[21m - Reset Bold/Bright
# \e[22m - Reset Dim
# \e[28m - Reset Hidden
#
# Colors:
# \e[39m - Default Foreground Color
# \e[30m - Black
# \e[31m - Red
# \e[32m - Green
# \e[34m - Blue
#####################################################
# Some initial variables to work with
timestamp=$(date "+%Y%m%d_%H%M")
filename="backup_$timestamp.tar.gz"
# Initial Prompt
# Bash allows for linebreaks in string literals and will
# break lines accordingly in the shell
echo -e "
[ Hestia Control Panel ]
This script is being run from: '$(pwd)'
Active Nodes: $(ps ax -o pid,user,command | grep 'node main.js' | grep -v grep)
Please enter an option from below:
[1] Launch Hestia Web Portal
[2] Quit Hestia Web Portal
[3] View the logs
[4] Update Hestia
[5] Set up database
[0] Quit Control Panel"
# Wait for input
read -p "Option: " opt
# Execute the correct commands based on input.
case "$opt" in
1)
# Launch Hestia Web Portal
clear
echo "Launching Hestia Web Portal"
nohup node main.js > log.txt &
;;
2)
# Quit Hestia Web Portal
clear
echo "Quitting Hestia Web Portal Gracefully"
touch quit
;;
3)
# View logs
clear
less +F log.txt
;;
4)
# Update Hestia
rm data/config.db
git pull
;;
5)
# Set up database
node modules/_setupdb.js
;;
0)
# Exit the script
clear
echo "Quitting..."
exit
;;
*)
clear
echo "Invalid Option!"
;;
esac
exec ./hestia.sh

91
main.js
View File

@ -1,91 +0,0 @@
const fn = require('./modules/functions.js').functions;
// Import the config file
var config = require('./templates/config.json');
// Database Functions
const dbfn = require('./modules/database.js');
// Web Portal
const portal = require('./modules/_server.js');
portal.start();
dbfn.run(`UPDATE timestamps SET value = ${Date.now()} WHERE key = 'process_start'`).catch(err => console.error(`Error setting process start time: ${err}`));
fn.commands.refreshConfig().then(res => {
if (process.env.DEBUG) console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] I: ${res.status}`);
config = res.config;
// Setup for use with the Pi's GPIO pins
switch (process.env.ONPI) {
case 'true':
console.log(`== Running on a Raspberry Pi.`);
var gpio = require('rpi-gpio');
fn.init(gpio).then((res) => {
console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] I: ${res}`);
main(gpio);
}).catch(rej => {
console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] E: Error during initialization: ${rej}`);
process.exit(1);
});
break;
case 'false':
console.log(`I: Not running on a Raspberry Pi.`);
var gpio = 'gpio';
fn.init(gpio).then(res => {
console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] I: ${res}`);
main(gpio);
}).catch(rej => {
console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] E: Error during initialization: ${rej}`);
process.exit(1);
});
break;
default:
console.log(`[${Date.now() - config.timestamps.procStart}] E: Problem with ENV file.`);
process.exit(1);
break;
}
}).catch(rej => {
console.error(`[${(Date.now() - config.timestamps.procStart)/1000}] E: Problem refreshing the config: ${rej}`);
process.exit(1);
});
function main(gpio) {
// If the auger is enabled
if (config.status.auger == 1) {
// Run a cycle of the auger
fn.auger.cycle(gpio).then(res => {
if (process.env.DEBUG) console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] I: ${res}`);
fn.checkForQuit().then(n => {
fn.commands.refreshConfig().then(res => {
if (process.env.DEBUG) console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] I: ${res.status}`);
config = res.config;
// Recursion ecursion cursion ursion rsion sion ion on n
main(gpio);
}).catch(rej => {
console.error(`[${(Date.now() - config.timestamps.procStart)/1000}] E: Problem refreshing the config: ${rej}`);
// Recursion ecursion cursion ursion rsion sion ion on n
main(gpio);
});
});
}).catch(err => {
if (process.env.DEBUG) console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] E: ${err}`);
});
} else {
// If the auger is disabled
fn.commands.pause().then(res => {
fn.checkForQuit().then(n => {
fn.commands.refreshConfig().then(res => {
if (process.env.DEBUG) console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] I: ${res.status}`);
config = res.config;
// Recursion ecursion cursion ursion rsion sion ion on n
main(gpio);
}).catch(rej => {
console.error(`[${(Date.now() - config.timestamps.procStart)/1000}] E: Problem refreshing the config: ${rej}`);
// Recursion ecursion cursion ursion rsion sion ion on n
main(gpio);
});
});
}).catch(err => {
if (process.env.DEBUG) console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] E: ${err}`);
// Recursion ecursion cursion ursion rsion sion ion on n
main(gpio);
});
}
}

View File

@ -1,83 +0,0 @@
/* Pellet Stove Control Panel
* Web Configuration Server
* v0.0.0 by Skylar Grant
*
* TODOs:
* Implement Express to make it easier
* Add actual data into the responses
*/
const express = require('express');
const http = require('http');
const fn = require('./functions.js').functions;
var config;
fn.commands.refreshConfig().then(newConfig => {
config = newConfig.config;
});
const { dbfn } = require('./functions.js');
const app = express();
const server = http.createServer(app);
app.use(express.urlencoded());
// Our root directory for the public web files
app.use(express.static(__dirname + '/../www/public'));
// Our directory for views used to render the pages
app.set('views', __dirname + '/../www/views');
// Set .html as the file extension for views
app.engine('html', require('ejs').renderFile);
app.set('view engine', 'html');
// A normal load of the root page
app.get('/', (req, res) => {
// if (process.env.DEBUG) console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] I: ${JSON.stringify(config)}`);
res.render('index', { config: JSON.stringify(config) });
});
// A POST form submission to the root page
app.post('/', (req, response) => {
if (req.body.start != undefined) {
fn.commands.startup();
fn.commands.refreshConfig().then(res => {
config = res.config;
response.render('index', { config: JSON.stringify(config) });
return;
});
}
if (req.body.shutdown != undefined) {
fn.commands.shutdown();
fn.commands.refreshConfig().then(res => {
config = res.config;
response.render('index', { config: JSON.stringify(config) });
return;
});
}
if (req.body.reload != undefined) {
const updateAugerOffIntervalQuery = `UPDATE intervals SET value = '${2000 - req.body.feedRate}' WHERE key = 'auger_off'`;
const updateAugerOnIntervalQuery = `UPDATE intervals SET value = '${req.body.feedRate}' WHERE key = 'auger_on'`;
dbfn.run(updateAugerOffIntervalQuery).then(res => {
if (process.env.DEBUG) console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] I: Auger off interval updated: ${res.data.changes}`);
dbfn.run(updateAugerOnIntervalQuery).then(res => {
if (process.env.DEBUG) console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] I: Auger on interval updated: ${res.data.changes}`);
fn.commands.refreshConfig().then(res => {
config = res.config;
response.render('index', { config: JSON.stringify(config) });
return;
});
}).catch(err => console.log(`E: ${err}`));
}).catch(err => console.log(`E: ${err}`));
}
if (req.body.quit != undefined) {
fn.commands.quit();
fn.commands.refreshConfig().then(res => {
config = res.config;
response.render('index', { config: JSON.stringify(config) });
return;
});
}
});
module.exports = {
start: () => {
server.listen(8080, "0.0.0.0");
}
};

View File

@ -1,158 +0,0 @@
const dbfn = require('../modules/database.js');
// Create `status` table
/*
+ ----- + ------------- + ---- + --- + ------- + -------------- +
| Field | Type | Null | Key | Default | Extra |
+ ----- + ------------- + ---- + --- + ------- + -------------- +
| key | varchar(100) | No | | | |
| value | varchar(1000) | No | | | |
+ ----- + ------------- + ---- + --- + ------- + -------------- +
+ ------------------- +
| igniter |
| blower |
| auger |
| igniter_finished |
| shutdown_initiated |
| vacuum |
| proof_of_fire |
| shutdown_next_cycle |
+ ------------------- +
CREATE TABLE IF NOT EXISTS status (
key varchar(100) NOT NULL,
value varchar(1000) NOT NULL
);
*/
const createStatusTableQuery = "CREATE TABLE IF NOT EXISTS status (key varchar(100) NOT NULL,value varchar(1000) NOT NULL);";
dbfn.run(createStatusTableQuery).then(res => {
console.log(res.status);
const statusEntries = {
igniter: 0,
blower: 0,
auger: 0,
igniter_finished: false,
shutdown_initiated: 0,
vacuum: 0,
proof_of_fire: 0,
shutdown_next_cycle: 0
};
for ( key in statusEntries ){
const insertStatusEntryQuery = `INSERT INTO status (key, value) VALUES ("${key}", "${statusEntries[key]}")`;
dbfn.run(insertStatusEntryQuery).then(res => {
console.log(`${res.status}: ${res.data.lastID}: ${res.data.changes} changes`);
}).catch(err => console.error(err));
}
const selectAllStatusEntriesQuery = "SELECT * FROM status";
dbfn.all(selectAllStatusEntriesQuery).then(res => {
console.log(res.status);
}).catch(err => console.error(err));
}).catch(err => {
console.error(err);
});
// Create `timestamps` table
/*
+ ----- + ------------- + ---- + --- + ------- + -------------- +
| Field | Type | Null | Key | Default | Extra |
+ ----- + ------------- + ---- + --- + ------- + -------------- +
| key | varchar(100) | No | | | |
| value | varchar(1000) | No | | | |
+ ----- + ------------- + ---- + --- + ------- + -------------- +
+ ------------- +
| process_start |
| blower_on |
| blower_off |
| igniter_on |
| igniter_off |
+ ------------- +
CREATE TABLE IF NOT EXISTS timestamps (
key varchar(100) NOT NULL,
value varchar(1000) NOT NULL
);
*/
const createTimestampsTableQuery = "CREATE TABLE IF NOT EXISTS timestamps (key varchar(100) NOT NULL,value varchar(1000) NOT NULL);";
dbfn.run(createTimestampsTableQuery).then(res => {
console.log(res.status);
const timestampsEntries = {
process_start: 0,
blower_on: 0,
blower_off: 0,
igniter_on: 0,
igniter_off: 0
};
for ( key in timestampsEntries ){
const insertTimestampsEntryQuery = `INSERT INTO timestamps (key, value) VALUES ("${key}", "${timestampsEntries[key]}")`;
dbfn.run(insertTimestampsEntryQuery).then(res => {
console.log(`${res.status}: ${res.data.lastID}: ${res.data.changes} changes`);
}).catch(err => console.error(err));
}
const selectAllTimestampsEntriesQuery = "SELECT * FROM timestamps";
dbfn.all(selectAllTimestampsEntriesQuery).then(res => {
console.log(res.status);
}).catch(err => console.error(err));
}).catch(err => {
console.error(err);
});
// Create `intervals` table
/*
+ ----- + ------------- + ---- + --- + ------- + -------------- +
| Field | Type | Null | Key | Default | Extra |
+ ----- + ------------- + ---- + --- + ------- + -------------- +
| key | varchar(100) | No | | | |
| value | varchar(1000) | No | | | |
+ ----- + ------------- + ---- + --- + ------- + -------------- +
+ ------------- +
| auger_on |
| auger_off |
| pause |
| igniter_start |
| blower_stop |
+ ------------- +
CREATE TABLE IF NOT EXISTS intervals (
key varchar(100) NOT NULL,
value varchar(1000) NOT NULL
);
*/
const createIntervalsTableQuery = "CREATE TABLE IF NOT EXISTS intervals (key varchar(100) NOT NULL,value varchar(1000) NOT NULL);";
dbfn.run(createIntervalsTableQuery).then(res => {
console.log(res.status);
const intervalsEntries = {
auger_on: 600,
auger_off: 1400,
pause: 5000,
igniter_start: 420000,
blower_stop: 600000
};
for ( key in intervalsEntries ){
const insertIntervalsEntryQuery = `INSERT INTO intervals (key, value) VALUES ("${key}", "${intervalsEntries[key]}")`;
dbfn.run(insertIntervalsEntryQuery).then(res => {
console.log(`${res.status}: ${res.data.lastID}: ${res.data.changes} changes`);
}).catch(err => console.error(err));
}
const selectAllIntervalsEntriesQuery = "SELECT * FROM intervals";
dbfn.all(selectAllIntervalsEntriesQuery).then(res => {
console.log(res.status);
}).catch(err => console.error(err));
}).catch(err => {
console.error(err);
});
// Show the tables to confirm they were created properly:
dbfn.showTables().then(res => {
res.rows.forEach(row => {
console.log("Table: " + JSON.stringify(row));
});
}).catch(err => {
console.error(err);
});

View File

@ -1,53 +0,0 @@
const sqlite3 = require('sqlite3').verbose();
const db = new sqlite3.Database('./data/config.db', (err) => {
if (err) throw `E: DB Connection: ${err.message}`;
console.log(`I: Connected to the database.`);
});
module.exports = {
run(query) {
return new Promise((resolve, reject) => {
db.serialize(() => {
db.run(query, function(err) {
if (err) {
reject("Problem executing the query: " + err.message);
return;
}
resolve( { "status": "Query executed successfully: " + query, "data": this });
});
});
});
},
all(query) {
return new Promise((resolve, reject) => {
db.serialize(() => {
db.all(query, (err, rows) => {
if (err) {
reject("Problem executing the query: " + err.message);
return;
}
// [ { key: 'key_name', value: '0' }, { key: 'key_name', value: '0' } ]
let organizedRows = {};
rows.forEach(row => {
organizedRows[row.key] = row.value;
});
resolve({ "status": "Query executed successfully: " + query, "rows": organizedRows });
});
});
});
},
showTables() {
return new Promise((resolve, reject) => {
db.serialize(() => {
db.all("SELECT name FROM sqlite_master WHERE type='table'", (err, rows) => {
if (err) {
reject("Problem executing the query: " + err.message);
return;
}
resolve({ "status": "Tables retreived successfully", "rows": rows });
});
});
});
}
};

View File

@ -1,292 +0,0 @@
// TODOs: Add tests for PoF and Vacuum switches, add delays for shutting down blower, test logic for igniter
// TODO: Move these to config
// Physical Pin numbers for GPIO
const augerPin = 7; // Pin for controlling the relay for the pellet auger motor.
// Require the package for pulling version numbers
const package = require('../package.json');
// Database Functions
const dbfn = require('./database.js');
// Get environment variables
const dotenv = require('dotenv').config();
// Module for working with files
const fs = require('fs');
const { exec } = require('child_process');
var config = require('../templates/config.json');
// The functions we'll export to be used in other files
const functions = {
auger: {
// Gets called once the Auger Pin has been setup by rpi-gpio
ready(err) {
if (err) throw err;
console.log('Auger GPIO Ready');
return;
},
// Turns the auger on (Pin 7 high)
on(gpio) {
return new Promise((resolve) => {
if (process.env.ONPI == 'true') {
gpio.write(augerPin, true, function (err) {
if (err) throw err;
resolve('Auger turned on.');
});
} else {
resolve('Simulated auger turned on.');
}
});
},
// Turns the auger off (pin 7 low)
off(gpio) {
return new Promise((resolve) => {
if (process.env.ONPI == 'true') {
gpio.write(augerPin, false, function (err) {
if (err) throw err;
resolve('Auger turned off.');
});
} else {
resolve('Simulated auger turned off.');
}
});
},
// Cycles the auger using the two functions above this one (functions.auger.on() and functions.auger.off())
// Sleeps in between cycles using functions.sleep()
cycle(gpio) {
return new Promise((resolve) => {
// Turn the auger on
this.on(gpio).then((res) => {
// Log action if in debug mode
// if (process.env.DEBUG) console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] I: ${res}`);
// Sleep for the time set in env variables
functions.sleep(config.intervals.augerOn).then((res) => {
// Log action if in debug mode
// if (process.env.DEBUG) console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] I: ${res}`);
// Turn the auger off
this.off(gpio).then((res) => {
// Log action if in debug mode
// if (process.env.DEBUG) console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] I: ${res}`);
// Sleep for the time set in env variables
functions.sleep(config.intervals.augerOff).then((res) => {
// Log action if in debug mode
// if (process.env.DEBUG) console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] I: ${res}`);
// Resolve the promise, letting the main script know the cycle is complete
resolve(`Auger cycled (${config.intervals.augerOn}/${config.intervals.augerOff})`);
});
});
});
});
});
},
},
commands: {
// Prepare the stove for starting
startup() {
// Basic startup just enables the auger
const enableAugerQuery = "UPDATE status SET value = 1 WHERE key = 'auger'";
dbfn.run(enableAugerQuery).then(res => {
console.log(`[${(Date.now() - config.timestamps.procStart) / 1000}] I: Auger enabled.`);
return;
}).catch(err => console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] E: ${err}`));
},
shutdown() {
// Basic shutdown only needs to disable the auger
const disableAugerQuery = "UPDATE status SET value = 0 WHERE key = 'auger'";
dbfn.run(disableAugerQuery).then(res => {
if (process.env.DEBUG) console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] I: ${res.status}`);
console.log(`[${(Date.now() - config.timestamps.procStart) / 1000}] I: Auger disabled.`);
return;
}).catch(err => console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] E: ${err}`));
},
// Pauses the script for the time defined in env variables
pause() {
return new Promise((resolve) => {
if (process.env.DEBUG) console.log(`[${(Date.now() - config.timestamps.procStart) / 1000}] I: Pausing for ${config.intervals.pause}ms`);
functions.sleep(config.intervals.pause).then((res) => {
if (process.env.DEBUG) console.log(`[${(Date.now() - config.timestamps.procStart) / 1000}] I: Pause finished.`);
resolve();
});
});
},
// Reload the environment variables on the fly
reload(envs) {
return new Promise((resolve) => {
// Re-require dotenv because inheritance in js sucks
const dotenv = require('dotenv').config({ override: true });
// Delete the reload file
fs.unlink('./reload', (err) => {
if (err) throw err;
if (process.env.DEBUG) console.log('Deleted reload file.');
});
// Print out the new environment variables
// This should be printed regardless of debug status, maybe prettied up TODO?
console.log('Reloaded environment variables.');
console.log(`ONTIME=${config.intervals.augerOn}\nOFFTIME=${config.intervals.augerOff}\nPAUSETIME=${config.intervals.pause}\nDEBUG=${process.env.DEBUG}\nONPI=${process.env.ONPI}`);
// Resolve the promise, letting the main script know we're done reloading the variables and the cycle can continue
resolve();
});
},
refreshConfig() {
return new Promise((resolve, reject) => {
// When the reload button is pressed, the call to this function will contain new config values
// {
// augerOff: 500,
// augerOn: 1500,
// pause: 5000
// }
// if (newSettings != undefined) {
// config.intervals.augerOff = newSettings.augerOff;
// config.intervals.augerOn = newSettings.augerOn;
// console.log(`[${(Date.now() - config.timestamps.procStart)/1000}] I: Intervals updated: (${newSettings.augerOn}/${newSettings.augerOff})`);
// }
// fs.writeFile('./config.json', JSON.stringify(config), (err) => {
// if (err) reject(err);
// resolve();
// });
// Get status
const selectStatusQuery = "SELECT * FROM status";
dbfn.all(selectStatusQuery).then(res => {
let { status } = config;
let { rows } = res;
status.auger = rows.auger;
status.blower = rows.blower;
status.igniter = rows.igniter;
status.igniterFinished = rows.igniter_finished;
status.pof = rows.proof_of_fire;
status.shutdownNextCycle = rows.shutdown_next_cycle;
status.vacuum = rows.vacuum;
// Get timestamps
const selectTimestampsQuery = "SELECT * FROM timestamps";
dbfn.all(selectTimestampsQuery).then(res => {
let { timestamps } = config;
let { rows } = res;
timestamps.blowerOff = rows.blower_off;
timestamps.blowerOn = rows.blower_on;
timestamps.igniterOff = rows.igniter_off;
timestamps.igniterOn = rows.igniter_on;
timestamps.procStart = rows.process_start;
// Get intervals
const selectIntervalsQuery = "SELECT * FROM intervals";
dbfn.all(selectIntervalsQuery).then(res => {
let { intervals } = config;
let { rows } = res;
intervals.augerOff = rows.auger_off;
intervals.augerOn = rows.auger_on;
intervals.blowerStop = rows.blower_stop;
intervals.igniterStart = rows.igniter_start;
intervals.pause = rows.pause;
resolve({ "status": "Refreshed the config", "config": config });
}).catch(err => {
reject(err);
return;
});
}).catch(err => {
reject(err);
return;
});
}).catch(err => {
reject(err);
return;
});
});
},
quit() {
functions.commands.shutdown();
functions.auger.off(gpio).then(res => {
console.log(`[${(Date.now() - config.timestamps.procStart) / 1000}] I: Exiting app...`);
process.exit(0);
}).catch(err => {
console.log(`[${(Date.now() - config.timestamps.procStart) / 1000}] E: Unable to shut off auger, rebooting Pi!`);
exec('shutdown -r 0');
});
}
},
// Sleeps for any given milliseconds
sleep(ms) {
return new Promise((resolve) => {
// if (process.env.DEBUG) console.log(`Sleeping for ${ms}ms`);
// Function to be called when setTimeout finishes
const finish = () => {
// Resolve the promise
resolve(`Slept for ${ms}ms`);
};
// The actual sleep function, sleeps for ms then calls finish()
setTimeout(finish, ms);
});
},
// Initializes rpi-gpio, or resolves if not on a raspberry pi
init(gpio) {
fs.readFile('./templates/config.json', (err, data) => {
fs.writeFile('./config.json', data, (err) => {
if (err) throw err;
config = require('../config.json');
})
})
// TODO this boot splash needs updating
return new Promise((resolve, reject) => {
// Boot/About/Info
console.log(`== Lennox Winslow PS40
== Pellet Stove Control Panel
== Author: Skylar Grant
== Version: v${package.version}
==
== Startup Time: ${new Date().toISOString()}
==
== Environment variables:
== == ONTIME=${config.intervals.augerOn}
== == OFFTIME=${config.intervals.augerOff}
== == PAUSETIME=${config.intervals.pause}
== == DEBUG=${process.env.DEBUG}
== == ONPI=${process.env.ONPI}`);
// Set up GPIO 4 (pysical pin 7) as output, then call functions.auger.ready()
if (process.env.ONPI == 'true') {
// Init the Auger pin
gpio.setup(augerPin, gpio.DIR_OUT, (err) => {
if (err) reject(err);
if (process.env.DEBUG) console.log('== Auger pin initialized.');
// Resolve the promise now that all pins have been initialized
resolve('== GPIO Initialized.');
});
} else {
// Resolve the promise
resolve('== GPIO Not Available');
}
});
},
checkForQuit() {
if (config.status.shutdownNextCycle == 1) {
console.log(`[${(Date.now() - config.timestamps.procStart) / 1000}] I: Exiting Process!`);
process.exit();
}
return new Promise((resolve, reject) => {
if (fs.existsSync('./quit')) {
fs.unlink('./quit', err => {
if (err) console.log('Error removing the quit file: ' + err);
config.status.shutdownNextCycle = 1;
config.status.auger = 0;
resolve();
});
} else {
resolve('Not shutting down');
}
});
},
time(stamp) {
const time = new Date(stamp);
return `${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}`;
}
}
// Export the above object, functions, as a module
module.exports = { functions, dbfn };

3770
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,19 @@
{
"name": "pscontrolpanel",
"version": "0.2.1",
"requires": true,
"packages": {},
"dependencies": {
"body-parser": "^1.20.1",
"dotenv": "^16.0.3",
"ejs": "^3.1.8",
"express": "^4.18.2",
"rpi-gpio": "^2.1.7",
"sequelize": "^6.28.0",
"sqlite3": "^5.1.4"
}
"name": "hestia",
"version": "1.0.0",
"description": "Hestia Pellet Stove Controller by Skylar Grant",
"main": "src/main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "https://git.vfsh.dev/voidf1sh/hestia"
},
"author": "Skylar Grant",
"license": "MIT",
"dependencies": {
"dotenv": "^16.4.5",
"mqtt": "^5.10.0"
}
}

28
src/_EMR/auger_loop.js Normal file
View File

@ -0,0 +1,28 @@
// Import modules
const gpio = require('../custom_modules/VoidGPIO.js');
const config = require('../custom_modules/config.json');
const fn = require('../custom_modules/functions.js');
// Variables
process.pinMap = new Map();
fn.log('Initializing pinMap', 'DEBUG');
for (const pin of config.pins) {
process.pinMap.set(pin.key, pin);
}
// Auger Loop
fn.log('Starting auger loop', 'DEBUG');
setInterval(() => {
fn.log('Running auger', 'DEBUG');
gpio.setPin(process.pinMap.get('auger').board, 1);
setTimeout(() => {
gpio.setPin(process.pinMap.get('auger').board, 0);
}, process.env.AUGER_CYCLE_TIME || 500);
}, 2000);
process.on('SIGINT', () => {
fn.log(`Exiting gracefully...`, 'INFO');
gpio.setPin(process.pinMap.get('auger').board, 0);
process.exit();
});

17
src/_EMR/exh_off.js Normal file
View File

@ -0,0 +1,17 @@
// Import modules
const gpio = require('../custom_modules/VoidGPIO.js');
const config = require('../custom_modules/config.json');
const fn = require('../custom_modules/functions.js');
// Variables
process.pinMap = new Map();
fn.log('Initializing pinMap', 'DEBUG');
for (const pin of config.pins) {
process.pinMap.set(pin.key, pin);
}
fn.log('Turning off exhaust', 'INFO');
gpio.setPin(process.pinMap.get('exhaust').board, 1);
fn.log('Exhast off', 'INFO');
process.exit();

17
src/_EMR/exh_on.js Normal file
View File

@ -0,0 +1,17 @@
// Import modules
const gpio = require('../custom_modules/VoidGPIO.js');
const config = require('../custom_modules/config.json');
const fn = require('../custom_modules/functions.js');
// Variables
process.pinMap = new Map();
fn.log('Initializing pinMap', 'DEBUG');
for (const pin of config.pins) {
process.pinMap.set(pin.key, pin);
}
fn.log('Turning on exhaust', 'INFO');
gpio.setPin(process.pinMap.get('exhaust').board, 0);
fn.log('Exhast on', 'INFO');
process.exit();

17
src/_EMR/ign_off.js Normal file
View File

@ -0,0 +1,17 @@
// Import modules
const gpio = require('../custom_modules/VoidGPIO.js');
const config = require('../custom_modules/config.json');
const fn = require('../custom_modules/functions.js');
// Variables
process.pinMap = new Map();
fn.log('Initializing pinMap', 'DEBUG');
for (const pin of config.pins) {
process.pinMap.set(pin.key, pin);
}
fn.log('Turning off igniter', 'INFO');
gpio.setPin(process.pinMap.get('igniter').board, 0);
fn.log('Igniter off', 'INFO');
process.exit();

17
src/_EMR/ign_on.js Normal file
View File

@ -0,0 +1,17 @@
// Import modules
const gpio = require('../custom_modules/VoidGPIO.js');
const config = require('../custom_modules/config.json');
const fn = require('../custom_modules/functions.js');
// Variables
process.pinMap = new Map();
fn.log('Initializing pinMap', 'DEBUG');
for (const pin of config.pins) {
process.pinMap.set(pin.key, pin);
}
fn.log('Turning on igniter', 'INFO');
gpio.setPin(process.pinMap.get('igniter').board, 1);
fn.log('Igniter on', 'INFO');
process.exit();

66
src/_EMR/mqtt.sh Normal file
View File

@ -0,0 +1,66 @@
#!/bin/bash
# Path variables
ROOT_PATH="/srv/hestia"
EMR_FOLDER="src/_EMR"
MQTT_HOST="192.168.0.12"
IGN_TOPIC="hestia/emr/igniter"
EXH_TOPIC="hestia/emr/exhaust"
AUG_TOPIC="hestia/emr/auger"
FR_TOPIC="hestia/emr/feed-rate"
# Loop
while true; do
# Prompt for input
echo "###################################"
echo "# Hestia Emergency MQTT Panel #"
echo "###################################"
echo "# 1. Toggle Exhaust #"
echo "# 2. Toggle Igniter #"
echo "# 3. Toggle Auger Loop #"
echo "# 4. Set Feed Rate #"
echo "# 5. Edit ENV Variables #"
echo "###################################"
echo "# 0. Exit #"
echo "###################################"
# Read user input
read -p "Menu Option: " choice
# Switch case on input
case $choice in
1)
echo "Toggling Exhaust"
cd $ROOT_PATH
mosquitto_pub -h $MQTT_HOST -t $EXH_TOPIC -m "toggle"
;;
2)
echo "Toggling Igniter"
cd $ROOT_PATH
mosquitto_pub -h $MQTT_HOST -t $IGN_TOPIC -m "toggle"
;;
3)
echo "Toggling Auger Loop"
cd $ROOT_PATH
mosquitto_pub -h $MQTT_HOST -t $AUG_TOPIC -m "toggle"
;;
4)
echo "Setting Feed Rate"
read -p "Enter Feed Rate: " feed_rate
cd $ROOT_PATH
mosquitto_pub -h $MQTT_HOST -t $FR_TOPIC -m $feed_rate
;;
5)
echo "Editing ENV Variables"
nano $ROOT_PATH/.env
;;
0)
echo "Exiting"
exit
break
;;
*)
echo "Invalid input"
;;
esac
done

83
src/_EMR/panel.sh Executable file
View File

@ -0,0 +1,83 @@
#!/bin/bash
# Path variables
ROOT_PATH="/srv/hestia"
EMR_FOLDER="src/_EMR"
# Loop
while true; do
# Prompt for input
echo "###################################"
echo "# Hestia Emergency Control Panel #"
echo "###################################"
echo "# 1. Exhaust ON #"
echo "# 2. Exhaust OFF #"
echo "# #"
echo "# 3. Igniter ON #"
echo "# 4. Igniter OFF #"
echo "# #"
echo "# 5. Start Auger Loop #"
echo "# 6. Stop Auger Loop #"
echo "# #"
echo "# 7. Restart Auger Loop #"
echo "# 8. Edit ENV Variables #"
echo "###################################"
echo "# 0. Exit #"
echo "###################################"
# Read user input
read -p "Menu Option: " choice
# Switch case on input
case $choice in
1)
echo "Turning Exhaust ON"
cd $ROOT_PATH
node $EMR_FOLDER/exh_on.js
# Return to the prompt
;;
2)
echo "Turning Exhaust OFF"
cd $ROOT_PATH
node $EMR_FOLDER/exh_off.js
;;
3)
echo "Turning Igniter ON"
cd $ROOT_PATH
node $EMR_FOLDER/ign_on.js
;;
4)
echo "Turning Igniter OFF"
cd $ROOT_PATH
node $EMR_FOLDER/ign_off.js
;;
5)
echo "Starting Auger Loop"
cd $ROOT_PATH
pm2 start $EMR_FOLDER/auger_loop.js --name hestia-emr
;;
6)
echo "Stopping Auger Loop"
pm2 stop hestia-emr
;;
7)
echo "Restarting Auger Loop"
pm2 restart hestia-emr
;;
8)
echo "Editing ENV Variables"
cd $ROOT_PATH
nano .env
;;
0)
echo "Exiting"
exit
break
;;
*)
echo "Invalid input"
;;
esac
done

View File

@ -0,0 +1,206 @@
const EventEmitter = require('events');
const mqtt = require('mqtt');
module.exports = {
// State class
State: class State extends EventEmitter {
constructor(config) {
super();
this.publisher = 'backend';
this.igniter = {
on: false,
name: "igniter",
topic: config.mqtt.topics.igniter,
publisher: this.publisher,
power: (comlink, pinState) => {
// Set the power based on the desired pinState
this.igniter.on = pinState === 1 ? true : false;
comlink.send(config.mqtt.topics.igniter, JSON.stringify(this.igniter));
}
};
this.exhaust = {
on: false,
name: "exhaust",
topic: config.mqtt.topics.exhaust,
publisher: this.publisher,
power: (comlink, pinState) => {
// Set the power based on the desired pinState
this.exhaust.on = pinState === 1 ? true : false;
comlink.send(config.mqtt.topics.exhaust, JSON.stringify(this.exhaust));
}
};
this.auger = {
on: false,
name: "auger",
feedRate: 500,
topic: config.mqtt.topics.auger,
publisher: this.publisher,
power: (comlink, pinState) => {
// Set the power based on the desired pinState
this.auger.on = pinState === 1 ? true : false;
comlink.send(config.mqtt.topics.auger, JSON.stringify(this.auger));
}
};
this.pof = {
on: false,
name: "pof",
topic: config.mqtt.topics.pof,
publisher: this.publisher,
power: (comlink, pinState) => {
// Set the power based on the desired pinState
this.pof.on = pinState === 1 ? true : false;
comlink.send(config.mqtt.topics.pof, JSON.stringify(this.pof));
}
};
this.vacuum = {
on: false,
name: "vacuum",
topic: config.mqtt.topics.vacuum,
publisher: this.publisher,
power: (comlink, pinState) => {
// Set the power based on the desired pinState
this.vacuum.on = pinState === 1 ? true : false;
comlink.send(config.mqtt.topics.vacuum, JSON.stringify(this.vacuum));
}
};
this.startup = {
topic: config.mqtt.topics.startup
};
this.emrIgniter = {
topic: config.mqtt.topics.emrIgniter
};
this.emrExhaust = {
topic: config.mqtt.topics.emrExhaust
};
this.emrAuger = {
topic: config.mqtt.topics.emrAuger
};
this.emrFeedRate = {
topic: config.mqtt.topics.emrFeedRate
};
this.shutdown = {
topic: config.mqtt.topics.shutdown
};
this.emit('announcement', `State initialized.`)
return this;
};
},
// Communicator class
Communicator: class Communicator extends EventEmitter {
constructor(state) {
super();
this.publisher = state.publisher;
return this;
}
init(state, host, port, config) {
// Connect to the MQTT Broker
this.emit('announcement', `Attempting MQTT connection to broker: ${host}:${port}`);
this.client = mqtt.connect(host, {
port: port,
});
const { client } = this;
client.on('connect', () => {
this.emit('announcement', 'Connected to MQTT broker');
// Subscribe to status topics
config.states.elements.forEach(element => {
client.subscribe(state[element].topic, (err) => {
if (!err) {
this.emit('announcement', `Subscribed to ${state[element].topic}`);
}
});
});
});
client.on('disconnect', () => {
this.emit('announcement', 'Disconnected from MQTT broker');
});
// Handle when the Broker sends us a message
client.on('message', (topic, message) => {
if (topic.startsWith('hestia/status')) {
// Save the existing state
const oldState = JSON.parse(JSON.stringify(state));
// The message is a buffer which will need to be converted to string
const msgStr = message.toString();
// Since the message is a JSON object, we can parse it
const msgJson = JSON.parse(msgStr);
// Log the message
// this.emit('announcement', `Message received on topic ${topic}:`);
// this.emit('announcement', msgJson);
// Check if the message is from the backend
if (msgJson.publisher === this.publisher) {
// this.emit('announcement', 'Message is from the backend, ignoring');
return;
}
// this.emit('announcement', 'Message is from the frontend, updating state');
// Update the state
state[msgJson.name] = msgJson;
// Emit the state change
this.emit('stateChange', oldState, state);
} else if (topic === 'hestia/command/startup') {
// Empty block for 'hestia/command' topics
this.emit('startup');
} else if (topic === 'hestia/command/shutdown') {
// Empty block for 'hestia/command' topics
this.emit('shutdown');
} else {
switch (topic) {
case 'hestia/emr/igniter':
this.emit('igniter', message.toString());
break;
case 'hestia/emr/exhaust':
this.emit('exhaust', message.toString());
break;
case 'hestia/emr/auger':
this.emit('auger', message.toString());
break;
case 'hestia/emr/feed-rate':
this.emit('feedRate', message.toString());
break;
default:
this.emit('announcement', `Unknown topic: ${topic}`);
break;
}
}
});
// Other MQTT client events [pingreq, pingresp, disconnect]
client.on('pingreq', () => {
this.emit('announcement', 'Ping request received from MQTT broker');
});
client.on('pingresp', () => {
this.emit('announcement', 'Ping response sent to MQTT broker');
});
client.on('disconnect', () => {
this.emit('announcement', 'Disconnected from MQTT broker');
});
}
// Publish a message to the MQTT Broker
send(topic, message) {
// Publish with retain flag set to true
this.client.publish(topic, message, { retain: true }, (err) => {
if (err) {
console.error('Failed to publish message:', err);
} else {
this.emit('announcement', 'Message published and retained on topic:', topic);
}
});
}
}
};

View File

@ -0,0 +1,39 @@
const { exec } = require('child_process');
let gpioScript = new String();
if (process.env.GPIO_MODE === 'EMULATED') {
gpioScript = 'src/python/fake_gpio_interface.py';
} else if (process.env.GPIO_MODE === 'PHYSICAL') {
gpioScript = 'src/python/gpio_interface.py';
} else {
throw new Error('GPIO_MODE environment variable not set');
}
console.log(`GPIO_MODE: ${process.env.GPIO_MODE}`);
module.exports = {
// Calls the GPIO Interface script to read a pin's state
readPin(pin) {
return new Promise((resolve, reject) => {
exec(`python3 ${gpioScript} read ${pin}`, (error, stdout, stderr) => {
if (error) reject(error);
if (stderr) reject(new Error(stderr));
resolve(stdout.trim());
});
});
},
// Calls the GPIO Interface script to set a pin's state regardless of its current state
setPin(pin, state) {
return new Promise((resolve, reject) => {
exec(`python3 ${gpioScript} set ${pin} ${state}`, (error, stdout, stderr) => {
if (error) {
reject(error);
}
if (stderr) {
reject(new Error(stderr));
}
resolve();
})
});
}
}

View File

@ -0,0 +1,71 @@
{
"mqtt": {
"topics": {
"igniter": "hestia/status/igniter",
"exhaust": "hestia/status/exhaust",
"auger": "hestia/status/auger",
"pof": "hestia/status/pof",
"vacuum": "hestia/status/vacuum",
"startup": "hestia/command/startup",
"shutdown": "hestia/command/shutdown",
"emrIgniter": "hestia/emr/igniter",
"emrExhaust": "hestia/emr/exhaust",
"emrAuger": "hestia/emr/auger",
"emrFeedRate": "hestia/emr/feed-rate"
}
},
"states": {
"elements": [
"igniter",
"exhaust",
"auger",
"pof",
"vacuum",
"emrIgniter",
"emrExhaust",
"emrAuger",
"emrFeedRate"
]
},
"pins": [
{
"key": "igniter",
"board": 13,
"bcm": 27,
"mode": "OUT",
"defaultState": 0
},
{
"key": "exhaust",
"board": 15,
"bcm": 22,
"mode": "OUT",
"defaultState": 1
},
{
"key": "auger",
"board": 7,
"bcm": 4,
"mode": "OUT",
"defaultState": 0
},
{
"key": "pof",
"board": 16,
"bcm": 23,
"mode": "IN"
}
],
"power": {
"start": {
"vacuumCheckDelay": 5000,
"igniterPreheat": 60000,
"igniterDelay": 420000
},
"stop": {
"exhaustDelay": 600000
}
},
"gpioScript": "src/python/gpio_interface.py",
"augerTotalCycleTime": 2000
}

View File

@ -0,0 +1,339 @@
const debug = process.env.DEBUG === "TRUE";
const config = require('./config.json');
const { pins } = config;
const gpio = require('./VoidGPIO.js');
module.exports = {
log(message, level) {
if (level) {
switch (level) {
case 'INFO':
console.log(`INFO: ${message}`);
break;
case 'DEBUG':
if (debug) {
console.log(`DEBUG: ${message}`);
}
break;
case 'AUGER':
if(process.env.DEBUG_AUGER === "TRUE") console.log(`AUGER: ${message}`);
break;
case 'ERROR':
console.error(`ERROR: ${message}`);
break;
default:
break;
}
} else {
if (debug) {
console.log(`DEBUG: ${message}`);
}
}
},
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
},
gpio: {
setDefaults(comlink, state) {
// Set all output pins to their default states
return new Promise((resolve, reject) => {
// Create an array to store state changes
let stateChanges = [];
// Create an array of promises for each pin
const promises = pins.map(pin => {
if (pin.mode === 'OUT') {
return gpio.setPin(pin.board, pin.defaultState).then(() => {
stateChanges.push(` -- Set Defaults: Set ${pin.key} pin to ${pin.defaultState}.`);
state[pin.key].power(comlink, pin.defaultState);
// Wait a second and check the pin states
setTimeout(() => {
gpio.readPin(pin.board).then(pinState => {
module.exports.log(`Set Defaults: Confirm Pin: ${pin.key} is ${pinState}`, 'DEBUG');
}).catch(e => console.error(e));
}, 1000);
}).catch(e => console.error(e));
} else if (pin.mode === 'IN') {
return gpio.readPin(pin.board).then(pinState => {
const boolState = pinState === '1' ? true : false;
state[pin.key].on = boolState;
comlink.send(config.mqtt.topics[pin.key], JSON.stringify(state[pin.key]));
stateChanges.push(` -- Set Defaults: Read: ${pin.key}: ${pinState} [Old: ${state[pin.key].on}]`);
}).catch(e => console.error(e));
}
});
Promise.all(promises).then(() => {
const changes = stateChanges.join('\n');
resolve(changes);
}).catch(reject);
});
},
async init(comlink, state) {
module.exports.log('GPIO Init: Resetting all output pins.');
module.exports.gpio.setDefaults(comlink, state).then((changes) => {
module.exports.log(`GPIO Init:\n${changes}`);
}).catch(e => console.error(`GPIO Init: ${e}`));
}
},
routines: {
startup(comlink, state) {
return new Promise(async (resolve, reject) => {
const mod = module.exports;
// Set pins to default states
mod.gpio.setDefaults(comlink, state).then(changes => {
mod.log(changes);
}).catch(e => console.error(e));
// Turn on the exhaust
mod.power.exhaust.on(comlink, state).then((res) => {
// Wait for vacuum
mod.sleep(config.power.start.vacuumCheckDelay).then(() => {
// Vacuum switch is in series with auger so no need for logic check
// Turn on the auger
mod.power.auger.on(comlink, state).then((res) => {
// Wait for auger to start
}).catch(e => console.error(e));
});
}).catch(e => console.error(e));
});
},
shutdown(comlink, state) {
return new Promise(async (resolve, reject) => {
});
},
cycleAuger() {
const mod = module.exports;
// Check if the auger is enabled
if (process.psState.auger.on) {
mod.power.auger.on().then((res) => {
// Sleep while auger is feeding
mod.log(`Auger feeding at ${process.psState.auger.feedRate}ms.`, 'AUGER');
mod.sleep(process.psState.auger.feedRate).then(() => {
// Turn off auger
mod.power.auger.off().then((res) => {
mod.log('Auger cycle complete.', 'AUGER');
}).catch(e => reject(e));
});
}).catch(e => reject(e));
} else {
mod.log('Auger is disabled.', 'AUGER');
}
}
},
power: {
auger: {
on() {
return new Promise(async (resolve, reject) => {
gpio.setPin(process.pinMap.get('auger').board, 1).then(() => {
resolve('Auger powered on.');
}).catch(e => reject(e));
});
},
off() {
return new Promise(async (resolve, reject) => {
gpio.setPin(process.pinMap.get('auger').board, 0).then(() => {
resolve('Auger powered off.');
}).catch(e => reject(e));
});
}
},
exhaust: {
on() {
return new Promise(async (resolve, reject) => {
gpio.setPin(process.pinMap.get('exhaust').board, 0).then(() => {
resolve('Exhaust powered on.');
}).catch(e => reject(e));
});
},
off() {
return new Promise(async (resolve, reject) => {
gpio.setPin(process.pinMap.get('exhaust').board, 1).then(() => {
resolve('Exhaust powered off.');
}).catch(e => reject(e));
});
}
},
igniter: {
on() {
return new Promise(async (resolve, reject) => {
gpio.setPin(process.pinMap.get('igniter').board, 1).then(() => {
resolve('Igniter powered on.');
}).catch(e => reject(e));
});
},
off() {
return new Promise(async (resolve, reject) => {
gpio.setPin(process.pinMap.get('igniter').board, 0).then(() => {
resolve('Igniter powered off.');
}).catch(e => reject(e));
});
}
},
start: {
init(comlink, state) {
return new Promise(async (resolve, reject) => {
// TODO: Check pin states?
// Start the exhaust
this.exhaust().then((res) => {
module.exports.log(res);
// Check for vacuum
this.vacuum().then((res) => {
module.exports.log(res);
// Start the auger
this.auger().then((res) => {
module.exports.log(res);
resolve('Startup sequence complete.');
}).catch(e => console.error(e));
// Preheat the igniter
this.igniter().then((res) => {
module.exports.log(res);
module.exports.sleep(config.power.start.igniterDelay).then((res) => {
module.exports.log(res);
// Check for fire
this.fire().then((res) => {
module.exports.log(res);
}).catch(e => console.error(e));
});
}).catch(e => console.error(e));
}).catch(e => console.error(e));
}).catch(e => console.error(e));
});
},
exhaust() {
return new Promise(async (resolve, reject) => {
// Start exhaust
gpio.setPin(process.pinMap.get('exhaust').board, 1).then(() => {
// Wait to resolve
module.exports.sleep(config.power.start.exhaustDelay).then(() => {
resolve('Exhaust started.');
});
}).catch(e => console.error(e));
});
},
vacuum() {
return new Promise(async (resolve, reject) => {
// Check for vacuum
gpio.readPin(process.pinMap.get('vacuum').board).then(state => {
if (state === '0') {
reject(new Error('Vacuum failure.'));
} else {
resolve('Vacuum established.');
}
}).catch(e => console.error(e));
});
},
igniter() {
return new Promise(async (resolve, reject) => {
// Start igniter
gpio.setPin(process.pinMap.get('igniter').board, 1).then(() => {
// Wait to resolve
module.exports.sleep(config.power.start.igniterPreheat).then(() => {
resolve('Igniter preheated.');
});
}).catch(e => console.error(e));
});
},
auger() {
return new Promise(async (resolve, reject) => {
// Start auger
gpio.setPin(process.pinMap.get('auger').board, 1).then(() => {
resolve('Auger started.');
}).catch(e => console.error(e));
});
},
fire() {
return new Promise(async (resolve, reject) => {
// Check for fire
gpio.readPin(process.pinMap.get('pof').board).then(state => {
if (state === '0') {
reject(new Error('Failed ignition.'));
} else {
resolve('Successful ignition.');
}
}).catch(e => console.error(e));
});
}
},
stop: {
init() {
return new Promise(async (resolve, reject) => {
// Power off auger
gpio.setPin(process.pinMap.auger.board, 0).then(async () => {
// Wait for exhaust shutdown delay
await module.exports.sleep(config.power.stop.exhaustDelay);
// Power off exhaust
gpio.setPin(process.pinMap.exhaust.board, 0).then(() => {
// Report successful shutdown
resolve('Successful shutdown.');
}).catch(e => console.error(e));
}).catch(e => console.error(e));
});
}
}
},
handlers: {
stateChange(oldState, state) {
// Create a promise for each state to check
const promises = pins.map(pin => {
// Check if the power state changed
if (oldState[pin.key].on !== state[pin.key].on) {
// Did it turn on or off?
if (state[pin.key].on) {
switch (pin.key) {
case 'exhaust':
module.exports.log('Exhaust powered on.', 'DEBUG');
return module.exports.power.exhaust.on();
case 'igniter':
module.exports.log('Igniter powered on.', 'DEBUG');
return module.exports.power.igniter.on();
case 'auger':
module.exports.log('Auger powered on.', 'DEBUG');
return module.exports.handlers.toggleAuger();
default:
module.exports.log('Invalid pin key.', 'ERROR');
throw new Error('Invalid pin key.');
}
} else {
switch (pin.key) {
case 'exhaust':
module.exports.log('Exhaust powered off.', 'DEBUG');
return module.exports.power.exhaust.off();
case 'igniter':
module.exports.log('Igniter powered off.', 'DEBUG');
return module.exports.power.igniter.off();
case 'auger':
module.exports.log('Auger powered off.', 'DEBUG');
return module.exports.handlers.toggleAuger();
default:
module.exports.log('Invalid pin key.', 'ERROR');
throw new Error('Invalid pin key.');
}
}
}
// Check if the feed rate changed
if (oldState.auger.feedRate !== state.auger.feedRate) {
module.exports.log(`Auger feed rate changed to ${state.auger.feedRate}ms.`, 'DEBUG');
return new Promise((resolve, reject) => {
resolve();
});
}
});
// Wait for all promises to resolve
Promise.all(promises).catch(e => console.error(e));
},
toggleAuger() {
return new Promise(async (resolve, reject) => {
// This is so stupid but I wanted to use promises.all so here we are (:
resolve();
});
}
}
}

151
src/main.js Normal file
View File

@ -0,0 +1,151 @@
/***************************************************************************************/
// Import modules
/***************************************************************************************/
const dotenv = require('dotenv');
dotenv.config();
const gpio = require('./custom_modules/VoidGPIO.js');
const config = require('./custom_modules/config.json');
const fn = require('./custom_modules/functions.js');
const { State, Communicator } = require('./custom_modules/HestiaClasses.js');
/***************************************************************************************/
// Variables
/***************************************************************************************/
process.pinMap = new Map();
for (const pin of config.pins) {
process.pinMap.set(pin.key, pin);
}
/***************************************************************************************/
// Initialization
/***************************************************************************************/
process.psState = new State(config);
process.comlink = new Communicator(process.psState);
/***************************************************************************************/
// Loops
/***************************************************************************************/
// Sensor Detection
setInterval(() => {
// Iterate through pins
for (const pin of config.pins) {
// If pin is an input, read it
if (pin.mode === 'IN') {
// fn.log(`Sensor Detection Loop: Reading pin ${pin.board}`, 'DEBUG');
// Read pin
gpio.readPin(pin.board).then(state => {
// fn.log(`Sensor Detection Loop: Pin ${pin.board} is ${state}`, 'DEBUG');
// Convert the state from string to boolean
const boolState = state === '1' ? true : false;
// Compare the state to the last known state
if (boolState !== process.psState[pin.key].on) {
// Update the state
process.psState[pin.key].on = boolState;
// Send the state to the MQTT broker
process.comlink.send(config.mqtt.topics[pin.key], JSON.stringify(process.psState[pin.key]));
fn.log(`Sensor Detection Loop: State Change: ${pin.key}: ${state} [Old: ${process.psState[pin.key].on}]`, 'DEBUG');
}
}).catch(e => fn.log(`Sensor Detection Loop: ${e}`, 'ERROR'));
}
}
}, 1000);
// Auger Feed
setInterval(fn.routines.cycleAuger, config.augerTotalCycleTime);
/***************************************************************************************/
// Event listeners
/***************************************************************************************/
process.comlink.on('stateChange', (oldState, state) => {
fn.log(`ComLink: State change detected.`, 'INFO');
fn.handlers.stateChange(oldState, state);
});
process.comlink.on('startup', () => {
fn.log(`ComLink: Startup detected.`, 'INFO');
fn.power.start.init(process.comlink, process.psState).catch(e => console.error(`E: Power Start Init: ${e}`));
});
process.comlink.on('shutdown', () => {
fn.log(`ComLink: Shutdown detected.`, 'INFO');
fn.power.stop.init(process.comlink, process.psState).catch(e => console.error(`E: Power Stop Init: ${e}`));
});
process.comlink.on('announcement', msg => {
fn.log(`ComLink: ${msg}`, 'INFO');
});
process.psState.on('announcement', msg => {
fn.log(`State: ${msg}`, 'INFO');
});
process.comlink.on('igniter', (message) => {
fn.log(`ComLink: V2 Igniter: ${message}`, 'INFO');
// Toggle the state
process.psState.igniter.on = !process.psState.igniter.on;
// Run the corresponding action
if (process.psState.igniter.on) {
fn.power.igniter.on().then(res => fn.log(res, 'DEBUG'))
.catch(error => fn.log(`ComLink: Igniter: ${error}`, 'ERROR'));
} else {
fn.power.igniter.off().then(res => fn.log(res, 'DEBUG'))
.catch(error => fn.log(`ComLink: Igniter: ${error}`, 'ERROR'));
}
});
process.comlink.on('exhaust', (message) => {
fn.log(`ComLink: V2 Exhaust: ${message}`, 'INFO');
// Toggle the state
process.psState.exhaust.on = !process.psState.exhaust.on;
// Run the corresponding action
if (process.psState.exhaust.on) {
fn.power.exhaust.on().then(res => fn.log(res, 'DEBUG'))
.catch(error => fn.log(`ComLink: Exhaust: ${error}`, 'ERROR'));
} else {
fn.power.exhaust.off().then(res => fn.log(res, 'DEBUG'))
.catch(error => fn.log(`ComLink: Exhaust: ${error}`, 'ERROR'));
}
});
process.comlink.on('auger', (message) => {
fn.log(`ComLink: V2 Auger: ${message}`, 'INFO');
// Toggle the state
process.psState.auger.on = !process.psState.auger.on;
// Nothing else to do since the auger loop always runs and checks the state
fn.log(`ComLink: Auger: ${process.psState.auger.on}`, 'INFO');
});
process.comlink.on('feedRate', (message) => {
fn.log(`ComLink: V2 Feed Rate: ${message}`, 'INFO');
// Check if the message is a number hiding in a string
if (!isNaN(message)) {
// Convert the message to a number
const feedRate = parseInt(message);
// Check if the feed rate is within the acceptable range
if (feedRate >= 350 && feedRate <= 1000) {
// Update the feed rate
process.psState.auger.feedRate = feedRate;
// Send the feed rate to the MQTT broker
fn.log(`ComLink: Feed Rate: ${feedRate}`, 'INFO');
} else {
fn.log(`ComLink: Invalid Feed Rate: ${message}`, 'ERROR');
}
} else {
fn.log(`ComLink: Invalid Feed Rate: ${message}`, 'ERROR');
}
});
process.on('SIGINT', () => {
fn.log(`Exiting gracefully...`, 'INFO');
// Fail-safe to turn off all the outputs and keep the exhaust fan on
gpio.setPin(process.pinMap.get('auger').board, 0);
gpio.setPin(process.pinMap.get('igniter').board, 0);
gpio.setPin(process.pinMap.get('exhaust').board, 0); // Exhaust 0: On 1: Off
process.exit();
});
/***************************************************************************************/
// Call Things
/***************************************************************************************/
process.comlink.init(process.psState, process.env.MQTT_HOST, process.env.MQTT_PORT, config);
fn.gpio.init(process.comlink, process.psState);

View File

@ -0,0 +1,75 @@
import sys
# Define the state of the pins
PIN_STATES = {
7: 0,
13: 0,
15: 0,
16: 0,
22: 0
}
# Define a function to get the pin state from the array
def get_pin_state(pin):
if pin in PIN_STATES:
return PIN_STATES[pin]
else:
print(f"Invalid pin: {pin}")
return -1
# Modify the read_pin function to use the get_pin_state function
def read_pin(pin):
try:
# Read pin state
state = get_pin_state(pin)
# Return 1 if pin is HIGH, 0 if LOW
return 1 if state == 1 else 0
except Exception as e:
# Handle errors
print(f"Error reading pin {pin}: {e}")
# Return -1 on error
return -1
# Modify the set_pin_state function to return success always
def set_pin_state(pin, state):
try:
# Set the pin state to either HIGH (1) or LOW (0)
# Exit with 0 for success
return 0
except Exception as e:
# Handle errors
print(f"Error setting pin {pin} to state {state}: {e}")
# Exit with 1 for failure
return 1
def main():
if len(sys.argv) < 3:
print("Usage: python3 gpio_interface.py <command> <pin> [<state>]")
sys.exit(1)
command = sys.argv[1].lower()
pin = int(sys.argv[2])
if command == "toggle":
result = toggle_pin(pin)
sys.exit(result)
elif command == "read":
result = read_pin(pin)
print(result)
sys.exit(0 if result >= 0 else 1)
elif command == "set":
if len(sys.argv) < 4:
print("Usage: python3 gpio_interface.py set <pin> <state>")
sys.exit(1)
state = int(sys.argv[3])
if state not in [0, 1]:
print("Invalid state. Use 0 for LOW or 1 for HIGH.")
sys.exit(1)
result = set_pin_state(pin, state)
sys.exit(result)
else:
print("Invalid command. Use 'toggle', 'read', or 'set'.")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,85 @@
import RPi.GPIO as GPIO
import sys
# Initialize GPIO using Board mode for pin numbering
GPIO.setmode(GPIO.BOARD)
GPIO.setwarnings(False)
# Define pin modes and states
def setup_pin(pin, mode):
if mode == 'OUT':
GPIO.setup(pin, GPIO.OUT)
elif mode == 'IN':
GPIO.setup(pin, GPIO.IN)
def toggle_pin(pin):
setup_pin(pin, 'OUT')
current_state = GPIO.input(pin)
try:
# Toggle pin state
GPIO.output(pin, GPIO.LOW if current_state == GPIO.HIGH else GPIO.HIGH)
# Exit with 0 for success
return 0
except Exception as e:
# Handle errors
print(f"Error toggling pin {pin}: {e}")
# Exit with 1 for failure
return 1
def read_pin(pin):
setup_pin(pin, 'IN')
try:
# Read pin state
state = GPIO.input(pin)
# Return 1 if pin is HIGH, 0 if LOW
return 1 if state == GPIO.HIGH else 0
except Exception as e:
# Handle errors
print(f"Error reading pin {pin}: {e}")
# Return -1 on error
return -1
def set_pin_state(pin, state):
setup_pin(pin, 'OUT')
try:
# Set the pin state to either HIGH (1) or LOW (0)
GPIO.output(pin, GPIO.HIGH if state == 1 else GPIO.LOW)
# Exit with 0 for success
return 0
except Exception as e:
# Handle errors
print(f"Error setting pin {pin} to state {state}: {e}")
# Exit with 1 for failure
return 1
def main():
if len(sys.argv) < 3:
print("Usage: python3 gpio_interface.py <command> <pin> [<state>]")
sys.exit(1)
command = sys.argv[1].lower()
pin = int(sys.argv[2])
if command == "toggle":
result = toggle_pin(pin)
sys.exit(result)
elif command == "read":
result = read_pin(pin)
print(result)
sys.exit(0 if result >= 0 else 1)
elif command == "set":
if len(sys.argv) < 4:
print("Usage: python3 gpio_interface.py set <pin> <state>")
sys.exit(1)
state = int(sys.argv[3])
if state not in [0, 1]:
print("Invalid state. Use 0 for LOW or 1 for HIGH.")
sys.exit(1)
result = set_pin_state(pin, state)
sys.exit(result)
else:
print("Invalid command. Use 'toggle', 'read', or 'set'.")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -1,30 +0,0 @@
{
"status": {
"igniter": 0,
"blower": 0,
"auger": 0,
"igniterFinished": false,
"shutdown": 0,
"vacuum": 0,
"pof": 0,
"shutdownNextCycle": 0
},
"timestamps": {
"procStart": 0,
"blowerOn": 0,
"blowerOff": 0,
"igniterOn": 0,
"igniterOff": 0
},
"intervals": {
"augerOn": "600",
"augerOff": "1400",
"pause": "3000",
"igniterStart": "5000",
"blowerStop": "5000"
},
"web": {
"port": 8080,
"ip": "0.0.0.0"
}
}

BIN
www/.DS_Store vendored

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

View File

@ -1,98 +0,0 @@
html, body {
padding: 0;
margin: 0;
background-color: #333;
color: aqua;
font-family: sans-serif;
}
#title {
text-align: center;
font-size: 35px;
margin-top: 10px;
padding-bottom: 15px;
}
#title a {
text-decoration: none;
color: inherit;
}
#safeties {
text-align: center;
}
.controls-container {
text-align: center;
}
#buttons button {
margin: 20px 5px;
font-size: 20px;
width: 150px;
height: 30px;
}
.controls-container input {
margin: 5px;
}
.subheading {
font-size: 20px;
font-weight: bold;
text-align: center;
}
#log-container {
text-align: center;
}
#log-area {
width: 100%;
height: 500px;
background-color: aqua;
color: aqua;
}
#trial {
display:;
color: yellow;
font-size: 20px;
background-color: red;
}
.button-container {
padding: 0;
margin-top: 20px;
margin-bottom: 20px;
text-align: center;
}
.btn {
background-color: #333;
color: aqua;
}
.button-selected {
margin: 5px;
border-width: 0px;
font-family: times;
font-size: 20px;
height: 35px;
width: 100px;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
table {
margin: 0 auto;
color: aqua !important;
}
table, th, td {
border: 1px solid;
border-collapse: collapse;
padding: 3px;
}

View File

@ -1,150 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Hestia Web Portal</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js" integrity="sha384-w76AqPfDkMBDXo30jS1Sgez6pr3x5MlQ1ZAGC+nuZB+EYdgRZgiwxhTBTkF7CXvN" crossorigin="anonymous"></script>
<link rel="stylesheet" href="/main.css">
</head>
<body onload="refreshData()" class="container">
<script>
// Get the config file
const config = <%- config %>;
console.log(<%- config %>);
</script>
<%- include('trial.html') -%>
<div id="title" class="text-center mb-4">
<a href='./'>Hestia Web Portal</a>
</div>
<div id="status" class="row">
<!--
| Auger | rows[0].cells[1] | On Time | rows[0].cells[3] |
| Feed Rate | rows[1].cells[1] | Off Time | rows[1].cells[3] |
-->
<table id="status-table" class="table table-bordered col-sm-12 col-md-6">
<tr>
<td>Auger</td>
<td></td>
<td>On Time</td>
<td></td>
</tr>
<tr>
<td>Feed Rate</td>
<td></td>
<td>Off Time</td>
<td></td>
</tr>
</table>
</div>
<div class="controls-container">
<form action="/" method="post">
<!-- Start | Shutdown | Reload Settings -->
<div class="button-container d-flex justify-content-between">
<input class="btn btn-outline-secondary" type="submit" id="ignite" value="Enable Auger" name="start">
<input class="btn btn-outline-secondary" type="submit" id="shutdown" value="Disable Auger" name="shutdown">
</div>
<!-- Set feed rates -->
<div class="form-group">
<label for="feedRate">Feed Rate: </label>
<select name="feedRate" class="form-control" id="feed-rate-select">
<option value="600">Low</option>
<option value="800">Medium</option>
<option value="1000">High</option>
</select>
</div>
<div class="button-container d-flex justify-content-end">
<input class="btn btn-outline-secondary" type="submit" id="reload" value="Set Feed Rate" name="reload">
</div>
</form>
</div>
<!-- <div class="text-center my-4">
<img src="./dancing_jesus.gif" class="img-fluid">
</div> -->
<div class="controls-container">
<form action="/" method="POST">
<input class="btn btn-danger" type="submit" id="quit" value="Quit!!" name="quit" style="visibility: hidden;">
</form>
</div>
<!-- <script src="./main.js"></script> -->
<script>
function sleep(ms) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
}, ms);
});
}
function readJSON(path) {
var request = new XMLHttpRequest();
request.open("GET", path, false);
request.send(null)
var JSONObj = JSON.parse(request.responseText);
return JSONObj;
}
function parseStatus(data) {
switch (data) {
case "0":
return "Off";
break;
case "1":
return "On";
break
default:
return "Error: " + data;
break;
}
}
function refreshData() {
// const log = document.getElementById('log-area');
// log.contentWindow.location.reload();
// sleep(100).then(() => {
// document.getElementById('log-area').contentWindow.scrollTo(0, 9999999999);
// });
// Get the elements we need to update
const statusTable = document.getElementById('status-table');
const augerStatus = statusTable.rows[0].cells[1];
const augerOn = statusTable.rows[0].cells[3];
const augerOff = statusTable.rows[1].cells[3];
const feedRate = statusTable.rows[1].cells[1];
const feedRateSelect = document.getElementById('feed-rate-select');
// console.log(config);
augerStatus.innerHTML = parseStatus(config.status.auger);
augerOn.innerHTML = config.intervals.augerOn;
augerOff.innerHTML = config.intervals.augerOff;
switch (config.intervals.augerOn) {
case '600':
feedRate.innerHTML = 'Low';
feedRateSelect.selectedIndex = 0;
break;
case '800':
feedRate.innerHTML = 'Medium';
feedRateSelect.selectedIndex = 1;
break;
case '1000':
feedRate.innerHTML = 'High';
feedRateSelect.selectedIndex = 2;
break;
default:
feedRate.innerHTML = 'Unknown';
break;
}
feedRate.value = config.intervals.augerOn;
};
</script>
</body>
</html>

View File

@ -1,65 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Hestia Web Portal</title>
<link rel="stylesheet" href="/main.css">
</head>
<body onload="refreshData()">
<%- include('trial.html') -%>
<div id="title"><a href='./'>Hestia Web Portal</a></div>
<div id="status">
<!--
| Auger | rows[0].cells[1] | On Time | rows[0].cells[3] |
| Feed Rate | rows[1].cells[1] | Off Time | rows[1].cells[3] |
-->
<table id="status-table">
<tr>
<td>Auger</td>
<td></td>
<td>On Time</td>
<td></td>
</tr>
<tr>
<td>Feed Rate</td>
<td></td>
<td>Off Time</td>
<td></td>
</tr>
</table>
</div>
<div class="controls-container">
<form action="/" method="post">
<!-- Start | Shutdown | Reload Settings -->
<div class="button-container">
<input class="button-unselected" type="submit" id="ignite" value="Enable Auger" name="start"><input class="button-unselected" type="submit" id="shutdown" value="Disable Auger" name="shutdown"><br>
</div>
<!-- Set feed rates -->
<label for="feedRate">Feed Rate: </label>
<select name="feedRate">
<option value="600">Low</option>
<option value="800">Medium</option>
<option value="1000">High</option>
</select>
<div class="button-container">
<input class="button-unselected" type="submit" id="reload" value="Reload" name="reload">
</div>
</form>
</div>
<div class="button-container">
<img src="./dancing_jesus.gif">
</div>
<div id="log-container">
<iframe id="log-area" src="log.txt"></iframe>
</div>
<div class="controls-container">
<form action="/" method="POST">
<input class="button-unselected" type="submit" id="quit" value="Quit!!" name="quit" style="visibility: hidden;">
</form>
</div>
<script src="./main.js"></script>
</body>
</html>

View File

@ -1,3 +0,0 @@
<body>
<marquee id="trial">YOUR FREE TRIAL HAS ENDED, PLEASE PURCHASE A PELLET STOVE SUBSCRIPTION</marquee>
</body>