mirror of
https://github.com/SpudGunMan/meshing-around.git
synced 2026-03-28 17:32:36 +01:00
Compare commits
2943 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dc6befeab | ||
|
|
219eea5399 | ||
|
|
c987c1286e | ||
|
|
2ebf721bc9 | ||
|
|
bdef9a1f08 | ||
|
|
2da56bc31f | ||
|
|
1e3c3b9ea0 | ||
|
|
d01d7ae668 | ||
|
|
b875eed9fd | ||
|
|
e8cd85700c | ||
|
|
91b02fead4 | ||
|
|
cba6fe3ba2 | ||
|
|
021efc8c63 | ||
|
|
a4b67072cb | ||
|
|
f1e1516919 | ||
|
|
e675134d08 | ||
|
|
655f2bf7e5 | ||
|
|
46cd2a8051 | ||
|
|
fcc4f24ea5 | ||
|
|
7ddf29ca06 | ||
|
|
372bc0c5a7 | ||
|
|
b3bcb62f6c | ||
|
|
6fb33dde10 | ||
|
|
744ca772f2 | ||
|
|
b5e0653839 | ||
|
|
f7462a498e | ||
|
|
30609c822d | ||
|
|
bbfce73aaa | ||
|
|
4f2cd2caef | ||
|
|
294c09754f | ||
|
|
9b69ca69c4 | ||
|
|
290c366cee | ||
|
|
a7f0561f09 | ||
|
|
4496f19605 | ||
|
|
6499a6e619 | ||
|
|
fe1444b025 | ||
|
|
7bfbae503a | ||
|
|
7cfb45d2b1 | ||
|
|
0fb351ef4d | ||
|
|
2f6abade80 | ||
|
|
5247f8d9d3 | ||
|
|
b36059183c | ||
|
|
f737e401a5 | ||
|
|
98b5f4fb7f | ||
|
|
17fa03ff9d | ||
|
|
40aaa7202c | ||
|
|
5088397856 | ||
|
|
db1c31579c | ||
|
|
dcf1b8f3cc | ||
|
|
2a7000a2e6 | ||
|
|
aa0aaed0b5 | ||
|
|
9db4dc8ab9 | ||
|
|
85e8f41dca | ||
|
|
ddb123b759 | ||
|
|
10afde663e | ||
|
|
c931d13e6e | ||
|
|
ba6075b616 | ||
|
|
68c065825b | ||
|
|
213f121807 | ||
|
|
530d78482a | ||
|
|
6c459cd317 | ||
|
|
626f0dddf7 | ||
|
|
bb57301b20 | ||
|
|
d3adf77896 | ||
|
|
157176acf7 | ||
|
|
4fd35dc004 | ||
|
|
955f7350e9 | ||
|
|
09515b9bc0 | ||
|
|
9b8c9d80c8 | ||
|
|
8ee838f5c6 | ||
|
|
757d6d30b8 | ||
|
|
1ee785d388 | ||
|
|
c3284f0a0f | ||
|
|
bdcc479360 | ||
|
|
b1444b24e4 | ||
|
|
aef67da492 | ||
|
|
b8b8145447 | ||
|
|
42a4842a5b | ||
|
|
201591d469 | ||
|
|
4ecdc7b108 | ||
|
|
3f78bf7a67 | ||
|
|
8af21b760c | ||
|
|
ea3ed46e86 | ||
|
|
d78d6acd1e | ||
|
|
e9b483f4e8 | ||
|
|
94660e7993 | ||
|
|
12aeaef250 | ||
|
|
2a6f76ab5b | ||
|
|
05df1e1a3c | ||
|
|
38131b4180 | ||
|
|
397c39b13d | ||
|
|
af7dfe8a51 | ||
|
|
d5d163aab9 | ||
|
|
58cc3e4314 | ||
|
|
3274dfdbc0 | ||
|
|
84a1a163d3 | ||
|
|
289eb70738 | ||
|
|
a6d51e41bf | ||
|
|
a63020bbb7 | ||
|
|
2416e73fbf | ||
|
|
f87f34f8bf | ||
|
|
eaed034d20 | ||
|
|
ec9ac1b1fe | ||
|
|
e84ce13878 | ||
|
|
a5fc8aca82 | ||
|
|
c31947194e | ||
|
|
c79f3cdfbc | ||
|
|
14b876b989 | ||
|
|
2cc5b23753 | ||
|
|
a5b0fda3ac | ||
|
|
9c5c332e01 | ||
|
|
ac5e96e463 | ||
|
|
0ce7deb740 | ||
|
|
a60333318b | ||
|
|
665acaa904 | ||
|
|
0aa8bccd04 | ||
|
|
2e5e8a7589 | ||
|
|
e3e6393bad | ||
|
|
be38588292 | ||
|
|
14fb3f9cb6 | ||
|
|
c40cd86592 | ||
|
|
69df48957e | ||
|
|
e29573ebc0 | ||
|
|
13b9b75f86 | ||
|
|
0bfe908391 | ||
|
|
5baee422c2 | ||
|
|
38ff05fd40 | ||
|
|
e1def5422a | ||
|
|
93031010cb | ||
|
|
21e614ab8e | ||
|
|
a5322867e3 | ||
|
|
2863a64ec8 | ||
|
|
678fde7b2c | ||
|
|
ec0f9f966c | ||
|
|
fd114301f6 | ||
|
|
1778cb6feb | ||
|
|
fc7ca37184 | ||
|
|
fe2110ca2b | ||
|
|
179113e83a | ||
|
|
79348be644 | ||
|
|
35c6232b0c | ||
|
|
2aa7ffb0e8 | ||
|
|
a7060bc516 | ||
|
|
998d979d71 | ||
|
|
cdfb451d67 | ||
|
|
994405955a | ||
|
|
17d92dc78d | ||
|
|
d0e33f943f | ||
|
|
f55c7311fa | ||
|
|
5f4f832af6 | ||
|
|
c3e8f4a93e | ||
|
|
e72b3c191e | ||
|
|
3774b8407b | ||
|
|
0e074a6885 | ||
|
|
c8800d837f | ||
|
|
289ada1fc0 | ||
|
|
e64e60358d | ||
|
|
658fb33b69 | ||
|
|
1568d026f2 | ||
|
|
232bf98efd | ||
|
|
3cce938334 | ||
|
|
e6a17d9258 | ||
|
|
d403e4c8c0 | ||
|
|
ae19c5b83f | ||
|
|
532efda9e8 | ||
|
|
d20eab03e9 | ||
|
|
df43b61a0c | ||
|
|
862347cbec | ||
|
|
9c412b8328 | ||
|
|
c3fcacd64b | ||
|
|
68b5de2950 | ||
|
|
f578ba6084 | ||
|
|
961bb3abba | ||
|
|
1f85fe7842 | ||
|
|
6808ef2e68 | ||
|
|
5efac1d8b6 | ||
|
|
12b2fe789d | ||
|
|
8f48442f60 | ||
|
|
180e9368e9 | ||
|
|
2c7a753cb5 | ||
|
|
c5ef0b4145 | ||
|
|
ffecd2a44f | ||
|
|
d1d5d6ba30 | ||
|
|
2fe1196a90 | ||
|
|
5c72dd7aa5 | ||
|
|
2834ac3d0d | ||
|
|
55c29c36ba | ||
|
|
0cc4bbf3cd | ||
|
|
b795268d99 | ||
|
|
e60593a3d9 | ||
|
|
8b2449eded | ||
|
|
0c7e8b99a9 | ||
|
|
3931848bd9 | ||
|
|
134ec9f7df | ||
|
|
b8937c6abe | ||
|
|
0e4f0ee83a | ||
|
|
04560b0589 | ||
|
|
78c0ab6bb6 | ||
|
|
2d4f81e662 | ||
|
|
c6f9bc4a90 | ||
|
|
409ae34f93 | ||
|
|
ec9fbc9bd1 | ||
|
|
51602a7fbd | ||
|
|
a49106500d | ||
|
|
ded62343fd | ||
|
|
d096433ab7 | ||
|
|
3273e57f0b | ||
|
|
7cc70dd555 | ||
|
|
edb3208e2c | ||
|
|
60a6244c69 | ||
|
|
f06a27957f | ||
|
|
384f5a62f3 | ||
|
|
dcaf9d7fb5 | ||
|
|
99faf72408 | ||
|
|
a5a7e19ddc | ||
|
|
912617dc34 | ||
|
|
ca6d0cce4e | ||
|
|
43051076ba | ||
|
|
83091e6100 | ||
|
|
6b512db552 | ||
|
|
09b684fad8 | ||
|
|
1122d6007e | ||
|
|
f51cace2c3 | ||
|
|
78cefd3704 | ||
|
|
421efd7521 | ||
|
|
e64f6317ab | ||
|
|
18a6c9dfac | ||
|
|
a96d57580a | ||
|
|
1388771cc1 | ||
|
|
2cbfdb0b78 | ||
|
|
38bef50e12 | ||
|
|
24090ce19f | ||
|
|
14ea1e3d97 | ||
|
|
fb7bf1975b | ||
|
|
1e7887d480 | ||
|
|
398a4c6c63 | ||
|
|
9ab6b3be89 | ||
|
|
255be455b7 | ||
|
|
95a35520c2 | ||
|
|
22ec62a2f2 | ||
|
|
3f95f1d533 | ||
|
|
4e04ebee76 | ||
|
|
91fb93ca8d | ||
|
|
f690f16771 | ||
|
|
2805240abc | ||
|
|
7a5b7e64d7 | ||
|
|
0fd881aa4b | ||
|
|
932112abb2 | ||
|
|
f3d1fd0ec5 | ||
|
|
e92b1a2876 | ||
|
|
11d3c1eaf4 | ||
|
|
0361153592 | ||
|
|
0c6fcf10ef | ||
|
|
647ae92649 | ||
|
|
254eef4be9 | ||
|
|
bd0a94e2a1 | ||
|
|
2d8256d9f7 | ||
|
|
1f9b81865e | ||
|
|
17221cf37f | ||
|
|
47dd75bfb3 | ||
|
|
d4773705ce | ||
|
|
4f46e659d9 | ||
|
|
404f84f39c | ||
|
|
c07ec534a7 | ||
|
|
4d88aed0d8 | ||
|
|
b1946608f4 | ||
|
|
b92cf48fd0 | ||
|
|
227ffc94e6 | ||
|
|
b9f5a0c7f9 | ||
|
|
d56c1380c3 | ||
|
|
e8a8eefcc2 | ||
|
|
5738e8d306 | ||
|
|
11359e4016 | ||
|
|
7bb31af1d2 | ||
|
|
fd115916f5 | ||
|
|
32b60297c8 | ||
|
|
f15a871967 | ||
|
|
a346354dbc | ||
|
|
3d8007bbf6 | ||
|
|
bb254474d0 | ||
|
|
37e3790ee4 | ||
|
|
0ec380931a | ||
|
|
9cfd1bc670 | ||
|
|
a672c94303 | ||
|
|
92b3574c22 | ||
|
|
27d8e198ae | ||
|
|
11eeaa445a | ||
|
|
57efc8a69b | ||
|
|
7442ce11b4 | ||
|
|
8bb6ba4d8e | ||
|
|
da10af8d93 | ||
|
|
46a33178f6 | ||
|
|
e07c5a923e | ||
|
|
d330f3e0d6 | ||
|
|
eddb2fe08c | ||
|
|
ebe729cf13 | ||
|
|
41a45c6e9c | ||
|
|
4224579f79 | ||
|
|
aa43d4acad | ||
|
|
4406f2b86f | ||
|
|
649c959304 | ||
|
|
3529e40743 | ||
|
|
f5c2dfa5e4 | ||
|
|
1fb144ae1e | ||
|
|
7e66ffc3a0 | ||
|
|
d7371fae98 | ||
|
|
e4c51c97a1 | ||
|
|
70f072d222 | ||
|
|
8bb587cc7a | ||
|
|
313c313412 | ||
|
|
e5e8fbd0b5 | ||
|
|
2ef96f3ae3 | ||
|
|
a58605aba3 | ||
|
|
ffdd3a1ea9 | ||
|
|
185de28139 | ||
|
|
0eea36fba2 | ||
|
|
cb9e62894d | ||
|
|
9443d5fb0a | ||
|
|
1751648b12 | ||
|
|
8823d415c3 | ||
|
|
55a1d951a7 | ||
|
|
c8096107a0 | ||
|
|
5bdf1a9d6c | ||
|
|
85344db27e | ||
|
|
5990a859d9 | ||
|
|
ad6a55b9cd | ||
|
|
6fcd981eae | ||
|
|
9564c92cc8 | ||
|
|
149dc10df6 | ||
|
|
e211efca4e | ||
|
|
a974de790b | ||
|
|
777c423f17 | ||
|
|
dbcb93eabb | ||
|
|
69518ea317 | ||
|
|
11faea2b4e | ||
|
|
acb0e870d6 | ||
|
|
17cce3b98b | ||
|
|
ed768b48fe | ||
|
|
cb8dc50424 | ||
|
|
17cde0ca36 | ||
|
|
206b72ec4f | ||
|
|
eadc843e27 | ||
|
|
14709e2828 | ||
|
|
4a5d877a3d | ||
|
|
0159c90708 | ||
|
|
05648f23f2 | ||
|
|
f27fbdf3c9 | ||
|
|
998c4078bc | ||
|
|
666ae24d2c | ||
|
|
41e7c1207a | ||
|
|
41c6de4183 | ||
|
|
af83ba636f | ||
|
|
8b54c52e7f | ||
|
|
240dd4b46f | ||
|
|
7505c9ec22 | ||
|
|
14c22c8156 | ||
|
|
88dcce2b23 | ||
|
|
5bc842c7e8 | ||
|
|
f73bef5894 | ||
|
|
9371e96feb | ||
|
|
85345ca45f | ||
|
|
823554f689 | ||
|
|
5426202d51 | ||
|
|
685e0762bc | ||
|
|
8bc81cee00 | ||
|
|
82f55c6a32 | ||
|
|
be885aa00c | ||
|
|
536fd4deea | ||
|
|
eb25e55c97 | ||
|
|
b7f25c7c5c | ||
|
|
c1f1bc5eb9 | ||
|
|
a9c00e92c7 | ||
|
|
713e3102f3 | ||
|
|
25136d1dd6 | ||
|
|
3795ae17ea | ||
|
|
aef62bfbc3 | ||
|
|
cbb4bf0a3c | ||
|
|
22ebc2bdbe | ||
|
|
517c6cbf82 | ||
|
|
2b0d7267b5 | ||
|
|
ee4f910d6e | ||
|
|
49c88306a0 | ||
|
|
0f918ebccd | ||
|
|
69fac4ba98 | ||
|
|
80745bec50 | ||
|
|
5afb1df41a | ||
|
|
fbb7971cb0 | ||
|
|
23c2d701df | ||
|
|
2f1c305b06 | ||
|
|
978fa19b56 | ||
|
|
b5de21a073 | ||
|
|
f225c21c7a | ||
|
|
23ebb715c9 | ||
|
|
af0645f761 | ||
|
|
113750869f | ||
|
|
c2a18e9f9e | ||
|
|
fcaab86e71 | ||
|
|
47c84d91f1 | ||
|
|
8372817733 | ||
|
|
9683d8b79e | ||
|
|
6f16fc6afb | ||
|
|
fd971d8cc5 | ||
|
|
96193a22e8 | ||
|
|
02b0cde1c8 | ||
|
|
40f4de02d9 | ||
|
|
0b1d626f09 | ||
|
|
964883cae9 | ||
|
|
6ab1102d07 | ||
|
|
c8d8880806 | ||
|
|
21c2f7df18 | ||
|
|
cb51cf921b | ||
|
|
908e84e155 | ||
|
|
b9eaf7deb0 | ||
|
|
128ac456eb | ||
|
|
1269214264 | ||
|
|
4daf087fa5 | ||
|
|
9282c63206 | ||
|
|
710342447f | ||
|
|
8e2c3a43fb | ||
|
|
8d82823ccc | ||
|
|
27789d7508 | ||
|
|
680ba98a1c | ||
|
|
4d71a64971 | ||
|
|
d608754b5e | ||
|
|
70ab741746 | ||
|
|
b0cf5914bf | ||
|
|
434fbc3eef | ||
|
|
1186801d7e | ||
|
|
902d764ca0 | ||
|
|
00fd29e679 | ||
|
|
163920b399 | ||
|
|
850ee2d291 | ||
|
|
cefbe93178 | ||
|
|
44b2837ba0 | ||
|
|
1aa6a7a41a | ||
|
|
7abd1fd704 | ||
|
|
d35832caa8 | ||
|
|
b4b0f2c561 | ||
|
|
1e8ff95769 | ||
|
|
41093be614 | ||
|
|
6fe874e192 | ||
|
|
3fa5d96073 | ||
|
|
242c1c8741 | ||
|
|
c679cee66c | ||
|
|
ca896c0f35 | ||
|
|
e3cd727cc3 | ||
|
|
ded8470677 | ||
|
|
f0b63b8b20 | ||
|
|
8a9c7a1147 | ||
|
|
2d0e6b54b3 | ||
|
|
0745847d3a | ||
|
|
6c49c5c87f | ||
|
|
719fa95c1c | ||
|
|
b642961d26 | ||
|
|
f59d97f6ad | ||
|
|
92d5f01ce5 | ||
|
|
39e53eb599 | ||
|
|
9f6165503e | ||
|
|
ec27ab65da | ||
|
|
f34eefb75a | ||
|
|
ce2ccb1455 | ||
|
|
da144a2b89 | ||
|
|
bbdccb382a | ||
|
|
95f75b8e0a | ||
|
|
0bf4915cd5 | ||
|
|
f83793acc9 | ||
|
|
abb2fa6b61 | ||
|
|
6d90d6f207 | ||
|
|
9c9e9a02e6 | ||
|
|
80fc795f35 | ||
|
|
166c49854f | ||
|
|
a685fc3a9b | ||
|
|
91da1a4c58 | ||
|
|
9889fd0da8 | ||
|
|
bebd9352ea | ||
|
|
fd1cd2a44c | ||
|
|
ac55a51c87 | ||
|
|
86144cd888 | ||
|
|
d7a37ce9f1 | ||
|
|
da7035dfed | ||
|
|
da500981a2 | ||
|
|
b4dc2207a6 | ||
|
|
b69a187466 | ||
|
|
66d143d68e | ||
|
|
49f2dcff88 | ||
|
|
2c3c3fed10 | ||
|
|
2872fb040e | ||
|
|
6097ff899c | ||
|
|
aec75d598a | ||
|
|
0640fdbbae | ||
|
|
8312f4e683 | ||
|
|
990ea4f4e4 | ||
|
|
9acf9df3bb | ||
|
|
37942e950e | ||
|
|
8a0e1cba7c | ||
|
|
8d0a53ec3e | ||
|
|
2ea3917eba | ||
|
|
75410c98e3 | ||
|
|
10171a712e | ||
|
|
fa76a76203 | ||
|
|
e0e275a49c | ||
|
|
bf39c2f088 | ||
|
|
34d36057c1 | ||
|
|
4e1d1de883 | ||
|
|
97f103dfd7 | ||
|
|
47089871b1 | ||
|
|
cc7ef129f6 | ||
|
|
0fa5d06a3a | ||
|
|
7fc44ec06e | ||
|
|
184760096e | ||
|
|
8868d10388 | ||
|
|
1ce2ecd75c | ||
|
|
69e1c21488 | ||
|
|
97a2ffce7b | ||
|
|
4c0d3a597e | ||
|
|
094f7e61a0 | ||
|
|
a54ecaa5a1 | ||
|
|
bd12392d69 | ||
|
|
882bcf3f4b | ||
|
|
c0d0ca3743 | ||
|
|
d74d848646 | ||
|
|
2afb915b56 | ||
|
|
d5e48bead1 | ||
|
|
3c80848f61 | ||
|
|
64345fe47a | ||
|
|
32f734d69b | ||
|
|
aa6de00c5b | ||
|
|
6df4ba5756 | ||
|
|
a11a2780db | ||
|
|
980414f872 | ||
|
|
f26334d625 | ||
|
|
24546b28d6 | ||
|
|
f33da848cd | ||
|
|
57ce15de4e | ||
|
|
b8886e0662 | ||
|
|
9a1e86f25e | ||
|
|
fa8021ab5a | ||
|
|
f3917f1c3d | ||
|
|
c1443048fd | ||
|
|
da430557f3 | ||
|
|
84152bda65 | ||
|
|
b6e80ae576 | ||
|
|
18ac26864c | ||
|
|
b661fbc750 | ||
|
|
3049d18663 | ||
|
|
126f81fbd3 | ||
|
|
337d43a7af | ||
|
|
8c3121d5d6 | ||
|
|
1d577c9ec5 | ||
|
|
3540b8f110 | ||
|
|
3fdebf3bf9 | ||
|
|
430279809e | ||
|
|
7ba3a78718 | ||
|
|
c329391450 | ||
|
|
6bc3c3e980 | ||
|
|
48788ceda8 | ||
|
|
8f5bae3b05 | ||
|
|
17c693c2f7 | ||
|
|
ff91356c2a | ||
|
|
180d9f4728 | ||
|
|
1202a076d1 | ||
|
|
9b62d7f4d8 | ||
|
|
9451d23c09 | ||
|
|
dcdef40e89 | ||
|
|
817dde42f2 | ||
|
|
b384d2d5b1 | ||
|
|
4db46f16f2 | ||
|
|
5590391f7e | ||
|
|
ccb505f37f | ||
|
|
d883927572 | ||
|
|
b0109be3b0 | ||
|
|
98af757d93 | ||
|
|
f8746ff348 | ||
|
|
32fbfba3e9 | ||
|
|
d2501bf353 | ||
|
|
db9d7d9790 | ||
|
|
c6b5a1c708 | ||
|
|
a36f1580b3 | ||
|
|
f051e95986 | ||
|
|
bafcfad190 | ||
|
|
8b2059c444 | ||
|
|
fd4b5607d7 | ||
|
|
df30ee9cc4 | ||
|
|
c1135ecadf | ||
|
|
899702eecc | ||
|
|
d4604d8cbd | ||
|
|
c674b0a404 | ||
|
|
d59ddfd517 | ||
|
|
f68c533488 | ||
|
|
6e47d71028 | ||
|
|
f9af9b756d | ||
|
|
c19d442190 | ||
|
|
4c2d0cdebb | ||
|
|
612dbf01d3 | ||
|
|
28846b24a6 | ||
|
|
cd398375a2 | ||
|
|
01372a0f2c | ||
|
|
8254ec5baf | ||
|
|
549e12ffc1 | ||
|
|
d940cdf534 | ||
|
|
54837884a7 | ||
|
|
91501d42db | ||
|
|
e0bcc31204 | ||
|
|
1cb56aa1b7 | ||
|
|
8cf2db3b49 | ||
|
|
755fc4fac3 | ||
|
|
7c341ed0e7 | ||
|
|
c87db75f2f | ||
|
|
13875b7cf8 | ||
|
|
fd4925ee92 | ||
|
|
eccc48ff3f | ||
|
|
3a6d464398 | ||
|
|
6c7e8558b0 | ||
|
|
74a744c77e | ||
|
|
5225998c92 | ||
|
|
97a0ff3112 | ||
|
|
1250479219 | ||
|
|
f8bcc4f495 | ||
|
|
5d1608f366 | ||
|
|
a19dc93350 | ||
|
|
30d4e487c9 | ||
|
|
5bf1ade2b0 | ||
|
|
13cefc2002 | ||
|
|
640bead32c | ||
|
|
49d9b58627 | ||
|
|
ad1a8aa1ce | ||
|
|
55567815ef | ||
|
|
346fb38bbd | ||
|
|
104e70c01c | ||
|
|
2111bb46ae | ||
|
|
459dad4c32 | ||
|
|
d9febeef0f | ||
|
|
f8a94fca71 | ||
|
|
a30b3dc2d2 | ||
|
|
b45254795d | ||
|
|
4a209e0c17 | ||
|
|
1aecb42186 | ||
|
|
6513e9f177 | ||
|
|
dd14034f3c | ||
|
|
8308c2f98c | ||
|
|
5050f1c5eb | ||
|
|
553137d228 | ||
|
|
d3e7a4d5e4 | ||
|
|
2e26819c2f | ||
|
|
f03b85cebe | ||
|
|
99f25345e8 | ||
|
|
4952bb3ecc | ||
|
|
f79f714317 | ||
|
|
a6db9bc878 | ||
|
|
774f76ecf1 | ||
|
|
3038d72996 | ||
|
|
33245e8443 | ||
|
|
54fb30d048 | ||
|
|
86c86d2f97 | ||
|
|
bb46981a85 | ||
|
|
e32cdf803c | ||
|
|
c6e8feefd7 | ||
|
|
1f48e9a4aa | ||
|
|
571c9c521f | ||
|
|
52b88ce16b | ||
|
|
6f38bff473 | ||
|
|
2b964390f8 | ||
|
|
a305acc492 | ||
|
|
cf66556fe6 | ||
|
|
76e9bd8677 | ||
|
|
c8c3c0f80b | ||
|
|
3a9330d831 | ||
|
|
8d3b0ce4bf | ||
|
|
bb0a22c69b | ||
|
|
df369c3d29 | ||
|
|
db1ca48f0a | ||
|
|
a38d0a6ed7 | ||
|
|
ac8d308f58 | ||
|
|
08b54d9009 | ||
|
|
9da6416433 | ||
|
|
efcfb749dc | ||
|
|
493d2792d6 | ||
|
|
aa68ce120e | ||
|
|
e3a9a00c92 | ||
|
|
f1feb1be0d | ||
|
|
290695327d | ||
|
|
c3ea07fde5 | ||
|
|
e551f1252a | ||
|
|
57093d09ef | ||
|
|
a8b2aefa28 | ||
|
|
849565cacb | ||
|
|
fdec3a6754 | ||
|
|
f3a97bc567 | ||
|
|
02625ad0f2 | ||
|
|
b4ba4b0daf | ||
|
|
fd7f8a94f5 | ||
|
|
d252250edd | ||
|
|
d6410e0461 | ||
|
|
050b4ab3ce | ||
|
|
8ac1a1eed7 | ||
|
|
370a417ce6 | ||
|
|
378b05df35 | ||
|
|
d002c5ede8 | ||
|
|
cd03cc56b4 | ||
|
|
d4fd484706 | ||
|
|
82d519279e | ||
|
|
09302e8c91 | ||
|
|
91fc4605ec | ||
|
|
abc6c07ee3 | ||
|
|
bbf8b04bd3 | ||
|
|
5fd293c990 | ||
|
|
a9a65a6c6d | ||
|
|
34e95c86d6 | ||
|
|
80891090c3 | ||
|
|
1b098fbf7b | ||
|
|
165d76cf8d | ||
|
|
045c9d433b | ||
|
|
fbe5e008de | ||
|
|
004adc7d9a | ||
|
|
9a2033452f | ||
|
|
5638204f82 | ||
|
|
e5c3b0cceb | ||
|
|
18ac53b230 | ||
|
|
4aa65dad6a | ||
|
|
f65a7b7934 | ||
|
|
886293087a | ||
|
|
e05e6f3451 | ||
|
|
4b5dd934e9 | ||
|
|
008ddfb5a2 | ||
|
|
e1330b9b9e | ||
|
|
7b43213094 | ||
|
|
b17c2b17ee | ||
|
|
b6505ee577 | ||
|
|
f7379b7ca5 | ||
|
|
ec9a1d88db | ||
|
|
a339570afe | ||
|
|
f5af9f419a | ||
|
|
ad5c1c90da | ||
|
|
eb1e0c82ea | ||
|
|
8d2277bc59 | ||
|
|
dc9908a72c | ||
|
|
21123d2993 | ||
|
|
7dc3134d0b | ||
|
|
b125178492 | ||
|
|
2ad9e84c33 | ||
|
|
63bd288caa | ||
|
|
5c7d199831 | ||
|
|
f56a39eeb6 | ||
|
|
ae5991ee39 | ||
|
|
1324f83f17 | ||
|
|
08ae8c31a0 | ||
|
|
957e803951 | ||
|
|
2de3441d67 | ||
|
|
2b420022f9 | ||
|
|
d01f143adf | ||
|
|
5f5aeeadac | ||
|
|
d57826613c | ||
|
|
af09dc0cf9 | ||
|
|
011bac41f2 | ||
|
|
20467ea886 | ||
|
|
bbfd71f011 | ||
|
|
e1ff87a197 | ||
|
|
a859f830bb | ||
|
|
d99698e7f3 | ||
|
|
5ecc563e96 | ||
|
|
eeeb43cacc | ||
|
|
9fdcea56fc | ||
|
|
24a33fe882 | ||
|
|
5710cebf39 | ||
|
|
b66487863d | ||
|
|
b3c4d208b7 | ||
|
|
f41ff2d5f7 | ||
|
|
48366bc595 | ||
|
|
02dd64382d | ||
|
|
731b48ad65 | ||
|
|
69a7082669 | ||
|
|
fafa7d8a51 | ||
|
|
6e69b5f014 | ||
|
|
03895248cd | ||
|
|
a79de8a325 | ||
|
|
740b53f02f | ||
|
|
76e75551c6 | ||
|
|
51752ae896 | ||
|
|
d81e773c0c | ||
|
|
1f1ed1ca70 | ||
|
|
df9f3806a3 | ||
|
|
081ccd9e2e | ||
|
|
d9a7dafe6e | ||
|
|
921225965b | ||
|
|
3659254785 | ||
|
|
7c502608f6 | ||
|
|
427c25f80b | ||
|
|
c3f15390ea | ||
|
|
e1476a44c6 | ||
|
|
72070fef3e | ||
|
|
b63ea677f6 | ||
|
|
f8389500b8 | ||
|
|
b257625a45 | ||
|
|
a233d8c7b3 | ||
|
|
11c9742ebe | ||
|
|
5af28c3dc2 | ||
|
|
aebb9e3c20 | ||
|
|
d5916f4ccc | ||
|
|
056159a3f3 | ||
|
|
2f6049d94b | ||
|
|
a2d7f664ab | ||
|
|
b26491b646 | ||
|
|
22e97b0eec | ||
|
|
f540866d08 | ||
|
|
c9729c8214 | ||
|
|
49901cbbee | ||
|
|
2aa2b80935 | ||
|
|
95695f4f58 | ||
|
|
b641d2b5e8 | ||
|
|
51d8faab12 | ||
|
|
7a1396b99d | ||
|
|
819bbbcaf4 | ||
|
|
0eeda96670 | ||
|
|
18cca4ffdd | ||
|
|
d169fe2dff | ||
|
|
1c732dfe17 | ||
|
|
bdad3927e5 | ||
|
|
0e0d6416d9 | ||
|
|
0da780371a | ||
|
|
37bf30cbc0 | ||
|
|
817a8601dd | ||
|
|
47cca409be | ||
|
|
e08a82ec39 | ||
|
|
345541dfb5 | ||
|
|
6e89762f1d | ||
|
|
0fb26bc16a | ||
|
|
f1ad5966af | ||
|
|
ac57d4683f | ||
|
|
eab099e5ee | ||
|
|
685bd3491d | ||
|
|
b8d64f3a9e | ||
|
|
852d491030 | ||
|
|
76565c5546 | ||
|
|
af1ec1630e | ||
|
|
0c2b36a206 | ||
|
|
c0934096f0 | ||
|
|
819bfaba90 | ||
|
|
8041a1296b | ||
|
|
10d93b4fd3 | ||
|
|
19dedef1e6 | ||
|
|
d4af0c7e8b | ||
|
|
8730f0fd38 | ||
|
|
9cda8daf65 | ||
|
|
a9223f1613 | ||
|
|
04ca4c99b8 | ||
|
|
3072520e63 | ||
|
|
bd6603766b | ||
|
|
075a23bd2b | ||
|
|
a8e4f653ed | ||
|
|
374a44f4a9 | ||
|
|
3c8d2e646e | ||
|
|
e5df983244 | ||
|
|
fa5f9250c4 | ||
|
|
3f7a831690 | ||
|
|
89aaaddae9 | ||
|
|
e1919616c2 | ||
|
|
8b9e637006 | ||
|
|
0df3e32901 | ||
|
|
1c2fa174ea | ||
|
|
c97aefcef1 | ||
|
|
dfb94c3993 | ||
|
|
7d62f69f12 | ||
|
|
cf896767fb | ||
|
|
1eb4cf71ed | ||
|
|
e959124eac | ||
|
|
d787c72812 | ||
|
|
9f0dd56d43 | ||
|
|
aa71e6045a | ||
|
|
a140ad83cd | ||
|
|
93c2d731e8 | ||
|
|
d8da553af9 | ||
|
|
9d9f070908 | ||
|
|
0f2061af55 | ||
|
|
d8423584d4 | ||
|
|
843320d268 | ||
|
|
216128b15a | ||
|
|
f8bc574753 | ||
|
|
6193c5933f | ||
|
|
b668965bda | ||
|
|
ae039b5baf | ||
|
|
824d43f16e | ||
|
|
2de76e6c5e | ||
|
|
afb02602fd | ||
|
|
99528c2bcf | ||
|
|
b53f5821f3 | ||
|
|
93fc6547b8 | ||
|
|
9a7e321dff | ||
|
|
39257f2d39 | ||
|
|
8c5abecac3 | ||
|
|
16dcc96037 | ||
|
|
b1d32a7745 | ||
|
|
631a2f53ea | ||
|
|
32903c97e3 | ||
|
|
6e61e8122d | ||
|
|
d109803f9d | ||
|
|
09ed4f57cf | ||
|
|
acfb8078a9 | ||
|
|
84f9693833 | ||
|
|
50fdcf486d | ||
|
|
eab5afccc8 | ||
|
|
ea9db47c2d | ||
|
|
cf3a9c5b43 | ||
|
|
adedaa092c | ||
|
|
f204237a63 | ||
|
|
057a400041 | ||
|
|
4cdf68f074 | ||
|
|
003a11c557 | ||
|
|
8d309fa579 | ||
|
|
232f9c24db | ||
|
|
39dccd149b | ||
|
|
b921c73fa7 | ||
|
|
f3ec1cbe93 | ||
|
|
a6bcfda0ac | ||
|
|
51cd2002af | ||
|
|
b40f41f41c | ||
|
|
4c33b30f14 | ||
|
|
b7490afb99 | ||
|
|
8b57ed727c | ||
|
|
fd5d64b9fb | ||
|
|
00af152c2c | ||
|
|
31f0abc8c8 | ||
|
|
6b7d795a31 | ||
|
|
1f093c4bc2 | ||
|
|
fe1c4a1ad0 | ||
|
|
11687cb7ba | ||
|
|
b07a7fb0cc | ||
|
|
b876d87ba9 | ||
|
|
0a63e89633 | ||
|
|
848f5609c2 | ||
|
|
0ccbed6165 | ||
|
|
646517db71 | ||
|
|
7d347bb80a | ||
|
|
e199d4f5eb | ||
|
|
a9767b58c4 | ||
|
|
69dfde047e | ||
|
|
da33b6f1b9 | ||
|
|
8a7125358b | ||
|
|
ae558052f7 | ||
|
|
5074d71eb7 | ||
|
|
632f42477a | ||
|
|
b3df38d15e | ||
|
|
b76b8ca718 | ||
|
|
d66a9e745b | ||
|
|
717bbccea3 | ||
|
|
50fd1c0410 | ||
|
|
ae89788ea4 | ||
|
|
4220b095ee | ||
|
|
ef28341cdb | ||
|
|
b5d610728c | ||
|
|
bc238ef476 | ||
|
|
feb3544014 | ||
|
|
31322dc0cd | ||
|
|
8c1cbaf442 | ||
|
|
8d6a95b5da | ||
|
|
b4b2ef3d80 | ||
|
|
13b1b90864 | ||
|
|
838bd3edce | ||
|
|
70bcf43b49 | ||
|
|
0a02ae860e | ||
|
|
a80575a381 | ||
|
|
e57b65b447 | ||
|
|
1d18f0936c | ||
|
|
b096716b96 | ||
|
|
f4734c5b87 | ||
|
|
96447b166f | ||
|
|
2fc151bbbf | ||
|
|
e66af5c068 | ||
|
|
d3ce4d3905 | ||
|
|
a95cdeb086 | ||
|
|
27bf61a913 | ||
|
|
d62990b6db | ||
|
|
0784aaebd9 | ||
|
|
e348854a50 | ||
|
|
a71e5fa8f3 | ||
|
|
9600ea5e00 | ||
|
|
cc7461929e | ||
|
|
0c8fb0c243 | ||
|
|
311563320e | ||
|
|
78b6d660dd | ||
|
|
77da966b9d | ||
|
|
d844c123be | ||
|
|
d4d36c8a31 | ||
|
|
9acd57a420 | ||
|
|
bc06712b87 | ||
|
|
260e52fe81 | ||
|
|
6b548f82b2 | ||
|
|
2273b481ad | ||
|
|
95ee7779b4 | ||
|
|
ee1391f6e7 | ||
|
|
b7a0d7cd8e | ||
|
|
a880236117 | ||
|
|
a67bdc3641 | ||
|
|
da8235adae | ||
|
|
22384463e2 | ||
|
|
b48377de5f | ||
|
|
855c2e08cc | ||
|
|
d0aa07ed7d | ||
|
|
7328a92535 | ||
|
|
35c8dc6f70 | ||
|
|
4b1123dcac | ||
|
|
b74dc1ff25 | ||
|
|
8c752dff3e | ||
|
|
b3b45a4335 | ||
|
|
fd86187798 | ||
|
|
4da3e68c62 | ||
|
|
e47907ebeb | ||
|
|
f63278ae8f | ||
|
|
0e0d2f11d7 | ||
|
|
6587ba61e2 | ||
|
|
b46697c0c4 | ||
|
|
f5f8539924 | ||
|
|
e8063fcf3f | ||
|
|
169f9b27a5 | ||
|
|
4ceb23bcff | ||
|
|
315ae84bb6 | ||
|
|
fb12c11a7e | ||
|
|
496c222cdc | ||
|
|
cb55aba498 | ||
|
|
1aac3d5ac2 | ||
|
|
413f2a24d9 | ||
|
|
c75782d559 | ||
|
|
d38314a21c | ||
|
|
65dfe90edc | ||
|
|
3ce24fb7c9 | ||
|
|
8765e5a871 | ||
|
|
b4f0421423 | ||
|
|
2b8906ae55 | ||
|
|
5710b47a99 | ||
|
|
1f8bf5a700 | ||
|
|
171480b704 | ||
|
|
c74e4f99b2 | ||
|
|
cd5749521c | ||
|
|
5ce7019dba | ||
|
|
6c1e0cc2f9 | ||
|
|
68b171f68e | ||
|
|
7cfd5d0b0e | ||
|
|
6dd4f0c4b6 | ||
|
|
8ef0fa2ac0 | ||
|
|
0c8d6b8fac | ||
|
|
1e4e5e6627 | ||
|
|
c97004b410 | ||
|
|
2292fb2655 | ||
|
|
3ebf3ba374 | ||
|
|
b6087c926c | ||
|
|
2895e6c034 | ||
|
|
691bc8d701 | ||
|
|
bd50524e95 | ||
|
|
299b749f0e | ||
|
|
9a060e3c6e | ||
|
|
a012ef17d0 | ||
|
|
adbf78b740 | ||
|
|
3aad8d89cf | ||
|
|
3370304249 | ||
|
|
ef62a06db1 | ||
|
|
8cc1d24b93 | ||
|
|
fca90cbee3 | ||
|
|
d05c7bb6a5 | ||
|
|
7774529fb4 | ||
|
|
4c615af22d | ||
|
|
6c078b4d17 | ||
|
|
ddb9c8b4bf | ||
|
|
73f3175705 | ||
|
|
d2ee1bce1c | ||
|
|
b4a2149815 | ||
|
|
320f41e05a | ||
|
|
48a57e875f | ||
|
|
ce317d8bbe | ||
|
|
c2d2a8f7e4 | ||
|
|
00280e351c | ||
|
|
0e8bb197a9 | ||
|
|
d825c0fa15 | ||
|
|
6abe73c1bc | ||
|
|
b8e9adb223 | ||
|
|
e621016e9a | ||
|
|
cfaf652852 | ||
|
|
6c27b5d5de | ||
|
|
a31fa90942 | ||
|
|
3cd347dff3 | ||
|
|
ea4ac1f9c1 | ||
|
|
a9da8336cc | ||
|
|
4ba60ed276 | ||
|
|
42e07d44e6 | ||
|
|
11f5218c2e | ||
|
|
e137420138 | ||
|
|
80c0f698b6 | ||
|
|
2045bf98f7 | ||
|
|
c36ce2c3a6 | ||
|
|
7ff36a3d5f | ||
|
|
ae1a3040b5 | ||
|
|
84b6b48d60 | ||
|
|
9f3446b605 | ||
|
|
10add3147d | ||
|
|
4e074a309f | ||
|
|
f394f58b9f | ||
|
|
30bcee498d | ||
|
|
5c54ce0b70 | ||
|
|
b4684f49ff | ||
|
|
c6c1e9f637 | ||
|
|
54540b1656 | ||
|
|
006c9f58c6 | ||
|
|
3d582e9b77 | ||
|
|
0578c0b233 | ||
|
|
98ccf8708f | ||
|
|
47280f4330 | ||
|
|
4b0b074ba7 | ||
|
|
d8d79f46b5 | ||
|
|
1a49a81cf5 | ||
|
|
5c0b04f0b7 | ||
|
|
8f1cb6265d | ||
|
|
c51a4584ae | ||
|
|
13ee6d4fd6 | ||
|
|
2d44faac98 | ||
|
|
0a2daeac1f | ||
|
|
da7ba256d8 | ||
|
|
42e99a0dc1 | ||
|
|
ca5896a015 | ||
|
|
ebe2636104 | ||
|
|
971a421d01 | ||
|
|
9d080de8f3 | ||
|
|
5209092928 | ||
|
|
6439f49fb1 | ||
|
|
c115cdf82f | ||
|
|
63fccbdf3e | ||
|
|
c23564d8b5 | ||
|
|
0b9db28951 | ||
|
|
1aa4eddb3b | ||
|
|
a7de64b385 | ||
|
|
2e8206d4ec | ||
|
|
b47c13503b | ||
|
|
a66dbd13fd | ||
|
|
02322cdf91 | ||
|
|
955d3681e9 | ||
|
|
9d96c02870 | ||
|
|
c87dba1e06 | ||
|
|
1c3d2f7f18 | ||
|
|
b53a7d3832 | ||
|
|
99e74ae8c0 | ||
|
|
1bdfc3828f | ||
|
|
26f39e76e6 | ||
|
|
c49dcfbfc8 | ||
|
|
1008ec6afa | ||
|
|
8c0a1bbd0d | ||
|
|
30d8f00aeb | ||
|
|
033b1bcd51 | ||
|
|
d80b2da06a | ||
|
|
dc02464662 | ||
|
|
b738881ff1 | ||
|
|
b8f0601684 | ||
|
|
910c045b08 | ||
|
|
35ba139577 | ||
|
|
eddd990cc5 | ||
|
|
6e3d83401f | ||
|
|
f9ab6a79d3 | ||
|
|
4b2402c286 | ||
|
|
47e0276f0c | ||
|
|
6dc54abf43 | ||
|
|
53ff37c782 | ||
|
|
5c48e008ee | ||
|
|
ddac18bb13 | ||
|
|
507919bb4c | ||
|
|
b4f3f9887d | ||
|
|
aa051abbd4 | ||
|
|
8ba0c6f14c | ||
|
|
166b15463a | ||
|
|
d02924bfda | ||
|
|
0a123251f4 | ||
|
|
a80f926d08 | ||
|
|
b8318f8f3e | ||
|
|
adb6fa3b5a | ||
|
|
a9254c9c79 | ||
|
|
0ac642ac44 | ||
|
|
ec24a8b8dd | ||
|
|
f79026a95f | ||
|
|
99acaf28a1 | ||
|
|
9c068c8d28 | ||
|
|
d9ab1b88c1 | ||
|
|
8499b6c851 | ||
|
|
ea47bf9329 | ||
|
|
229043c32a | ||
|
|
5dbd137f14 | ||
|
|
ca83117180 | ||
|
|
fde37313f5 | ||
|
|
a39f588f9f | ||
|
|
39e348f701 | ||
|
|
4fdfa49b87 | ||
|
|
8a64b8e7ad | ||
|
|
c585f60882 | ||
|
|
4e91801cb9 | ||
|
|
4e1d3e2b58 | ||
|
|
38d5006236 | ||
|
|
642738e3b6 | ||
|
|
5728d6b9e3 | ||
|
|
82bec43f22 | ||
|
|
7ca8b6793a | ||
|
|
d589d3e155 | ||
|
|
452c4aa520 | ||
|
|
e5d2ea4bcb | ||
|
|
2596d133fd | ||
|
|
c3221d64a8 | ||
|
|
dcabfc0f50 | ||
|
|
2eca5f644a | ||
|
|
3f90a7fc39 | ||
|
|
9639c793d9 | ||
|
|
18294f4ca3 | ||
|
|
14043dd950 | ||
|
|
fe1c264b19 | ||
|
|
8848d9b6fe | ||
|
|
daa6e85318 | ||
|
|
f7f127590d | ||
|
|
5b0fd65d31 | ||
|
|
fe12e1f107 | ||
|
|
11025f101f | ||
|
|
92a5fc2ed5 | ||
|
|
3f891d93d2 | ||
|
|
398c9ddb60 | ||
|
|
518eb25f0d | ||
|
|
0613cc7b3d | ||
|
|
be2d1fbf72 | ||
|
|
8595190ed1 | ||
|
|
5703cfb381 | ||
|
|
ff9b76c966 | ||
|
|
e41f692038 | ||
|
|
19935d9f08 | ||
|
|
f0e8b2c057 | ||
|
|
eb2d809fe4 | ||
|
|
b276bbb40a | ||
|
|
35ea7cb505 | ||
|
|
eb78c2e5e8 | ||
|
|
3df16b7626 | ||
|
|
8c01433d14 | ||
|
|
a8dbef7e12 | ||
|
|
23478812e0 | ||
|
|
08c2c668f9 | ||
|
|
3b41e39ff5 | ||
|
|
78fa3209e6 | ||
|
|
c5ba56a656 | ||
|
|
50c3249edc | ||
|
|
80897f7a82 | ||
|
|
d311832a92 | ||
|
|
56af59345d | ||
|
|
c1adca7db0 | ||
|
|
4c7fe55b43 | ||
|
|
df6a1cfb66 | ||
|
|
9994446510 | ||
|
|
9272218815 | ||
|
|
388d862fc9 | ||
|
|
ac33f8a02b | ||
|
|
f04392a81c | ||
|
|
d0097c092b | ||
|
|
92ff166260 | ||
|
|
bfe0d219f9 | ||
|
|
85a2d90cff | ||
|
|
e15232875c | ||
|
|
d1a87f161b | ||
|
|
626ac59b4e | ||
|
|
835a9e5f89 | ||
|
|
3ae928dd66 | ||
|
|
3973406783 | ||
|
|
4fbdd42837 | ||
|
|
04378efdd8 | ||
|
|
0d19a40ed6 | ||
|
|
75ac3c974a | ||
|
|
7e0eb348ae | ||
|
|
af6ea2a512 | ||
|
|
6665ea7dcd | ||
|
|
3212661ee8 | ||
|
|
0675132171 | ||
|
|
fdb7897963 | ||
|
|
8ff7a0bf3c | ||
|
|
c210534543 | ||
|
|
ea7574a868 | ||
|
|
8f69c4d93c | ||
|
|
bc9ada91b4 | ||
|
|
28f06f0a21 | ||
|
|
267fe392e3 | ||
|
|
6c1f7940ca | ||
|
|
29a26d5d14 | ||
|
|
2fc9281394 | ||
|
|
b5bd1008c2 | ||
|
|
ee1db5b7be | ||
|
|
7395b96337 | ||
|
|
f3c6f77b23 | ||
|
|
f6e04a42a0 | ||
|
|
3fcd588d02 | ||
|
|
e1b47484f2 | ||
|
|
14798cb992 | ||
|
|
41c8f0044b | ||
|
|
45eefb24d8 | ||
|
|
410d32947c | ||
|
|
748652ac62 | ||
|
|
d715cb6b4d | ||
|
|
1895a365ae | ||
|
|
cc58a38165 | ||
|
|
a8ccb05d56 | ||
|
|
a90a533a30 | ||
|
|
57a4e5d68c | ||
|
|
7c99b684ad | ||
|
|
b957c89d70 | ||
|
|
9b986dd57a | ||
|
|
9e348332e5 | ||
|
|
0cfe759ef6 | ||
|
|
e95902ef98 | ||
|
|
43e6349351 | ||
|
|
c7df4d88d1 | ||
|
|
6d01c5a986 | ||
|
|
3f882dcfcd | ||
|
|
b146fd6f64 | ||
|
|
8709e5aed5 | ||
|
|
caf8a2708b | ||
|
|
9b4200c198 | ||
|
|
097cae6e94 | ||
|
|
628f66e4b7 | ||
|
|
29f97c62d0 | ||
|
|
b805e6d428 | ||
|
|
6d5ded7df6 | ||
|
|
0a260b28b6 | ||
|
|
3f5c6f2e9a | ||
|
|
8a4f7a904a | ||
|
|
0bc3d392cf | ||
|
|
5eaef8b5b8 | ||
|
|
3a0007771d | ||
|
|
67ba2b1fb5 | ||
|
|
f2e7a9aa5c | ||
|
|
9d22270dde | ||
|
|
409d07436e | ||
|
|
5ab0001f2b | ||
|
|
5e34537af7 | ||
|
|
1764bdf4f3 | ||
|
|
2290f07351 | ||
|
|
ee01051cf7 | ||
|
|
de50a52fa6 | ||
|
|
8eabfaa9c4 | ||
|
|
ca7114b058 | ||
|
|
8b94dc8111 | ||
|
|
5b26aabb00 | ||
|
|
67b3c67348 | ||
|
|
860cceec59 | ||
|
|
53a0535e55 | ||
|
|
621f4ad916 | ||
|
|
118857ec15 | ||
|
|
1be13be92a | ||
|
|
895fc3fd37 | ||
|
|
0e0bda60ad | ||
|
|
903767f4b3 | ||
|
|
f54d362ea0 | ||
|
|
60bb68c6b5 | ||
|
|
feb9a1d9b3 | ||
|
|
d055c35c96 | ||
|
|
27820daaf4 | ||
|
|
56e8e1c0d5 | ||
|
|
4545b8f4a4 | ||
|
|
6ed48d49ce | ||
|
|
a3a54b081d | ||
|
|
ab420af63e | ||
|
|
a55c61c47d | ||
|
|
7236f47eb7 | ||
|
|
05e11ae5f8 | ||
|
|
f8ffcc19b1 | ||
|
|
ea20eec604 | ||
|
|
d1204d2c26 | ||
|
|
654d8b3ff7 | ||
|
|
3bf12d62b5 | ||
|
|
0ec8613d27 | ||
|
|
10dd413ae7 | ||
|
|
09ac7525b3 | ||
|
|
aac497dfa0 | ||
|
|
6f652230b0 | ||
|
|
6f1c44e62a | ||
|
|
837d049acb | ||
|
|
2463407ade | ||
|
|
af2bc7be0c | ||
|
|
38654213e8 | ||
|
|
a06819dbda | ||
|
|
9818cccbbf | ||
|
|
239dbb8be0 | ||
|
|
872a9601d0 | ||
|
|
2b6dc726e1 | ||
|
|
ef27ddff84 | ||
|
|
8a8ad961d5 | ||
|
|
a8b4362d3c | ||
|
|
dc731ae237 | ||
|
|
d0d024d770 | ||
|
|
9b633502e6 | ||
|
|
ac1a007ba4 | ||
|
|
09cf6f585c | ||
|
|
916719f1c5 | ||
|
|
11a6dc3cf0 | ||
|
|
c160678e79 | ||
|
|
0c9fd919ab | ||
|
|
e17dc79896 | ||
|
|
06d6855d92 | ||
|
|
66f937a645 | ||
|
|
f4985b744a | ||
|
|
7ae6174f96 | ||
|
|
d44fdd4462 | ||
|
|
3dd6da4684 | ||
|
|
a229b57964 | ||
|
|
5e045b6447 | ||
|
|
1e328d4f4d | ||
|
|
879d141844 | ||
|
|
7daf8c4c33 | ||
|
|
3e6d1f5c6f | ||
|
|
32deea9e3b | ||
|
|
793fabcdb8 | ||
|
|
a7a710208a | ||
|
|
41efbc6189 | ||
|
|
f399190d3c | ||
|
|
5760c10534 | ||
|
|
9deb4a9436 | ||
|
|
1f348d963d | ||
|
|
b35edf13c8 | ||
|
|
37185b9f8b | ||
|
|
4e25535ede | ||
|
|
4de2a36099 | ||
|
|
6c0d6fd343 | ||
|
|
abd865c918 | ||
|
|
82222addbe | ||
|
|
7750ce468b | ||
|
|
135778d511 | ||
|
|
c54df673c3 | ||
|
|
2fec08060f | ||
|
|
ce9af3c0d3 | ||
|
|
217cd01d0a | ||
|
|
8a6057995b | ||
|
|
47e21dbaab | ||
|
|
267f50c591 | ||
|
|
0013a7bb74 | ||
|
|
73fe8be432 | ||
|
|
3d45195ae9 | ||
|
|
ff390cf470 | ||
|
|
17d8cd1067 | ||
|
|
b9348c906d | ||
|
|
6ba3508cc5 | ||
|
|
1c78f154da | ||
|
|
e0a3d0f94e | ||
|
|
066211e9f2 | ||
|
|
5701cd108b | ||
|
|
b877a294ac | ||
|
|
2aedcfc46e | ||
|
|
12147db5d0 | ||
|
|
cef37b574b | ||
|
|
6f121b7aac | ||
|
|
9e31b7f47e | ||
|
|
f3103984ef | ||
|
|
9c8b3f0a54 | ||
|
|
f88cbf210e | ||
|
|
9909113beb | ||
|
|
c1b783b1cd | ||
|
|
9b3b6a5d3d | ||
|
|
cffdb3c089 | ||
|
|
7bb9c9ac55 | ||
|
|
830ec95080 | ||
|
|
0ea575ac70 | ||
|
|
d836255716 | ||
|
|
4f115c9c21 | ||
|
|
63bd5b836d | ||
|
|
5ad9b9a261 | ||
|
|
7a024b681f | ||
|
|
75df5a695b | ||
|
|
0ef8cffd56 | ||
|
|
73e8e063d2 | ||
|
|
82880677f4 | ||
|
|
fe8ba8aaf4 | ||
|
|
cea9147745 | ||
|
|
c1c68d4c10 | ||
|
|
5fcd21680e | ||
|
|
9e1356172f | ||
|
|
de7fdfad11 | ||
|
|
a87055874a | ||
|
|
5c7433091d | ||
|
|
f0ca818461 | ||
|
|
76006dcda7 | ||
|
|
33abe646ae | ||
|
|
c47004c47c | ||
|
|
e66d945be7 | ||
|
|
10afc128f4 | ||
|
|
e6fc794951 | ||
|
|
4839e9ba03 | ||
|
|
bde15e311a | ||
|
|
21c83222e9 | ||
|
|
bbcdd6656a | ||
|
|
7f61b86252 | ||
|
|
25ae27a162 | ||
|
|
a04133e82f | ||
|
|
2a9dfc90ee | ||
|
|
f1bf84f6f0 | ||
|
|
4b91ef10b4 | ||
|
|
cd4497b129 | ||
|
|
01374a8307 | ||
|
|
46c115b783 | ||
|
|
eec7230a84 | ||
|
|
9394fd6ca9 | ||
|
|
c6653da1f3 | ||
|
|
9f47958a03 | ||
|
|
78e51b7be1 | ||
|
|
26fcf6fc02 | ||
|
|
c2336850fe | ||
|
|
54e0d17e70 | ||
|
|
7a6d1f7b29 | ||
|
|
7e26d3f0e5 | ||
|
|
89be8e13a2 | ||
|
|
aa8482ab52 | ||
|
|
69605e0984 | ||
|
|
8e15a3fc99 | ||
|
|
d671b19bce | ||
|
|
943dd4d5a3 | ||
|
|
05d8671b3f | ||
|
|
4bccd33827 | ||
|
|
71ebe7087f | ||
|
|
8dbffe2e63 | ||
|
|
cbea9b5294 | ||
|
|
acdc94cd06 | ||
|
|
e9deb62047 | ||
|
|
f1ad470f88 | ||
|
|
b19f7be0b0 | ||
|
|
053acd1ac6 | ||
|
|
3d5b671d81 | ||
|
|
f090230c96 | ||
|
|
d9040a4ec7 | ||
|
|
e35c954e5d | ||
|
|
93ed84fdee | ||
|
|
9f074e5250 | ||
|
|
12d94fb0dc | ||
|
|
afa2bc4024 | ||
|
|
8dcbf66618 | ||
|
|
902b4f22ee | ||
|
|
7ae0d5e927 | ||
|
|
49b8206e76 | ||
|
|
5a30cc7511 | ||
|
|
a85cc8c593 | ||
|
|
5ae496702d | ||
|
|
1dffa0987d | ||
|
|
f3d07eed97 | ||
|
|
de8266b955 | ||
|
|
d482f2ccc9 | ||
|
|
9f676a4c8d | ||
|
|
5d0dae236c | ||
|
|
bf32eca47d | ||
|
|
dcef6da5bc | ||
|
|
a1ffc8b1f6 | ||
|
|
921b66f9e1 | ||
|
|
0553a43a01 | ||
|
|
5079c67f62 | ||
|
|
785deb2add | ||
|
|
4b0654971c | ||
|
|
d2fd133743 | ||
|
|
d689495ee7 | ||
|
|
b16b4e3c12 | ||
|
|
10109672a7 | ||
|
|
4a3cd2560c | ||
|
|
576898b8fe | ||
|
|
4db9c136d6 | ||
|
|
a1a4c1b0f0 | ||
|
|
7b1b435e45 | ||
|
|
54e716d2cc | ||
|
|
b44fa22c11 | ||
|
|
5829cdcef9 | ||
|
|
f0a93b0191 | ||
|
|
9014a7e8f9 | ||
|
|
6c9f9f2521 | ||
|
|
9bae30bcb1 | ||
|
|
7069ba1f43 | ||
|
|
ae844f8ecd | ||
|
|
af734ccb1f | ||
|
|
1ff5895bad | ||
|
|
f12fa0fe9b | ||
|
|
45c67024e7 | ||
|
|
725cbd8045 | ||
|
|
502a4f2666 | ||
|
|
9aaebaad62 | ||
|
|
d163bffba6 | ||
|
|
36ba04a234 | ||
|
|
0ac683b5c0 | ||
|
|
b16d9322e3 | ||
|
|
868009b650 | ||
|
|
f917df709c | ||
|
|
ab54dc06d7 | ||
|
|
c7b7b182b9 | ||
|
|
b78cf4d022 | ||
|
|
6f492ef382 | ||
|
|
e24c9a9d56 | ||
|
|
b1155dea7d | ||
|
|
0d9245d448 | ||
|
|
858bef7703 | ||
|
|
acf39d0870 | ||
|
|
89a0884600 | ||
|
|
70e11117f1 | ||
|
|
d3f07ae524 | ||
|
|
4f9c36fdad | ||
|
|
df15fb54b0 | ||
|
|
638dc4df16 | ||
|
|
81e91ab6c5 | ||
|
|
05476c2bff | ||
|
|
3b4b0e8c32 | ||
|
|
772218d108 | ||
|
|
dae2e4c4f4 | ||
|
|
5d5595ef8b | ||
|
|
cf16fc3db7 | ||
|
|
70659c9c14 | ||
|
|
b04368f852 | ||
|
|
9e5285a845 | ||
|
|
475d475e18 | ||
|
|
2c4cfa9e81 | ||
|
|
15d7f75507 | ||
|
|
30131bc6d5 | ||
|
|
5373b61f83 | ||
|
|
7eb629676b | ||
|
|
db9b89d0ac | ||
|
|
d7af337a63 | ||
|
|
e3c5eb6add | ||
|
|
b0e57e8aca | ||
|
|
b4168214b6 | ||
|
|
7fa5928537 | ||
|
|
f12198b140 | ||
|
|
0d44ffb635 | ||
|
|
c11ebf1443 | ||
|
|
b94a5ebd8d | ||
|
|
3392d2d5a8 | ||
|
|
1df3a7aaa2 | ||
|
|
9a11214208 | ||
|
|
0a4f101370 | ||
|
|
5f3c32dc00 | ||
|
|
74cb135c6c | ||
|
|
a20e520501 | ||
|
|
23e0e4c6a0 | ||
|
|
10918546d6 | ||
|
|
cf16cc6606 | ||
|
|
3b73b665d6 | ||
|
|
993fd760af | ||
|
|
a029334576 | ||
|
|
eb8143f298 | ||
|
|
c756b447ac | ||
|
|
cef05e061c | ||
|
|
c85d517b91 | ||
|
|
170d1a6a45 | ||
|
|
8d2313cfb1 | ||
|
|
ed8636f5a5 | ||
|
|
b95d94f06f | ||
|
|
f7cdf446bf | ||
|
|
28e8e2705a | ||
|
|
9bc6f6f661 | ||
|
|
2630310210 | ||
|
|
3fae42305c | ||
|
|
9cc8dd7143 | ||
|
|
7ffa9d5309 | ||
|
|
30d2b996c0 | ||
|
|
49c098ef0b | ||
|
|
afa41c6ecd | ||
|
|
8861179cb2 | ||
|
|
f32ceb0383 | ||
|
|
9a380964aa | ||
|
|
180a8261ca | ||
|
|
0536657c8e | ||
|
|
c5a2330dd1 | ||
|
|
dc0b5be387 | ||
|
|
a1f43a5e94 | ||
|
|
b05a817769 | ||
|
|
f7187fdf27 | ||
|
|
cca51d68dd | ||
|
|
21804cc975 | ||
|
|
7a9ee27336 | ||
|
|
0c637226b2 | ||
|
|
555b14ddc0 | ||
|
|
656c23c631 | ||
|
|
bb591257c9 | ||
|
|
364a5c5c67 | ||
|
|
8cb05d38db | ||
|
|
f9fe13f322 | ||
|
|
b8d33cc270 | ||
|
|
a6ce9e9211 | ||
|
|
60bdabdd1b | ||
|
|
9c5c2080cf | ||
|
|
8f758229cb | ||
|
|
8ac9c53f1a | ||
|
|
98cbf5528c | ||
|
|
6296150677 | ||
|
|
13cb1e8df9 | ||
|
|
e26e876ccf | ||
|
|
550b50f74e | ||
|
|
ac5aa1a201 | ||
|
|
19700f54c5 | ||
|
|
7e5626cd30 | ||
|
|
c27b6ed8a1 | ||
|
|
717181bcd0 | ||
|
|
4d5916df29 | ||
|
|
93b7a1d613 | ||
|
|
35cc029984 | ||
|
|
589d44c152 | ||
|
|
06a14d875f | ||
|
|
454f823ad7 | ||
|
|
6974c4ef66 | ||
|
|
bd956dfebc | ||
|
|
4aaac5ba49 | ||
|
|
2ae792dd8d | ||
|
|
ca033f024e | ||
|
|
ad11f787de | ||
|
|
e3d1607c86 | ||
|
|
b68461cbc8 | ||
|
|
ddad35aa1e | ||
|
|
35f4aad6f8 | ||
|
|
f08f98e040 | ||
|
|
467376d9c7 | ||
|
|
1cbdc93632 | ||
|
|
2323015617 | ||
|
|
51de0dee8a | ||
|
|
b74c0ebd36 | ||
|
|
0a4c54a5a2 | ||
|
|
481809493c | ||
|
|
c3914e0423 | ||
|
|
ac40254bc4 | ||
|
|
b6540a1d20 | ||
|
|
87d29d123f | ||
|
|
0aa6f8cc07 | ||
|
|
e2bb480f5f | ||
|
|
920f951e47 | ||
|
|
215fe76f2a | ||
|
|
1740bbf666 | ||
|
|
f9370d47b4 | ||
|
|
91072cb47d | ||
|
|
c30be37f02 | ||
|
|
d51dadba04 | ||
|
|
99c404f479 | ||
|
|
659ee2959c | ||
|
|
1ac9f3b0d6 | ||
|
|
d0dc737863 | ||
|
|
e438c82a11 | ||
|
|
9d7d4601dc | ||
|
|
fdd741446c | ||
|
|
fdbab1685f | ||
|
|
ed0940b126 | ||
|
|
a087c7bb3a | ||
|
|
0439db2ec0 | ||
|
|
c1a5d4d336 | ||
|
|
eeffc6361a | ||
|
|
e2be3c20b7 | ||
|
|
b43c21fc98 | ||
|
|
e115f33d47 | ||
|
|
b8016aafc9 | ||
|
|
743b0ab10b | ||
|
|
e06b2a3581 | ||
|
|
582e00402a | ||
|
|
82551e0b4a | ||
|
|
a9c2660ec1 | ||
|
|
fa802ba313 | ||
|
|
874d56045e | ||
|
|
8204cbe60f | ||
|
|
a50c06206c | ||
|
|
895e5a2b07 | ||
|
|
2012986aff | ||
|
|
63d1f84887 | ||
|
|
d8233bc9e2 | ||
|
|
bdea3d6036 | ||
|
|
2fe2009b97 | ||
|
|
dcad12935f | ||
|
|
0e2f6343a2 | ||
|
|
56bd6f9ea7 | ||
|
|
5718a43d20 | ||
|
|
f759e2e7e5 | ||
|
|
1e97554cbf | ||
|
|
04d4a2f5a7 | ||
|
|
fb47756deb | ||
|
|
a33fed711d | ||
|
|
bcb741102d | ||
|
|
8b2d933fd1 | ||
|
|
f8d6419551 | ||
|
|
cf518aeff5 | ||
|
|
95eebcde2b | ||
|
|
5cd7dca9b0 | ||
|
|
eb87cf1bc8 | ||
|
|
8a510a7b11 | ||
|
|
e2631407e8 | ||
|
|
eb86fa911c | ||
|
|
448ad65c67 | ||
|
|
bb8d2167ce | ||
|
|
a2bf33d71d | ||
|
|
e287bdeaef | ||
|
|
16e5acbd27 | ||
|
|
1ea6961393 | ||
|
|
bd2bce0029 | ||
|
|
33c8d4c0ad | ||
|
|
d453c3cac1 | ||
|
|
187fc7c2e4 | ||
|
|
33154626e5 | ||
|
|
cfdbf1836f | ||
|
|
054692adf0 | ||
|
|
ce33421b16 | ||
|
|
d2cde424fc | ||
|
|
517ae5d4b4 | ||
|
|
e69ee5c1a8 | ||
|
|
b2eae85cc2 | ||
|
|
0749df04e5 | ||
|
|
a66ea58d24 | ||
|
|
13738d1042 | ||
|
|
695d510b9f | ||
|
|
f5e80c31b1 | ||
|
|
572a15fbab | ||
|
|
8dc9a5de3f | ||
|
|
c8643b7ce9 | ||
|
|
786dcab420 | ||
|
|
ab2f9a9846 | ||
|
|
daf43f306b | ||
|
|
53adb4be70 | ||
|
|
2458a4d141 | ||
|
|
1c78a8f593 | ||
|
|
6077eef26e | ||
|
|
8f3aaaba25 | ||
|
|
b1cd0ca44f | ||
|
|
879555915f | ||
|
|
f61ba7c1af | ||
|
|
7cb2ea33c7 | ||
|
|
855a9ac0d0 | ||
|
|
3e2e1de8ce | ||
|
|
372f49d6ef | ||
|
|
a31b3e1c79 | ||
|
|
d3ecef9216 | ||
|
|
1175e23525 | ||
|
|
08e3e21306 | ||
|
|
7e3de5e490 | ||
|
|
abc3eccf4e | ||
|
|
80751f9cfc | ||
|
|
7209992887 | ||
|
|
6c18d97f27 | ||
|
|
cdd7d6e766 | ||
|
|
8d5334126f | ||
|
|
bcd23ebb83 | ||
|
|
5d581c2319 | ||
|
|
8e3b449c42 | ||
|
|
0975b3235a | ||
|
|
9a2a4f1b77 | ||
|
|
5df17b5905 | ||
|
|
894c5f155f | ||
|
|
f848e12571 | ||
|
|
7adf6e7a1d | ||
|
|
c6958c7c69 | ||
|
|
05c6e56a4f | ||
|
|
c45cf5d207 | ||
|
|
a3995f7cce | ||
|
|
fb3652a954 | ||
|
|
b385001db2 | ||
|
|
ac96ca9e2f | ||
|
|
02ffe0eb3a | ||
|
|
389945e023 | ||
|
|
446fa0c049 | ||
|
|
8b4409c115 | ||
|
|
5684a75c65 | ||
|
|
1c6a98fea5 | ||
|
|
7c1b886c3d | ||
|
|
75bbd1a0cd | ||
|
|
a53f5a033b | ||
|
|
ea37405149 | ||
|
|
e16ecbe1b7 | ||
|
|
db6f20dd3b | ||
|
|
9fa60d0c84 | ||
|
|
2fdad79dbb | ||
|
|
20342fb58c | ||
|
|
b7e815cf85 | ||
|
|
8e3d1c432e | ||
|
|
1a8ed573a8 | ||
|
|
63516b36e4 | ||
|
|
d17b05a40a | ||
|
|
e4cefa2264 | ||
|
|
90bf3459c9 | ||
|
|
0983259117 | ||
|
|
377e5a9825 | ||
|
|
7edcb4457a | ||
|
|
3fec7867d9 | ||
|
|
7e447616d9 | ||
|
|
e59c3de0aa | ||
|
|
db808568cb | ||
|
|
0615733445 | ||
|
|
402c58c111 | ||
|
|
dde6c2ed32 | ||
|
|
766ff0a195 | ||
|
|
d614cbcff5 | ||
|
|
81798c1fc2 | ||
|
|
210a75671f | ||
|
|
f3e113dcc1 | ||
|
|
145664a42f | ||
|
|
acc770732e | ||
|
|
ded4c79911 | ||
|
|
ad0c9c710f | ||
|
|
259c4991f9 | ||
|
|
5fe185ab7f | ||
|
|
974caaff42 | ||
|
|
41d8758969 | ||
|
|
92e1e3168e | ||
|
|
a608e29911 | ||
|
|
015b72c8c6 | ||
|
|
74cf5841ff | ||
|
|
9ba7b1c972 | ||
|
|
5bf0417203 | ||
|
|
2b7a20f8d9 | ||
|
|
2afb49cbc7 | ||
|
|
17008b7711 | ||
|
|
36ff328380 | ||
|
|
bb051f4225 | ||
|
|
61c5be1a08 | ||
|
|
bc7d47b2a7 | ||
|
|
24bcd5cbf9 | ||
|
|
8407512b0f | ||
|
|
6f4e8615a3 | ||
|
|
314d36e0dc | ||
|
|
27accb0d4a | ||
|
|
fd84505ad1 | ||
|
|
8f75b13c4d | ||
|
|
31d05f8aa7 | ||
|
|
cdfe4bb844 | ||
|
|
f30e9cd8b8 | ||
|
|
931bc7b9f7 | ||
|
|
049c0d5ad7 | ||
|
|
a5f1e452e4 | ||
|
|
d89cd8598d | ||
|
|
d4e3ea60e3 | ||
|
|
b98bc8429a | ||
|
|
4bb7c9296a | ||
|
|
bb7b5b1c90 | ||
|
|
c400f6f998 | ||
|
|
fce6c0b2e4 | ||
|
|
0d0288ba18 | ||
|
|
c25d7bc8de | ||
|
|
d42fa72d54 | ||
|
|
bc7176c1cf | ||
|
|
15d454f93a | ||
|
|
249ee3bb5a | ||
|
|
a3b3d4ea0e | ||
|
|
27f9d04538 | ||
|
|
03f1869b23 | ||
|
|
479e177a64 | ||
|
|
5cf166af87 | ||
|
|
e24bcd7d38 | ||
|
|
768898df64 | ||
|
|
cf282e04bb | ||
|
|
db4edac083 | ||
|
|
877d0cf7f8 | ||
|
|
e78c441a6e | ||
|
|
e945819365 | ||
|
|
23e8db50fd | ||
|
|
193ffe6394 | ||
|
|
87016186d8 | ||
|
|
d7d96a89cf | ||
|
|
aa5ef23363 | ||
|
|
c18e0401e4 | ||
|
|
8568990295 | ||
|
|
44e6460224 | ||
|
|
d53480290c | ||
|
|
1499d883bc | ||
|
|
883a6902fa | ||
|
|
6d3b754c6c | ||
|
|
62f73ce2e6 | ||
|
|
eeab9f3fb1 | ||
|
|
c21a67d1cf | ||
|
|
afe48a44da | ||
|
|
7e4822e4ec | ||
|
|
705ab6a980 | ||
|
|
963b29eea4 | ||
|
|
b3f889c4c7 | ||
|
|
545b4891b4 | ||
|
|
c89f14b3c2 | ||
|
|
c416b00383 | ||
|
|
669a891eeb | ||
|
|
520d58b262 | ||
|
|
24dff868ff | ||
|
|
cf45bb5060 | ||
|
|
0f9064f2c3 | ||
|
|
f94f329b1f | ||
|
|
dc4560081d | ||
|
|
b42cd0e6dc | ||
|
|
bbe1e45541 | ||
|
|
2c61db1215 | ||
|
|
fde2bb94d9 | ||
|
|
436a43d3ad | ||
|
|
6b2a6f3a83 | ||
|
|
8e5773115c | ||
|
|
626a5dfe16 | ||
|
|
e63f4816c4 | ||
|
|
13852b194b | ||
|
|
a68c20098b | ||
|
|
432b5a767e | ||
|
|
952659198c | ||
|
|
4e518758e5 | ||
|
|
e1b3dd311f | ||
|
|
bb0f923155 | ||
|
|
ab86f02bd7 | ||
|
|
43067cfb07 | ||
|
|
3300694059 | ||
|
|
f59b8715dd | ||
|
|
60abadd1fc | ||
|
|
4297c91c5e | ||
|
|
c8eddc3787 | ||
|
|
d01d81a6d7 | ||
|
|
40b31fd8af | ||
|
|
7b995b35cd | ||
|
|
00885d57c9 | ||
|
|
d03d7dbc47 | ||
|
|
7fd4074bd3 | ||
|
|
8367bca4d5 | ||
|
|
5059990adb | ||
|
|
9dd9d39df4 | ||
|
|
87f89fea6d | ||
|
|
8433b3cf5f | ||
|
|
eb9f1eb4c2 | ||
|
|
7308597f23 | ||
|
|
a4837cf337 | ||
|
|
dd8ba14bbe | ||
|
|
5a5b394a17 | ||
|
|
ab8eb41853 | ||
|
|
38bfcc1581 | ||
|
|
1c7840a203 | ||
|
|
b5fb7e997c | ||
|
|
1df2fd3486 | ||
|
|
957619933d | ||
|
|
3f9fdb10a3 | ||
|
|
bf48a61766 | ||
|
|
6a1606ca6c | ||
|
|
7551ff2ecb | ||
|
|
20e864b672 | ||
|
|
91f7ea072f | ||
|
|
8b62d70562 | ||
|
|
fd43eb0ea1 | ||
|
|
759b89f790 | ||
|
|
54a27df86d | ||
|
|
4a99c44702 | ||
|
|
ed01b1fb87 | ||
|
|
f3e135e5f8 | ||
|
|
c51ef99946 | ||
|
|
01ee3f7418 | ||
|
|
88e58e65ed | ||
|
|
83673981f2 | ||
|
|
dd398ccacc | ||
|
|
0d43243650 | ||
|
|
bb0088171d | ||
|
|
8f74b19aae | ||
|
|
62a9c84114 | ||
|
|
a863def55a | ||
|
|
59d538cdee | ||
|
|
8aad0c71f7 | ||
|
|
de1b5f08b1 | ||
|
|
75217eea04 | ||
|
|
9d37a9eaa5 | ||
|
|
66d7f98716 | ||
|
|
ae6dfa3321 | ||
|
|
3f4c6f8703 | ||
|
|
eb96d22139 | ||
|
|
448b30e0f2 | ||
|
|
110909f64d | ||
|
|
58b25f6da4 | ||
|
|
91110460fb | ||
|
|
a81fafe268 | ||
|
|
f5512b19da | ||
|
|
6da457bb06 | ||
|
|
3117a9d4ea | ||
|
|
8ffcc18c62 | ||
|
|
81364738cf | ||
|
|
ce9436f449 | ||
|
|
bb83a64317 | ||
|
|
a4bd6f7b83 | ||
|
|
83300d0ef1 | ||
|
|
6a6c10b825 | ||
|
|
cb398c5b1d | ||
|
|
68893741f0 | ||
|
|
64dede5c9a | ||
|
|
3f920aaf60 | ||
|
|
749296a6c0 | ||
|
|
709e5a9949 | ||
|
|
5d35b5fb2c | ||
|
|
3059b88991 | ||
|
|
0fb0b4df0a | ||
|
|
26df351edf | ||
|
|
6e4979df44 | ||
|
|
55a681e7f2 | ||
|
|
4388b6512e | ||
|
|
efd6b882d1 | ||
|
|
9f629c6ec6 | ||
|
|
5aae7e9eeb | ||
|
|
165ff43df4 | ||
|
|
0fe5640b23 | ||
|
|
780d52a444 | ||
|
|
de46e0c679 | ||
|
|
200bee3721 | ||
|
|
c7b4bce1aa | ||
|
|
af2d6c4f97 | ||
|
|
6d71e97590 | ||
|
|
c3a4ac781a | ||
|
|
70a9eb4b93 | ||
|
|
f2e06bcd50 | ||
|
|
416e6951fc | ||
|
|
4e608ad268 | ||
|
|
580f3dd308 | ||
|
|
66f3c75b40 | ||
|
|
66d2593a1c | ||
|
|
7473148a8f | ||
|
|
f748967669 | ||
|
|
f922884f04 | ||
|
|
659c19c3bd | ||
|
|
15a0920943 | ||
|
|
2ae634b434 | ||
|
|
64dff2c1cc | ||
|
|
8354372ffb | ||
|
|
1b1861c7a2 | ||
|
|
f3dc72b234 | ||
|
|
eac0700338 | ||
|
|
d75ad73d63 | ||
|
|
8b72c6d21d | ||
|
|
a3185a5a67 | ||
|
|
e7c9674fb4 | ||
|
|
a56b64fd63 | ||
|
|
c4dcd44efb | ||
|
|
2b90234905 | ||
|
|
336952bc57 | ||
|
|
7d2ff25b05 | ||
|
|
b3a1ac2e1e | ||
|
|
e455da63c2 | ||
|
|
d49da4376a | ||
|
|
ab6f072b1c | ||
|
|
a5ab1aebf3 | ||
|
|
ef18f1cb0c | ||
|
|
639568f9a0 | ||
|
|
bc458d7907 | ||
|
|
f843ee1f27 | ||
|
|
5d3cb8c1f3 | ||
|
|
9df35bd9eb | ||
|
|
0619429c00 | ||
|
|
a5f68e1c20 | ||
|
|
8c32714dfb | ||
|
|
dc561a8d17 | ||
|
|
e2cb809961 | ||
|
|
fe6d7fa663 | ||
|
|
3f3a4ea4e3 | ||
|
|
6df93c3436 | ||
|
|
1dd8c4a102 | ||
|
|
14d8197b1e | ||
|
|
828970cc5e | ||
|
|
d9f596f06d | ||
|
|
0274f96d6c | ||
|
|
a1e108ca5e | ||
|
|
6a8a258dc0 | ||
|
|
92db01c4fe | ||
|
|
208af67a50 | ||
|
|
2aad28cd7f | ||
|
|
b3959e0867 | ||
|
|
b0dca67a70 | ||
|
|
2cd0ff81e4 | ||
|
|
d1a39153b3 | ||
|
|
85a29a6942 | ||
|
|
22d8b889fe | ||
|
|
62bddf8b34 | ||
|
|
75d7783e20 | ||
|
|
93cfbc8230 | ||
|
|
c18d56dac5 | ||
|
|
775c54566c | ||
|
|
94d5c4c325 | ||
|
|
2c57d3aacf | ||
|
|
71dd28e09d | ||
|
|
ee01373b27 | ||
|
|
2eeab7a55e | ||
|
|
67d326c6c7 | ||
|
|
7af52572e0 | ||
|
|
4f6d74a19d | ||
|
|
c3232437af | ||
|
|
cf4b51a297 | ||
|
|
124d5af2f2 | ||
|
|
a0eb4a6a6e | ||
|
|
2f560a9049 | ||
|
|
d9c250b9eb | ||
|
|
e67d9903ac | ||
|
|
6b4ca9b0cd | ||
|
|
5eab13ee0c | ||
|
|
3fe19ab837 | ||
|
|
b61161f257 | ||
|
|
9ed65acfb1 | ||
|
|
0404a7444c | ||
|
|
34b2e7af71 | ||
|
|
6f88d4fa1d | ||
|
|
d486aa73e0 | ||
|
|
f447d8ad55 | ||
|
|
9cd25db5c1 | ||
|
|
c198bd30a5 | ||
|
|
964db770d6 | ||
|
|
a906aaf56b | ||
|
|
0ebac22842 | ||
|
|
c93aee580f | ||
|
|
329fafea47 | ||
|
|
c4b5022f45 | ||
|
|
f31416c7cb | ||
|
|
7267542db9 | ||
|
|
ff4eab235d | ||
|
|
e8caf704fe | ||
|
|
5d6f1a9f95 | ||
|
|
726cd7d4d2 | ||
|
|
34077c6818 | ||
|
|
5434228df0 | ||
|
|
b28593c46a | ||
|
|
1502ccf3ac | ||
|
|
8f47bc5799 | ||
|
|
ea0c06444a | ||
|
|
9d42f856d1 | ||
|
|
e0c6e9313b | ||
|
|
a01ce204ab | ||
|
|
194019c091 | ||
|
|
99884884de | ||
|
|
ea437036db | ||
|
|
1eb0b4fda5 | ||
|
|
484b965bbc | ||
|
|
1ee73008be | ||
|
|
3c38357ca9 | ||
|
|
92f9fd4e69 | ||
|
|
9c9b090af4 | ||
|
|
53e4e0c59b | ||
|
|
df446ccab5 | ||
|
|
0d7ea454b6 | ||
|
|
d57dfb1055 | ||
|
|
99b4e5a1b5 | ||
|
|
c42b6cccb1 | ||
|
|
f24a04d0dc | ||
|
|
8373c4b3c5 | ||
|
|
08d7171d1a | ||
|
|
ede455f2e3 | ||
|
|
b63981286c | ||
|
|
b9dfa38bcc | ||
|
|
fe9c63387b | ||
|
|
ee49f69231 | ||
|
|
94907bec26 | ||
|
|
3566016f7b | ||
|
|
182b725f43 | ||
|
|
6af486d772 | ||
|
|
18db0b0028 | ||
|
|
d95962a358 | ||
|
|
983bf4d61f | ||
|
|
0928d8da0d | ||
|
|
ae6c94da97 | ||
|
|
35492ca88b | ||
|
|
b94029e925 | ||
|
|
aa643610b5 | ||
|
|
573d9ec036 | ||
|
|
c84906f2ce | ||
|
|
96ac640116 | ||
|
|
7bc93f8a79 | ||
|
|
a2e3c59857 | ||
|
|
1306b25950 | ||
|
|
d52956ebf6 | ||
|
|
dd22f41fd0 | ||
|
|
f0df305124 | ||
|
|
073f13b44b | ||
|
|
d6418c941a | ||
|
|
3adcafde2b | ||
|
|
6151daa68b | ||
|
|
89bd5727a1 | ||
|
|
31ea9d8c55 | ||
|
|
6d8e901158 | ||
|
|
1c957ba4d9 | ||
|
|
a2d9049541 | ||
|
|
93ba9615d9 | ||
|
|
1a165b36ae | ||
|
|
cbc3cb8464 | ||
|
|
dad7cccae0 | ||
|
|
f14370ac8d | ||
|
|
783dcad5c0 | ||
|
|
014a45a2dc | ||
|
|
24e93e3bd5 | ||
|
|
5ae16e6de1 | ||
|
|
195e6327e0 | ||
|
|
3649246d19 | ||
|
|
7044f85245 | ||
|
|
fcf8fb5846 | ||
|
|
cd9ab37d00 | ||
|
|
c607913e82 | ||
|
|
919b5e730c | ||
|
|
4fbc0f5817 | ||
|
|
afd1bcae17 | ||
|
|
1334763e3d | ||
|
|
c6725395da | ||
|
|
617ca3ecbc | ||
|
|
bdd376c46d | ||
|
|
8e6f126335 | ||
|
|
70af483e01 | ||
|
|
e1331d7fd5 | ||
|
|
6cec06a14e | ||
|
|
1d32ab9ee7 | ||
|
|
9035be3f5d | ||
|
|
cf127ae0e7 | ||
|
|
7b940c409f | ||
|
|
02e6225240 | ||
|
|
97660d7b2d | ||
|
|
b53c0cfe4f | ||
|
|
b676ace34b | ||
|
|
2d1e9af5cb | ||
|
|
679184e8e0 | ||
|
|
7f912e04e7 | ||
|
|
26ed32d51f | ||
|
|
374d76bb35 | ||
|
|
664c3f1277 | ||
|
|
c48c89b0d9 | ||
|
|
7f1787e52b | ||
|
|
a28f51fa55 | ||
|
|
383102802b | ||
|
|
0516749708 | ||
|
|
002f1570dc | ||
|
|
9d025fc3cd | ||
|
|
03bb13b830 | ||
|
|
b351ed0cf4 | ||
|
|
abeb28c9cc | ||
|
|
2325f74581 | ||
|
|
e75ef3e44d | ||
|
|
6431b45769 | ||
|
|
7dfcbb619f | ||
|
|
051b58ca6f | ||
|
|
5777b39c22 | ||
|
|
eb4f135698 | ||
|
|
3bcb04ece7 | ||
|
|
e42aca875f | ||
|
|
28915ab848 | ||
|
|
0fe491871d | ||
|
|
6fc6a483a8 | ||
|
|
13236880b5 | ||
|
|
9461719039 | ||
|
|
63163cc4c1 | ||
|
|
5cdf159bc1 | ||
|
|
47a9981fdf | ||
|
|
f85bdb7d02 | ||
|
|
7796d03e21 | ||
|
|
90094c082a | ||
|
|
2b695e2f2e | ||
|
|
2e05f3ef64 | ||
|
|
316f1efd08 | ||
|
|
4678a63955 | ||
|
|
8584454d5d | ||
|
|
2c6cf76a10 | ||
|
|
cd3226df21 | ||
|
|
4bcc6ef1f2 | ||
|
|
77e56c25ae | ||
|
|
e7b363612a | ||
|
|
a217c61ba1 | ||
|
|
e7b4fe44c8 | ||
|
|
f8cc580b99 | ||
|
|
03057b3263 | ||
|
|
452b9b7c67 | ||
|
|
5ae16d0adc | ||
|
|
5ed135c023 | ||
|
|
d425298cd9 | ||
|
|
786815d073 | ||
|
|
54cad92a3f | ||
|
|
54e21f4644 | ||
|
|
3c76f177cd | ||
|
|
aa05c62d94 | ||
|
|
3f16158e27 | ||
|
|
6f2824512d | ||
|
|
723b67f886 | ||
|
|
008d55e63b | ||
|
|
79885454ab | ||
|
|
ba21723bdc | ||
|
|
c36c4918a8 | ||
|
|
853147518d | ||
|
|
2f19d86c95 | ||
|
|
39bdabffcb | ||
|
|
a7bdaedfe1 | ||
|
|
1c6106081f | ||
|
|
8ab6cded2e | ||
|
|
ff63bb959a | ||
|
|
6c79bb1ff0 | ||
|
|
ce73336c0c | ||
|
|
248977c5b5 | ||
|
|
77a6e63210 | ||
|
|
6f6fb35177 | ||
|
|
9db565cb4f | ||
|
|
2a254a7fab | ||
|
|
15e76ab029 | ||
|
|
66b95cdaa0 | ||
|
|
32ea93cb88 | ||
|
|
22a2a64861 | ||
|
|
d841fdf02c | ||
|
|
9421f09ded | ||
|
|
b4af552fb9 | ||
|
|
69dfb17460 | ||
|
|
4703750c27 | ||
|
|
40caf99939 | ||
|
|
df5f648b26 | ||
|
|
55472bbbc0 | ||
|
|
f23c4e2b6a | ||
|
|
0b101d662e | ||
|
|
a7f07afc14 | ||
|
|
2715021898 | ||
|
|
e8fa0036e2 | ||
|
|
f628a5e7ef | ||
|
|
95e6bc120e | ||
|
|
0e35c891c4 | ||
|
|
b7a3e7014c | ||
|
|
0c1c587bc7 | ||
|
|
a0a2c60e63 | ||
|
|
45c912a0d6 | ||
|
|
39945f161d | ||
|
|
ed958302bd | ||
|
|
477f2141d7 | ||
|
|
d321a958f0 | ||
|
|
d14f1df823 | ||
|
|
f7e3b9f6c7 | ||
|
|
cd3ac201f8 | ||
|
|
ceef493b01 | ||
|
|
480a75e30c | ||
|
|
d8cc953fe7 | ||
|
|
0baec88321 | ||
|
|
74bd3f681f | ||
|
|
713b750f4a | ||
|
|
11eee911ca | ||
|
|
b288aaea90 | ||
|
|
7acc018fd2 | ||
|
|
7aba1096f9 | ||
|
|
0be7202144 | ||
|
|
83a5db74e5 | ||
|
|
8dc4371beb | ||
|
|
e5045a0984 | ||
|
|
2c9b37a0cc | ||
|
|
b608482220 | ||
|
|
9290fac899 | ||
|
|
d7901ee575 | ||
|
|
7eb33a5aef | ||
|
|
c5dc103ac0 | ||
|
|
c90172a862 | ||
|
|
8540786c2c | ||
|
|
7aeb8e851d | ||
|
|
2f207dc3d9 | ||
|
|
7e2be73962 | ||
|
|
e2a87eb945 | ||
|
|
4b79c6304f | ||
|
|
22f63e1056 | ||
|
|
bf1613ba66 | ||
|
|
38e04db0cb | ||
|
|
08cc3034a2 | ||
|
|
a0eb176b6e | ||
|
|
a43774b33f | ||
|
|
8369b4d205 | ||
|
|
87e51cf9f9 | ||
|
|
8a8fb79fde | ||
|
|
1dc4cf36b1 | ||
|
|
75cf43e02a | ||
|
|
dd8357453b | ||
|
|
e9b273a62c | ||
|
|
ebebd0fda6 | ||
|
|
3e06902b07 | ||
|
|
a874b42c41 | ||
|
|
f6215d3563 | ||
|
|
4fad58f7fe | ||
|
|
276e4c3d09 | ||
|
|
8aef4d605b | ||
|
|
962c891baa | ||
|
|
eb596ea901 | ||
|
|
e708ec9adc | ||
|
|
c631a083ea | ||
|
|
169ea8c233 | ||
|
|
241e2258e8 | ||
|
|
5d2d6bc5fb | ||
|
|
c7ef22f5c2 | ||
|
|
3ec89decf0 | ||
|
|
2693f47ed5 | ||
|
|
496a13e0e0 | ||
|
|
71ef416dbb | ||
|
|
5bc80c8677 | ||
|
|
8c300f467c | ||
|
|
78b3bf1475 | ||
|
|
ce74f910a7 | ||
|
|
ece531249e | ||
|
|
5052e2510e | ||
|
|
b8f8f80499 | ||
|
|
a13d98e32b | ||
|
|
94996dcec8 | ||
|
|
4b8ce30df8 | ||
|
|
485a37e9b5 | ||
|
|
b41adb12f7 | ||
|
|
a9cd443bdd | ||
|
|
92b9df718f | ||
|
|
a6a4f91d83 | ||
|
|
0fb2c498f4 | ||
|
|
d87e70f7ee | ||
|
|
bee8bae0ae | ||
|
|
d5ef277121 | ||
|
|
dfc8e6c108 | ||
|
|
9100aee91f | ||
|
|
c94cc92d1c | ||
|
|
a41f5e3aca | ||
|
|
cc2d15de0d | ||
|
|
39e08aedae | ||
|
|
fe8730fb1f | ||
|
|
aac3ac8947 | ||
|
|
1acc908bb8 | ||
|
|
47ed98a5e1 | ||
|
|
103034d1b8 | ||
|
|
608714d00a | ||
|
|
575c287860 | ||
|
|
820470bef7 | ||
|
|
ea000ef56c | ||
|
|
793b8f8495 | ||
|
|
373eee3024 | ||
|
|
194273635e | ||
|
|
0972d7d89d | ||
|
|
d1415f9d86 | ||
|
|
b3ff3bb406 | ||
|
|
1efce62a8a | ||
|
|
442f2ff927 | ||
|
|
84cefa1be8 | ||
|
|
45e9c1eccb | ||
|
|
e7e2c9604e | ||
|
|
edaf6875ef | ||
|
|
e7976a0a88 | ||
|
|
e7daae9250 | ||
|
|
91a188f3fd | ||
|
|
49a6d48101 | ||
|
|
a4b5ed3597 | ||
|
|
2efbfeef8c | ||
|
|
1a9093bbdf | ||
|
|
9b3d3c6bdc | ||
|
|
620a6f4795 | ||
|
|
1615fa9e51 | ||
|
|
205906ebb7 | ||
|
|
0d8016651c | ||
|
|
ffdc776413 | ||
|
|
9b92b3d1ae | ||
|
|
3bfea2fd0e | ||
|
|
97636ca575 | ||
|
|
8c4c2095d7 | ||
|
|
9855656e9a | ||
|
|
cffdf92daf | ||
|
|
d07ca4076e | ||
|
|
47740df9a9 | ||
|
|
e830d3fef9 | ||
|
|
3c0a87354e | ||
|
|
b5a2eb9fb9 | ||
|
|
244bd02771 | ||
|
|
95a978495d | ||
|
|
292ad8bb61 | ||
|
|
8b0a2263e7 | ||
|
|
bb9ffe3492 | ||
|
|
ea7ef074e1 | ||
|
|
897d6727b4 | ||
|
|
0f99728be3 | ||
|
|
51903d04f5 | ||
|
|
dde7dbf3a0 | ||
|
|
508998efa8 | ||
|
|
2691247532 | ||
|
|
f3c9d8211f | ||
|
|
ec970d9f08 | ||
|
|
04b86ae7e4 | ||
|
|
b7b31ca5c2 | ||
|
|
7c76e3b3a9 | ||
|
|
863f8d8630 | ||
|
|
b12d16f7a7 | ||
|
|
ead8474783 | ||
|
|
03a1ead721 | ||
|
|
234dbb60fe | ||
|
|
43503df53c | ||
|
|
6c381aecb7 | ||
|
|
8d1e366e10 | ||
|
|
d6a0713f52 | ||
|
|
113bde4bab | ||
|
|
b5354a2431 | ||
|
|
c166dd9ed8 | ||
|
|
80b73da72c | ||
|
|
98490e6444 | ||
|
|
56649471d0 | ||
|
|
0186bdc15a | ||
|
|
eb02014371 | ||
|
|
993292cec8 | ||
|
|
aac99eaf07 | ||
|
|
e5d82df65c | ||
|
|
10bbbecf7b | ||
|
|
8b5f7c4bcb | ||
|
|
10c568fcac | ||
|
|
7443a2fbc4 | ||
|
|
3e5681abc5 | ||
|
|
e5036c0b45 | ||
|
|
a312a81141 | ||
|
|
40dd55d828 | ||
|
|
135bb622e1 | ||
|
|
ed8911be5f | ||
|
|
a126d0610f | ||
|
|
946301ba86 | ||
|
|
604e8ec366 | ||
|
|
2593dd4e86 | ||
|
|
9ee9cdd969 | ||
|
|
ad3918f071 | ||
|
|
3cbc1efa59 | ||
|
|
446f0336b6 | ||
|
|
e3c3becb63 | ||
|
|
8564c85378 | ||
|
|
eedca4c1d0 | ||
|
|
28c46dcbfd | ||
|
|
544c4c3e80 | ||
|
|
8bfd1a4d11 | ||
|
|
3fae6241a7 | ||
|
|
48ff4209c4 | ||
|
|
1d3986c589 | ||
|
|
52b2ec776b | ||
|
|
9d3c6b28ef | ||
|
|
c242eb3750 | ||
|
|
e718fea565 | ||
|
|
14644da9c1 | ||
|
|
0c99dc52d3 | ||
|
|
9cf94d2d1d | ||
|
|
de560f9b8a | ||
|
|
47b8b7a9b8 | ||
|
|
216c468118 | ||
|
|
dd6735167b | ||
|
|
a400eabc0e | ||
|
|
3b95f1b873 | ||
|
|
7b1441814d | ||
|
|
cc39a3a173 | ||
|
|
34df5cfc6a | ||
|
|
37836e7842 | ||
|
|
121231f6fa | ||
|
|
71c8c98ce9 | ||
|
|
2c3cce4d51 | ||
|
|
34ed1247e3 | ||
|
|
e8351a1136 | ||
|
|
4f93e3f5e2 | ||
|
|
33017aa948 | ||
|
|
cbfe68230c | ||
|
|
775c998e4d | ||
|
|
46b8fc7dcd | ||
|
|
d671eb14ce | ||
|
|
cd7d4969ef | ||
|
|
a4e7509354 | ||
|
|
cd74573adc | ||
|
|
86f14ad88a | ||
|
|
847634e524 | ||
|
|
f162019f77 | ||
|
|
b814632f74 | ||
|
|
741ced6289 | ||
|
|
d939a69d16 | ||
|
|
08416cb4e1 | ||
|
|
a30a607f4f | ||
|
|
5caae28b55 | ||
|
|
c85b9d9ee1 | ||
|
|
3d8c8520de | ||
|
|
9e8aec16e5 | ||
|
|
91ea47a6f4 | ||
|
|
c0d108ee77 | ||
|
|
94479f47d9 | ||
|
|
f6fd017871 | ||
|
|
15de706fba | ||
|
|
f7745ac556 | ||
|
|
ad32ed5a80 | ||
|
|
18e1522374 | ||
|
|
bd01da15cc | ||
|
|
1b4ca71e38 | ||
|
|
68fabef2be | ||
|
|
c07f756d67 | ||
|
|
bf52369d2d | ||
|
|
e49e63f39a | ||
|
|
f651f2e577 | ||
|
|
f424da410b | ||
|
|
ada809066c | ||
|
|
e7ec8518e7 | ||
|
|
1ea6a4d987 | ||
|
|
f111aad6b7 | ||
|
|
88282db0e4 | ||
|
|
55e09938bb | ||
|
|
add6c73af7 | ||
|
|
62a932b9ed | ||
|
|
037024395d | ||
|
|
e2105e1fa3 | ||
|
|
be67b33f99 | ||
|
|
7224da9c7a | ||
|
|
fd68007c41 | ||
|
|
e77c7935de | ||
|
|
cf0072fce4 | ||
|
|
6f8a8b4264 | ||
|
|
746db62fdb | ||
|
|
fa4e254ba4 | ||
|
|
0a2e385f41 | ||
|
|
2d6e939306 | ||
|
|
b5d7107760 | ||
|
|
575dd08b74 | ||
|
|
b043541dca | ||
|
|
fa5fd85b5d | ||
|
|
be68cece47 | ||
|
|
50245e618b | ||
|
|
e0d85a65f9 | ||
|
|
a414a994fb | ||
|
|
c0e7f4c0fa | ||
|
|
c03375c7f0 | ||
|
|
453cfbb306 | ||
|
|
deb5ee8374 | ||
|
|
5d2f7dbe8a | ||
|
|
3831998add | ||
|
|
e85982e17c | ||
|
|
46c17c8470 | ||
|
|
5fd326f2e8 | ||
|
|
ad5e086eb5 | ||
|
|
ef9231f51f | ||
|
|
bf34661e42 | ||
|
|
286db4fbea | ||
|
|
4f4dbfbc6f | ||
|
|
1046daaf16 | ||
|
|
b7df9d05a7 | ||
|
|
394c3ef4f6 | ||
|
|
7a071c33cd | ||
|
|
71733de05f | ||
|
|
d5e48a3e36 | ||
|
|
e36755a21d | ||
|
|
9620164884 | ||
|
|
711844cc83 | ||
|
|
6eb82b26a7 | ||
|
|
b12cf6219a | ||
|
|
7e46305277 | ||
|
|
14aa127f31 | ||
|
|
6e7e89c2d0 | ||
|
|
819c37bdec | ||
|
|
c80690d66e | ||
|
|
084f879537 | ||
|
|
7afd6bbbe9 | ||
|
|
46641e8a86 | ||
|
|
010b386ce1 | ||
|
|
a73b320715 | ||
|
|
5f822a6230 | ||
|
|
024fac90cd | ||
|
|
133bc36cca | ||
|
|
51a7ff2820 | ||
|
|
bc96f8df49 | ||
|
|
979f197476 | ||
|
|
1677b69363 | ||
|
|
d627f694df | ||
|
|
4c52cba21f | ||
|
|
597fdd1695 | ||
|
|
9031704b9b | ||
|
|
510a5c5007 | ||
|
|
469e76c50b | ||
|
|
f6c6c58c17 | ||
|
|
e546866f78 | ||
|
|
081566b5d9 | ||
|
|
ec078666ae | ||
|
|
1ce394c7a1 | ||
|
|
2fc3930b43 | ||
|
|
9fa9da5e74 | ||
|
|
d6ad0b5e94 | ||
|
|
15dc50804f | ||
|
|
63c3e35064 | ||
|
|
297930c4d1 | ||
|
|
098c344047 | ||
|
|
4f74677d14 | ||
|
|
0869b19408 | ||
|
|
9b02611700 | ||
|
|
5daa71e6c1 | ||
|
|
aa5f2f66f8 | ||
|
|
92d04f81c3 | ||
|
|
5d53db4211 | ||
|
|
eb3bbdd3c5 | ||
|
|
1ac816ca37 | ||
|
|
33cf18cde5 | ||
|
|
0c0d53dd78 | ||
|
|
1959ee7560 | ||
|
|
ee13401b5a | ||
|
|
78b1cf4af5 | ||
|
|
0599260e31 | ||
|
|
08dd921088 | ||
|
|
e66e938d7d | ||
|
|
b5b7d2a9d2 | ||
|
|
46298d555b | ||
|
|
8fb34b5fde | ||
|
|
28f8986837 | ||
|
|
e968173f61 | ||
|
|
f703a8868b | ||
|
|
0a29e5f156 | ||
|
|
c5c28ee042 | ||
|
|
44ca43399d | ||
|
|
13a47d822d | ||
|
|
5621cd90bb | ||
|
|
9f7055ffd2 | ||
|
|
37a9fc2eb0 | ||
|
|
923325874c | ||
|
|
7ca0c4d744 | ||
|
|
a584a71429 | ||
|
|
70f47635b4 | ||
|
|
8e35d77e07 | ||
|
|
7024f2d472 | ||
|
|
7e2dd4c7ff | ||
|
|
f20d83ca8c | ||
|
|
f31f920137 | ||
|
|
0f428438a3 | ||
|
|
b7882b0322 | ||
|
|
3a417a9281 | ||
|
|
748085c2be | ||
|
|
6a3f56f95f | ||
|
|
f6d6fb7185 | ||
|
|
7865263c1c | ||
|
|
2cf51d5a09 | ||
|
|
f993be950f | ||
|
|
52c4c49bab | ||
|
|
60fdc7b7ea | ||
|
|
a330cff3e5 | ||
|
|
9ffbac7420 | ||
|
|
7909707894 | ||
|
|
8d8014b157 | ||
|
|
a459b7a393 | ||
|
|
7d405dc0c2 | ||
|
|
3decf8749b | ||
|
|
ba6869ec76 | ||
|
|
33cb70ea17 | ||
|
|
69f1b7471f | ||
|
|
76a7d1dba7 | ||
|
|
9f0d3c9d3b | ||
|
|
ff6292160f | ||
|
|
52dcb7972f | ||
|
|
10e2b0ee59 | ||
|
|
473eccbdea | ||
|
|
f6b2e0a506 | ||
|
|
22e16db1f2 | ||
|
|
2c71ca9b8a | ||
|
|
023189bca9 | ||
|
|
8447985b98 | ||
|
|
ad123dc93c | ||
|
|
22983133ee | ||
|
|
60c4a885fd | ||
|
|
95d6d7b7d5 | ||
|
|
37a86b7e2b | ||
|
|
c4ef1251c9 | ||
|
|
9d7e42aa60 | ||
|
|
8536e354ad | ||
|
|
e3faf676cd | ||
|
|
630e016805 | ||
|
|
23b8b8135c | ||
|
|
7f0b4c079a | ||
|
|
47649cdedc | ||
|
|
7915798ca2 | ||
|
|
86cd88910a | ||
|
|
229ccc75f0 | ||
|
|
6f3e3a7957 | ||
|
|
1f1996b909 | ||
|
|
c2069da919 | ||
|
|
458957ddfb | ||
|
|
95c266fbf3 | ||
|
|
4857940165 | ||
|
|
4c780d09e7 | ||
|
|
d616867cd1 | ||
|
|
909c4ad3bc | ||
|
|
44eff643a9 | ||
|
|
a223e57690 | ||
|
|
69bf2d7081 | ||
|
|
c64644a331 | ||
|
|
e8b82ca687 | ||
|
|
47bd8d1d26 | ||
|
|
a6e88a63d5 | ||
|
|
e6be9a7d13 | ||
|
|
8e34925af7 | ||
|
|
1ec6cefc16 | ||
|
|
4a4c5c3e0f | ||
|
|
19e6a38355 | ||
|
|
066f451a4d | ||
|
|
c50776b991 | ||
|
|
8daa9f71e2 | ||
|
|
340cff5e5b | ||
|
|
1747125ea7 | ||
|
|
6ce650dc15 | ||
|
|
d2b303b47c | ||
|
|
74c5bfa64b | ||
|
|
f826c0e4bb | ||
|
|
b8fc3c6c37 | ||
|
|
22b8c8a62e | ||
|
|
f7ad83d2b5 | ||
|
|
fa8b5d6b71 | ||
|
|
036bff1489 | ||
|
|
fe1854f2d8 | ||
|
|
df9a34dc16 | ||
|
|
e762ea4b90 | ||
|
|
3b725837ac | ||
|
|
23efd8e5d8 | ||
|
|
b61463f570 | ||
|
|
8339233459 | ||
|
|
df68111f0c | ||
|
|
b73ad38156 | ||
|
|
2b7d1ed09f | ||
|
|
f1ef5fa787 | ||
|
|
ec14e07513 | ||
|
|
efdd5fab66 | ||
|
|
4fa114a3f2 | ||
|
|
ab64ff14b1 | ||
|
|
65609c5822 | ||
|
|
bdd41c0434 | ||
|
|
80da793c8d | ||
|
|
ba6c296b14 | ||
|
|
9ae95752ad | ||
|
|
9ba430c53c | ||
|
|
9e605a2717 | ||
|
|
aeab22010f | ||
|
|
2d20f4479c | ||
|
|
6546679def | ||
|
|
4dabd20a2e | ||
|
|
d8e5cb7893 | ||
|
|
28514adf00 | ||
|
|
bfa8aa0a86 | ||
|
|
9e205155a5 | ||
|
|
1e921dd5ea | ||
|
|
5c73e49610 | ||
|
|
91f11e4828 | ||
|
|
4a9c969dc0 | ||
|
|
88e960ae33 | ||
|
|
0217f4f2cc | ||
|
|
29fb8b0b40 | ||
|
|
773ee78fb2 | ||
|
|
d43e28d723 | ||
|
|
d063fdd81d | ||
|
|
f73cd5ec31 | ||
|
|
35df43b727 | ||
|
|
e17999a2d6 | ||
|
|
9f658fc060 | ||
|
|
27ece919d7 | ||
|
|
0e97953adf | ||
|
|
66d44c3a6d | ||
|
|
66ca1b4103 | ||
|
|
0b3040f7b7 | ||
|
|
066f7edfd9 | ||
|
|
72f049452b | ||
|
|
c1b493b7c7 | ||
|
|
67af1ba39e | ||
|
|
c48851719a | ||
|
|
cfbda17cfb | ||
|
|
be32fd4a17 | ||
|
|
98b9e0471c | ||
|
|
9efbbb4f20 | ||
|
|
7b8779fc48 | ||
|
|
07e6042e67 | ||
|
|
814303c521 | ||
|
|
2673b638bf | ||
|
|
92b7b7ae2a | ||
|
|
7d63c2dc11 | ||
|
|
514facacd5 | ||
|
|
89dc8791d0 | ||
|
|
700f65ce73 | ||
|
|
4f24701460 | ||
|
|
0514d51aea | ||
|
|
99a05c66ef | ||
|
|
e533e1472e | ||
|
|
ab00cb11bb | ||
|
|
932b98a634 | ||
|
|
b084b0f79e | ||
|
|
115d479020 | ||
|
|
1cb9a60bba | ||
|
|
14c304ca2d | ||
|
|
88d1ecc7ec | ||
|
|
7cabff0bc4 | ||
|
|
5e0ab39301 | ||
|
|
f6ff4e2d7d | ||
|
|
49c0f3b1c5 | ||
|
|
fbd38aa147 | ||
|
|
922956e981 | ||
|
|
ba1447d5f4 |
28
.github/dependabot.yml
vendored
Normal file
28
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
assignees:
|
||||
- "SpudGunMan"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
assignees:
|
||||
- "SpudGunMan"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
assignees:
|
||||
- "SpudGunMan"
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
61
.github/workflows/docker-image.yml
vendored
Normal file
61
.github/workflows/docker-image.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
#
|
||||
name: Create and publish a Docker image on new release
|
||||
|
||||
# Configures this workflow to run every time a change is pushed to the branch called `release`.
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
|
||||
# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds.
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
#
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@9fe7774c8f8ebfade96f0a62aa10f3882309d517
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
|
||||
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see [Usage](https://github.com/docker/build-push-action#usage) in the README of the `docker/build-push-action` repository.
|
||||
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
# This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see [Using artifact attestations to establish provenance for builds](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds).
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
21
.github/workflows/greetings.yml
vendored
Normal file
21
.github/workflows/greetings.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: Greetings
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
greeting:
|
||||
name: Greet first-time contributors
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/first-interaction@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
pr_message: "Thank you for your pull request!"
|
||||
30
.gitignore
vendored
30
.gitignore
vendored
@@ -1,15 +1,31 @@
|
||||
# config
|
||||
config.ini
|
||||
config_new.ini
|
||||
ini_merge_log.txt
|
||||
install_notes.txt
|
||||
|
||||
# Pickle files, specifically, bbsdb.pkl
|
||||
bbsdb.pkl
|
||||
bbsdm.pkl
|
||||
|
||||
# virtualenv
|
||||
venv/
|
||||
# logs
|
||||
logs/*.log
|
||||
|
||||
# modified .service files
|
||||
etc/*.service
|
||||
|
||||
# fileMonitor test file
|
||||
bee.txt
|
||||
bible.txt
|
||||
|
||||
# data files
|
||||
data/*.json
|
||||
data/*.txt
|
||||
data/*.pkl
|
||||
data/*.csv
|
||||
data/*.db
|
||||
|
||||
# modules/custom_scheduler.py
|
||||
modules/custom_scheduler.py
|
||||
|
||||
# virtualenv
|
||||
venv/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
__pycache__/
|
||||
@@ -1 +1,2 @@
|
||||
currently operating under "Agile software development" aka rolling code; no major structure. meshing about .. get it..
|
||||
currently operating under "Agile software development" aka rolling code; no major structure. meshing about .. get it..
|
||||
there is some ideas for adding code in modules/README.md
|
||||
43
Dockerfile
Normal file
43
Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
||||
FROM python:3.14-slim
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
LANG=en_US.UTF-8 \
|
||||
LANGUAGE=en_US:en \
|
||||
LC_ALL=en_US.UTF-8 \
|
||||
TZ=America/Los_Angeles
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
build-essential \
|
||||
python3-dev \
|
||||
gettext \
|
||||
tzdata \
|
||||
locales \
|
||||
nano && \
|
||||
sed -i 's/^# *\(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen && \
|
||||
locale-gen en_US.UTF-8 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first for better caching
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
# Copy the rest of the application
|
||||
COPY . /app
|
||||
COPY config.template /app/config.ini
|
||||
|
||||
RUN chmod +x /app/script/docker/entrypoint.sh
|
||||
|
||||
# Add a non-root user and switch to it
|
||||
# RUN useradd -m appuser && usermod -a -G dialout appuser
|
||||
# USER appuser
|
||||
|
||||
# Expose Meshtastic TCP API port from the host
|
||||
#EXPOSE 4403
|
||||
# Meshing Around Web Dashboard port
|
||||
#EXPOSE 8420
|
||||
|
||||
ENTRYPOINT ["/bin/bash", "/app/script/docker/entrypoint.sh"]
|
||||
222
INSTALL.md
Normal file
222
INSTALL.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# INSTALL.md
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Manual Install](#manual-install)
|
||||
- [Docker Installation](#docker-installation)
|
||||
- [Requirements](#requirements)
|
||||
- [install.sh](#installsh)
|
||||
- [Purpose](#purpose)
|
||||
- [Usage](#usage)
|
||||
- [What it does](#what-it-does)
|
||||
- [When to use](#when-to-use)
|
||||
- [Note](#note)
|
||||
- [update.sh](#updatesh)
|
||||
- [Purpose](#purpose-1)
|
||||
- [Usage](#usage-1)
|
||||
- [What it does](#what-it-does-1)
|
||||
- [When to use](#when-to-use-1)
|
||||
- [Note](#note-1)
|
||||
- [launch.sh](#launchsh)
|
||||
- [Purpose](#purpose-2)
|
||||
- [How to Use](#how-to-use)
|
||||
- [What it does](#what-it-does-2)
|
||||
- [Note](#note-2)
|
||||
|
||||
---
|
||||
|
||||
## Manual Install
|
||||
|
||||
Install all required dependencies using pip:
|
||||
|
||||
```sh
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Copy the configuration template and edit as needed:
|
||||
|
||||
```sh
|
||||
cp config.template config.ini
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Installation
|
||||
|
||||
See [script/docker/README.md](script/docker/README.md) for Docker-based setup instructions.
|
||||
Docker is recommended for Windows or if you want an isolated environment.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Python 3.8 or later** (Python 3.13+ supported in Docker)
|
||||
- All dependencies are listed in `requirements.txt` and can be installed with:
|
||||
```sh
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
- To enable emoji in the Debian/Ubuntu console:
|
||||
```sh
|
||||
sudo apt-get install fonts-noto-color-emoji
|
||||
```
|
||||
- For Ollama LLM support, see the prompts during `install.sh` or visit [https://ollama.com](https://ollama.com).
|
||||
|
||||
---
|
||||
|
||||
## install.sh
|
||||
|
||||
### Purpose
|
||||
|
||||
`install.sh` automates installation, configuration, and service setup for the Meshing Around Bot project. It is designed for Linux systems (Debian/Ubuntu/Raspberry Pi and embedded devices).
|
||||
|
||||
### Usage
|
||||
|
||||
Run from the project root directory:
|
||||
|
||||
```sh
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
To uninstall:
|
||||
|
||||
```sh
|
||||
bash install.sh --nope
|
||||
```
|
||||
|
||||
### What it does
|
||||
|
||||
- Checks for existing installations and permissions.
|
||||
- Optionally moves the project to `/opt/meshing-around`.
|
||||
- Installs Python and pip if missing (unless on embedded systems).
|
||||
- Adds the current user (or a dedicated `meshbot` user) to necessary groups for serial and Bluetooth access.
|
||||
- Copies and configures systemd service files for running the bot as a service.
|
||||
- Sets up configuration files, updating latitude/longitude automatically.
|
||||
- Offers to create and activate a Python virtual environment, or install dependencies system-wide.
|
||||
- Installs optional components (emoji fonts, Ollama LLM) if desired.
|
||||
- Sets permissions for log and data directories.
|
||||
- Optionally installs and enables the bot as a systemd service.
|
||||
- Provides post-installation notes and commands in `install_notes.txt`.
|
||||
- Offers to reboot the system to complete setup.
|
||||
|
||||
### When to use
|
||||
|
||||
- For first-time installation of the Meshing Around Bot.
|
||||
- When migrating to a new device or environment.
|
||||
- After cloning or updating the repository to set up dependencies and services.
|
||||
|
||||
### Note
|
||||
|
||||
- You may be prompted for input during installation (e.g., for embedded mode, virtual environment, or optional features).
|
||||
- Review and edit the script if you have custom requirements or are running on a non-standard system.
|
||||
|
||||
---
|
||||
|
||||
## update.sh
|
||||
|
||||
### Purpose
|
||||
|
||||
`update.sh` is an update and maintenance script for the Meshing Around Bot project. It automates the process of safely updating your codebase, backing up data, and merging configuration changes.
|
||||
|
||||
### Usage
|
||||
|
||||
Run from the project root directory:
|
||||
|
||||
```sh
|
||||
bash update.sh
|
||||
```
|
||||
Or, after making it executable:
|
||||
```sh
|
||||
chmod +x update.sh
|
||||
./update.sh
|
||||
```
|
||||
|
||||
### What it does
|
||||
|
||||
- Stops running Mesh Bot services to prevent conflicts during update.
|
||||
- Fetches and pulls the latest changes from the GitHub repository (using `git pull --rebase`).
|
||||
- Handles git conflicts, offering to reset to the latest remote version if needed.
|
||||
- Copies a custom scheduler template if not already present.
|
||||
- Backs up the `data/` directory (and `custom_scheduler.py` if present) to a compressed archive.
|
||||
- Merges your existing configuration with new defaults using `script/configMerge.py`, and logs the process.
|
||||
- Restarts services if they were stopped for the update.
|
||||
- Provides status messages and logs for troubleshooting.
|
||||
|
||||
### When to use
|
||||
|
||||
- To update your Mesh Bot installation to the latest version.
|
||||
- Before making significant changes or troubleshooting, as it creates a backup of your data.
|
||||
|
||||
### Note
|
||||
|
||||
- Review `ini_merge_log.txt` and `config_new.ini` after running for any configuration changes or errors.
|
||||
- You may be prompted if git conflicts are detected.
|
||||
|
||||
---
|
||||
|
||||
## launch.sh
|
||||
|
||||
### Purpose
|
||||
|
||||
`launch.sh` is a convenience script for starting the Mesh Bot, Pong Bot, or generating reports within the Python virtual environment. It ensures the correct environment is activated and the appropriate script is run.
|
||||
|
||||
### How to Use
|
||||
|
||||
From your project root, run one of the following commands:
|
||||
|
||||
- Launch Mesh Bot:
|
||||
```sh
|
||||
bash launch.sh mesh
|
||||
```
|
||||
- Launch Pong Bot:
|
||||
```sh
|
||||
bash launch.sh pong
|
||||
```
|
||||
- Generate HTML report:
|
||||
```sh
|
||||
bash launch.sh html
|
||||
```
|
||||
- Generate HTML5 report:
|
||||
```sh
|
||||
bash launch.sh html5
|
||||
```
|
||||
- Add a favorite (calls `script/addFav.py`):
|
||||
```sh
|
||||
bash launch.sh add
|
||||
```
|
||||
|
||||
### What it does
|
||||
|
||||
- Ensures you are in the project directory.
|
||||
- Copies `config.template` to `config.ini` if no config exists.
|
||||
- Activates the Python virtual environment (`venv`).
|
||||
- Runs the selected Python script based on your argument.
|
||||
- Deactivates the virtual environment when done.
|
||||
|
||||
### Note
|
||||
|
||||
- The script requires a Python virtual environment (`venv`) to be present in the project directory.
|
||||
- If `venv` is missing, the script will exit with an error message.
|
||||
- Always provide an argument (`mesh`, `pong`, `html`, `html5`, or `add`) to specify what you want to launch.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Permissions Issues
|
||||
|
||||
If you encounter errors related to file or directory permissions (e.g., "Permission denied" or services failing to start):
|
||||
|
||||
- Ensure you are running installation scripts with sufficient privileges (use `sudo` if needed).
|
||||
- The `logs`, `data`, and `config.ini` files must be owned by the user running the bot (often `meshbot` or your current user).
|
||||
- You can manually reset permissions using the provided script:
|
||||
|
||||
```sh
|
||||
sudo bash etc/set-permissions.sh meshbot
|
||||
```
|
||||
|
||||
- If you moved the project directory, re-run the permissions script to update ownership.
|
||||
|
||||
- For systemd service issues, check logs with:
|
||||
```sh
|
||||
sudo journalctl -u mesh_bot.service
|
||||
```
|
||||
|
||||
If problems persist, double-check that the user specified in your service files matches the owner of the project files and directories.
|
||||
283
README.md
283
README.md
@@ -1,147 +1,188 @@
|
||||
# meshing-around
|
||||
Random Mesh Scripts for Network Testing and BBS Activities for Use with Meshtastic Nodes
|
||||
# Mesh Bot for Network Testing and BBS Activities
|
||||
|
||||

|
||||
Mesh Bot is a feature-rich Python bot designed to enhance your [Meshtastic](https://meshtastic.org/docs/introduction/) network experience. It provides powerful tools for network testing, messaging, games, and more—all via text-based message delivery. Whether you want to test your mesh, send messages, or play games, [mesh_bot.py](mesh_bot.py) has you covered.
|
||||
|
||||
## mesh_bot.sh
|
||||
The feature-rich bot requires the internet for full functionality. These responder bots will trap keywords like ping and respond to a DM (direct message) with pong! The script will also monitor the group channels for keywords to trap. You can also `Ping @Data to Echo` as an example for further processing.
|
||||
* [Getting Started](#getting-started)
|
||||
|
||||
Along with network testing, this bot has a lot of other features, like simple mail messaging you can leave for another device, and when that device is seen, it can send the mail as a DM.
|
||||

|
||||
|
||||
The bot is also capable of using dual radio/nodes, so you can monitor two networks at the same time and send messages to nodes using the same `bbspost @nodeNumber #message` function. There is a small message board to fit in the constraints of Meshtastic for posting bulletin messages with `bbspost $subject #message`.
|
||||
#### TLDR
|
||||
* [install.sh](INSTALL.md)
|
||||
* [Configuration Guide](modules/README.md)
|
||||
* [Games Help](modules/games/README.md)
|
||||
|
||||
Store and forward-like message re-play with `messages`, and there is a repeater module for dual radio bots to cross post messages. Messages are also logged locally to disk.
|
||||
## Key Features
|
||||

|
||||
|
||||
The bot can also be used to monitor a frequency and let you know when activity is seen. Using Hamlib to watch the S meter on a connected radio. You can send alerts to channels when a frequency is detected for 20 seconds within the thresholds set in config.ini
|
||||
### Intelligent Keyword Responder
|
||||
- **Automated Responses**: Detects keywords like "ping" and replies with "pong" in direct messages (DMs) or group channels.
|
||||
- **Customizable Triggers**: Monitors group channels for specific keywords and sends custom responses.
|
||||
- **Emergency Detection**: Watches for emergency-related keywords and alerts a wider audience.
|
||||
- **New Node Greetings**: Automatically welcomes new nodes joining the mesh.
|
||||
|
||||
Any messages that are over 160 characters are chunked into 160 message bytes to help traverse hops, in testing, this keeps delivery success higher.
|
||||
### Network Tools
|
||||
- **Mesh Testing**: Use `ping` to test message delivery with realistic packets.
|
||||
- **Hardware Testing**: The `test` command sends incrementally sized data to test radio buffer limits.
|
||||
- **Network Monitoring**: Alerts for noisy nodes, tracks node locations, and suggests optimal relay placement.
|
||||
|
||||
- Various solar details for radio propagation
|
||||
- `sun` and `moon` return info on rise and set local time
|
||||
- `solar` gives an idea of the x-ray flux
|
||||
- `hfcond` returns a table of HF solar conditions
|
||||
- Bulletin Board (BBS) functions
|
||||
- `bbshelp` returns the following
|
||||
- `bbslist` list the messages by ID and subject
|
||||
- `bbsread` read a message example use: `bbsread #1`
|
||||
- `bbspost` post a message to public board or send a DM example use: `bbspost $subject #message, or bbspost @nodeNumber #message`
|
||||
- `bbsdelete` delete a message example use: `bbsdelete #4`
|
||||
- Other functions
|
||||
- `whereami` returns the address of location of sender if known
|
||||
- `tide` returns the local tides, NOAA data source
|
||||
- `wx` and `wxc` returns local weather forecast, NOAA data source (wxc is metric value)
|
||||
- `wxa` and `wxalert` return NOAA alerts. Short title or expanded details
|
||||
- `joke` tells a joke
|
||||
- `messages` Replay the last messages heard, like Store and Forward
|
||||
- `motd` or to set the message `motd $New Message Of the day`
|
||||
- `lheard` returns the last 5 heard nodes with SNR, can also use `sitrep`
|
||||
- `cmd` returns the list of commands (the help message)
|
||||
- **Site Survey & Location Logging**: Use the `map` command to log your current GPS location with a custom description—ideal for site surveys, asset tracking, or mapping nodes locations. Entries are saved to a CSV file for later analysis or visualization.
|
||||
|
||||
## pong_bot.sh
|
||||
Stripped-down bot, mostly around for archive purposes. The mesh-bot enhanced modules can be disabled by config to disable features.
|
||||
### Multi-Radio/Node Support
|
||||
- **Simultaneous Monitoring**: Observe up to nine networks at once.
|
||||
- **Flexible Messaging**: Send mail and messages between networks.
|
||||
|
||||
## Hardware
|
||||
The project is written on Linux on a Pi and should work anywhere meshtastic Python modules will function, with any supported meshtastic hardware. While BLE and TCP will work, they are not as reliable as serial connections.
|
||||
- Firmware 2.3.14/15 could also have an issue with connectivity with slower devices.
|
||||
### Advanced Messaging Capabilities
|
||||
- **Mail Messaging**: Leave messages for other devices; delivered as DMs when the device is next seen. Use `bbspost @nodeNumber #message` or `bbspost @nodeShortName #message`.
|
||||
- **Message Scheduler**: Automate messages such as weather updates or net reminders.
|
||||
- **Store and Forward**: Retrieve missed messages with the `messages` command; optionally log messages to disk.
|
||||
- **BBS Linking**: Connect multiple bots to expand BBS coverage.
|
||||
- **E-Mail/SMS Integration**: Send mesh messages to email or SMS for broader reach.
|
||||
- **New Node Greetings**: Automatically greet new nodes via text.
|
||||
|
||||
## Install
|
||||
Clone the project with `git clone https://github.com/spudgunman/meshing-around`
|
||||
code is under a lot of development, so check back often with `git pull`
|
||||
Copy [config.template](config.template) to `config.ini` and edit for your needs.
|
||||
- Optionally
|
||||
- `install.sh` will automate optional venv and requirements installation.
|
||||
- `launch.sh` will activate and launch the app in the venv if built.
|
||||
### Interactive AI and Data Lookup
|
||||
- **Weather, Earthquake, River, and Tide Data**: Get local alerts and info from NOAA/USGS; uses Open-Meteo for areas outside NOAA coverage.
|
||||
- **Wikipedia Search**: Retrieve summaries from Wikipedia and Kiwix
|
||||
- **OpenWebUI, Ollama LLM Integration**: Query the [Ollama](https://github.com/ollama/ollama/tree/main/docs) AI for advanced responses. Supports RAG (Retrieval Augmented Generation) with Wikipedia/Kiwix context and [OpenWebUI](https://github.com/open-webui/open-webui) integration for enhanced AI capabilities. [LLM Readme](modules/llm.md)
|
||||
- **Satellite Passes**: Find upcoming satellite passes for your location.
|
||||
- **GeoMeasuring Tools**: Calculate distances and midpoints using collected GPS data; supports Fox & Hound direction finding.
|
||||
- **RSS & News Feeds**: Receive news and data from multiple sources directly on the mesh.
|
||||
|
||||
### Configurations
|
||||
Copy the [config.template](config.template) to `config.ini` and set the appropriate interface for your method (serial/ble/tcp). While BLE and TCP will work, they are not as reliable as serial connections. There is a watchdog to reconnect tcp if possible.
|
||||
### Proximity Alerts
|
||||
- **Location-Based Alerts**: Get notified when members arrive at a configured latitude/longitude—ideal for campsites, geo-fences, or remote locations. Optionally, trigger scripts, send emails, or automate actions (e.g., change node config, turn on lights, or drop an `alert.txt` file to start a survey or game).
|
||||
- **Customizable Triggers**: Use proximity events for creative applications like "king of the hill" or 🧭 geocache games by adjusting the alert cycle.
|
||||
- **High Flying Alerts**: Receive notifications when nodes with high altitude are detected on the mesh.
|
||||
- **Voice/Command Triggers**: Activate bot functions using keywords or voice commands (see [Voice Commands](#voice-commands-vox) for "Hey Chirpy!" support).
|
||||
- **YOLOv5 alerts**: Use camera modules to detect objects or OCR
|
||||
|
||||
### EAS Alerts
|
||||
- **FEMA iPAWS/EAS Alerts**: Receive Emergency Alerts from FEMA via API on internet-connected nodes.
|
||||
- **NOAA EAS Alerts**: Get Emergency Alerts from NOAA via API.
|
||||
- **USGS Volcano Alerts**: Receive volcano alerts from USGS via API.
|
||||
- **NINA Alerts (Germany)**: Receive emergency alerts from the xrepository.de feed for Germany.
|
||||
- **Offline EAS Alerts**: Report EAS alerts over the mesh using external tools, even without internet.
|
||||
|
||||
### File Monitor Alerts
|
||||
- **File Monitoring**: Watch a text file for changes and broadcast updates to the mesh channel.
|
||||
- **News File Access**: Retrieve the contents of a news file on request; supports multiple news sources or files.
|
||||
- **Shell Command Access**: Execute shell commands via DM with replay protection (admin only).
|
||||
|
||||
#### Radio Frequency Monitoring
|
||||
- **SNR RF Activity Alerts**: Monitor radio frequencies and receive alerts when high SNR (Signal-to-Noise Ratio) activity is detected.
|
||||
- **Hamlib Integration**: Use Hamlib (rigctld) to monitor the S meter on a connected radio.
|
||||
- **Speech-to-Text Broadcasting**: Convert received audio to text using [Vosk](https://alphacephei.com/vosk/models) and broadcast it to the mesh.
|
||||
- **WSJT-X Integration**: Monitor WSJT-X (FT8, FT4, WSPR, etc.) decode messages and forward them to the mesh network with optional callsign filtering.
|
||||
- **JS8Call Integration**: Monitor JS8Call messages and forward them to the mesh network with optional callsign filtering.
|
||||
- **Meshages TTS**: The bot can speak mesh messages aloud using [KittenTTS](https://github.com/KittenML/KittenTTS). Enable this feature to have important alerts and messages read out loud on your device—ideal for hands-free operation or accessibility. See [radio.md](modules/radio.md) for setup instructions.
|
||||
- **Offline Tone out Decoder**: Decode fire Tone out and DTMF and action with alerts to mesh
|
||||
|
||||
### Asset Tracking, Check-In/Check-Out, and Inventory Management
|
||||
Advanced check-in/check-out and asset tracking for people and equipment—ideal for accountability, safety monitoring, and logistics (e.g., Radio-Net, FEMA, trailhead groups). Admin approval workflows, GPS location capture, and overdue alerts. The integrated inventory and point-of-sale (POS) system enables item management, sales tracking, cart-based transactions, and daily reporting, for swaps, emergency supply management, and field operations, maker-places.
|
||||
|
||||
### Fun and Games
|
||||
- **Built-in Games**: Play classic games like DopeWars, Lemonade Stand, BlackJack, and Video Poker directly via DM.
|
||||
- **FCC ARRL QuizBot**: Practice for the ham radio exam with the integrated quiz bot.
|
||||
- **Command-Based Gameplay**: Use the `games` command to view available games and start playing.
|
||||
- **Telemetry Leaderboard**: Compete for fun stats like lowest battery or coldest temperature.
|
||||
|
||||
#### QuizMaster
|
||||
- **Group Quizzes**: Admins can start and stop quiz games for groups.
|
||||
- **Player Participation**: Players join with `q: join`, leave with `q: leave`, and answer questions by prefixing their answer with `q:`, e.g., `q: 42`.
|
||||
- **Scoring & Leaderboards**: Check your score with `q: score` and see the top performers with `q: top`.
|
||||
- **Admin Controls**: QuizMasters (from `bbs_admin_list`) can use `q: start`, `q: stop`, and `q: broadcast <message>` to manage games.
|
||||
|
||||
#### Survey Module
|
||||
- **Custom Surveys**: Create and manage surveys by editing JSON files in `data/survey`. Multiple surveys are supported (e.g., `survey snow`).
|
||||
- **User Feedback**: Users participate via DM; responses are logged for review.
|
||||
- **Reporting**: Retrieve survey results with `survey report` or `survey report <surveyname>`.
|
||||
|
||||
### Data Reporting
|
||||
- **HTML Reports**: Visualize bot traffic and data flows with a built-in HTML generator. See [data reporting](logs/README.md) for details.
|
||||
|
||||
### Robust Message Handling
|
||||
- **Automatic Message Chunking**: Messages over 160 characters are automatically split to ensure reliable delivery across multiple hops.
|
||||
|
||||
## Getting Started
|
||||
This project is developed on Linux (specifically a Raspberry Pi) but should work on any platform where the [Meshtastic protobuf API](https://meshtastic.org/docs/software/python/cli/) modules are supported, and with any compatible [Meshtastic](https://meshtastic.org/docs/getting-started/) hardware, however it is **recomended to use the latest firmware code**. For low-powered devices [mPWRD-OS](https://github.com/SpudGunMan/mPWRD-OS) for running on luckfox hardware. If you need a local console consider the [firefly](https://github.com/pdxlocations/firefly) project.
|
||||
|
||||
🥔 Please use responsibly and follow local rulings for such equipment. This project captures packets, logs them, and handles over the air communications which can include PII such as GPS locations.
|
||||
|
||||
### Quick Setup
|
||||
#### Clone the Repository
|
||||
If you dont have git you will need it `sudo apt-get install git`
|
||||
```sh
|
||||
git clone https://github.com/spudgunman/meshing-around
|
||||
```
|
||||
#config.ini
|
||||
# type can be serial, tcp, or ble.
|
||||
# port is the serial port to use; commented out will try to auto-detect
|
||||
# hostname is the IP address of the device to connect to for TCP type
|
||||
# mac is the MAC address of the device to connect to for ble type
|
||||
- **Automated Installation**: [install.sh](INSTALL.md) will automate optional venv and requirements installation.
|
||||
- **Launch Script**: [laynch.sh](INSTALL.md) only used in a venv install, to launch the bot and the report generator.
|
||||
|
||||
[interface]
|
||||
type = serial
|
||||
# port = '/dev/ttyUSB0'
|
||||
# hostname = 192.168.0.1
|
||||
# mac = 00:11:22:33:44:55
|
||||
### Docker Installation
|
||||
Good for windows or OpenWebUI enabled bots
|
||||
|
||||
# Additional interface for dual radio support See config.template for more.
|
||||
[interface2]
|
||||
enabled = False
|
||||
```
|
||||
The following pair of settings determine how to respond: The default action is to not spam the default channel. Setting'respond_by_DM_only'` will force all messages to be sent to DM, which may not be wanted. Setting the value to False will allow responses in the channel for all to see.
|
||||
[docker.md](script/docker/README.md)
|
||||
|
||||
Setting the default channel is the channel that won't be spammed by the bot. It's the public default channel 0 on the new Meshtastic firmware. Anti-Spam is hard-coded into the responder to prevent abuse of the public channel.
|
||||
```
|
||||
## Module Help
|
||||
Configuration Guide
|
||||
[modules/README.md](modules/README.md)
|
||||
|
||||
### Game Help
|
||||
Games are DM only by default
|
||||
|
||||
[modules/games/README.md](modules/games/README.md)
|
||||
|
||||
### Firmware 2.6 DM Key, and 2.7 CLIENT_BASE Favorite Nodes
|
||||
Firmware 2.6 introduced [PKC](https://meshtastic.org/blog/introducing-new-public-key-cryptography-in-v2_5/), enabling secure private messaging by adding necessary keys to each node. To fully utilize this feature, you should add favorite nodes—such as BBS admins—to your node’s favorites list to ensure their keys are retained. A helper script is provided to simplify this process:
|
||||
- Run the helper script from the main program directory: `python3 script/addFav.py`
|
||||
- By default, this script adds nodes from `bbs_admin_list` and `bbslink_whitelist`
|
||||
- If using a virtual environment, run: `launch.sh addfav`
|
||||
- The API will not work-fully today to set nodes this is a WIP
|
||||
|
||||
Additionally, you can just DM a bot to "auto favorite." If your node is set to not be messageable, DMs won't work—be advised.
|
||||
|
||||
To configure favorite nodes, add their numbers to your config file:
|
||||
```conf
|
||||
[general]
|
||||
respond_by_dm_only = True
|
||||
defaultChannel = 0
|
||||
favoriteNodeList = # list of favorite nodes numbers ex: 2813308004,4258675309 used by script/addFav.py
|
||||
```
|
||||
|
||||
Modules can be disabled or enabled.
|
||||
```
|
||||
[bbs]
|
||||
enabled = False
|
||||
### MQTT Notes
|
||||
There is no direct support for MQTT in the code, however, reports from Discord are that using [meshtasticd](https://meshtastic.org/docs/hardware/devices/linux-native-hardware/) with no radio and attaching the bot to the software node, which is MQTT-linked, allows routing. Tested working fully Firmware:2.6.11 with [mosquitto](https://meshtastic.org/docs/software/integrations/mqtt/mosquitto/).
|
||||
|
||||
[general]
|
||||
DadJokes = False
|
||||
StoreForward = False
|
||||
```
|
||||
The BBS has admin and block lists; see the [config.template](config.template)
|
||||
|
||||
A repeater function for two different nodes and cross-posting messages. The'repeater_channels` is a list of repeater channel(s) that will be consumed and rebroadcast on the same number channel on the other device, node, or interface. Each node should have matching channel numbers. The channel names and PSK do not need to be the same on the nodes. With great power comes great responsibility; danger could lurk in the use of this feature! If you have the two nodes in the same radio configuration, you could create a feedback loop!!!
|
||||
|
||||
```
|
||||
# repeater module
|
||||
[repeater]
|
||||
enabled = True
|
||||
repeater_channels = [2, 3]
|
||||
```
|
||||
A module allowing a Hamlib compatible radio to connect to the bot, when functioning it will message the channel configured with a message of in use. **Requires hamlib/rigctld to be running as a service.**
|
||||
|
||||
```
|
||||
[radioMon]
|
||||
enabled = False
|
||||
rigControlServerAddress = localhost:4532
|
||||
# channel to brodcast to can be 2,3
|
||||
sigWatchBrodcastCh = 2
|
||||
# minimum SNR as reported by radio via hamlib
|
||||
signalDetectionThreshold = -10
|
||||
# hold time for high SNR
|
||||
signalHoldTime = 10
|
||||
# the following are combined to reset the monitor
|
||||
signalCooldown = 5
|
||||
signalCycleLimit = 5
|
||||
```
|
||||
# requirements
|
||||
can also be installed with `pip install -r requirements.txt`
|
||||
|
||||
```
|
||||
pip install meshtastic
|
||||
pip install pubsub
|
||||
```
|
||||
mesh-bot enhancements
|
||||
|
||||
```
|
||||
pip install pyephem
|
||||
pip install requests
|
||||
pip install geopy
|
||||
pip install maidenhead
|
||||
pip install beautifulsoup4
|
||||
pip install dadjokes
|
||||
```
|
||||
|
||||
To enable emoji in the Debian console, install the fonts `sudo apt-get install fonts-noto-color-emoji`
|
||||
~~There also seems to be a quicker way to enable MQTT by having your bot node with the enabled [serial](https://meshtastic.org/docs/configuration/module/serial/) module with echo enabled and MQTT uplink and downlink. These two~~
|
||||
|
||||
# Recognition
|
||||
|
||||
I used ideas and snippets from other responder bots and want to call them out!
|
||||
- https://github.com/Murturtle/MeshLink
|
||||
- https://github.com/pdxlocations/Meshtastic-Python-Examples
|
||||
- https://github.com/geoffwhittington/meshtastic-matrix-relay
|
||||
|
||||
GitHub user PiDiBi looking at test functions and other suggestions like wxc, CPU use, and alerting ideas
|
||||
Discord and Mesh user Cisien, and github Hailo1999, for testing and ideas!
|
||||
### Inspiration and Code Snippets
|
||||
- [MeshLink](https://github.com/Murturtle/MeshLink)
|
||||
- [Meshtastic Python Examples](https://github.com/pdxlocations/meshtastic-Python-Examples)
|
||||
- [Meshtastic Matrix Relay](https://github.com/geoffwhittington/meshtastic-matrix-relay)
|
||||
|
||||
### Games Ported From
|
||||
- [Lemonade Stand](https://github.com/tigerpointe/Lemonade-Stand/)
|
||||
- [Drug Wars](https://github.com/Reconfirefly/drugwars)
|
||||
- [BlackJack](https://github.com/Himan10/BlackJack)
|
||||
- [Video Poker Terminal Game](https://github.com/devtronvarma/Video-Poker-Terminal-Game)
|
||||
- [Python Mastermind](https://github.com/pwdkramer/pythonMastermind/)
|
||||
- [Golf](https://github.com/danfriedman30/pythongame)
|
||||
- ARRL Question Pool Data from https://github.com/russolsen/ham_radio_question_pool
|
||||
|
||||
### Special Thanks
|
||||
For testing and feature ideas on Discord and GitHub, if its stable its thanks to you all.
|
||||
- **PiDiBi, Cisien, bitflip, nagu, Nestpebble, NomDeTom, Iris, Josh, GlockTuber, FJRPiolt, dj505, Woof, propstg, snydermesh, trs2982, F0X, Malice, mesb1, Hailo1999**
|
||||
- **xdep**: For the reporting html. 📊
|
||||
- **mrpatrick1991**: For OG Docker configurations. 💻
|
||||
- **A-c0rN**: Assistance with iPAWS and 🚨
|
||||
- **Mike O'Connell/skrrt**: For [eas_alert_parser](etc/eas_alert_parser.py) enhanced by **sheer.cold**
|
||||
- **dadud**: For idea on [etc/icad_tone.py](etc/icad_tone.py)
|
||||
- **WH6GXZ nurse dude**: Volcano Alerts 🌋
|
||||
- **mikecarper**: hamtest, leading to quiz etc.. 📋
|
||||
- **c.merphy360**: high altitude alerts. 🚀
|
||||
- **G7KSE**: DX Spotting idea. 📻
|
||||
- **Growing List of GitHub Contributers**
|
||||
- **Meshtastic Discord Community**: For putting up with 🥔
|
||||
|
||||
### Tools
|
||||
- **Node Backup Management**: [Node Slurper](https://github.com/SpudGunMan/node-slurper)
|
||||
|
||||
Meshtastic® is a registered trademark of Meshtastic LLC. Meshtastic software components are released under various licenses, see GitHub for details. No warranty is provided - use at your own risk.
|
||||
|
||||
14
SECURITY.md
Normal file
14
SECURITY.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Use this section to tell people about which versions of your project are
|
||||
currently being supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| git pull| :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If its serious, its likley big. otherwise post issues, reachout on discord.
|
||||
29
bootstrap.sh
Executable file
29
bootstrap.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
cd "$BASE_DIR"
|
||||
|
||||
if [[ ! -d "$BASE_DIR/venv" ]]; then
|
||||
python3 -m venv "$BASE_DIR/venv"
|
||||
fi
|
||||
|
||||
source "$BASE_DIR/venv/bin/activate"
|
||||
"$BASE_DIR/venv/bin/pip" install -r "$BASE_DIR/requirements.txt"
|
||||
|
||||
mkdir -p "$BASE_DIR/data"
|
||||
cp -Rn "$BASE_DIR/etc/data/." "$BASE_DIR/data/"
|
||||
|
||||
if [[ ! -f "$BASE_DIR/config.ini" ]]; then
|
||||
cp "$BASE_DIR/config.template" "$BASE_DIR/config.ini"
|
||||
sleep 1
|
||||
replace="s|type = serial|type = tcp|g"
|
||||
sed -i.bak "$replace" "$BASE_DIR/config.ini"
|
||||
replace="s|# hostname = meshtastic.local|hostname = localhost|g"
|
||||
sed -i.bak "$replace" "$BASE_DIR/config.ini"
|
||||
rm -f "$BASE_DIR/config.ini.bak"
|
||||
else
|
||||
echo "config.ini already exists, leaving it unchanged."
|
||||
fi
|
||||
|
||||
deactivate
|
||||
71
compose.yaml
Normal file
71
compose.yaml
Normal file
@@ -0,0 +1,71 @@
|
||||
services:
|
||||
meshing-around:
|
||||
ports:
|
||||
- 8420:8420
|
||||
#devices:
|
||||
#- /dev/ttyUSB0:/dev/tty #update your config.ini to /dev/tty
|
||||
#- /dev/ttyACM0:/dev/tty #if using serial select proper port
|
||||
volumes:
|
||||
- .:/app:rw
|
||||
image: ghcr.io/spudgunman/meshing-around:main
|
||||
container_name: meshing-around
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- OLLAMA_API_URL=http://ollama:11434
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
#user: "1000:1000"
|
||||
#user: "10999:10999"
|
||||
networks:
|
||||
- meshing-around-network
|
||||
|
||||
test-bot:
|
||||
image: ghcr.io/spudgunman/meshing-around:main
|
||||
container_name: test-bot
|
||||
command: ["/bin/bash", "-c", "python3 modules/test_bot.py | tee /tmp/test_tmp.txt; if grep -E 'failures=|errors=' /tmp/test_tmp.txt; then cp /tmp/test_tmp.txt /app/test_results.txt; fi"]
|
||||
volumes:
|
||||
- .:/app:rw
|
||||
networks:
|
||||
- meshing-around-network
|
||||
stdin_open: true
|
||||
|
||||
debug-console:
|
||||
image: ghcr.io/spudgunman/meshing-around:main
|
||||
container_name: debug-console
|
||||
command: ["/bin/bash"]
|
||||
stdin_open: true
|
||||
tty: true
|
||||
volumes:
|
||||
- .:/app:rw
|
||||
networks:
|
||||
- meshing-around-network
|
||||
|
||||
meshtasticd:
|
||||
ports:
|
||||
- 4403:4403
|
||||
- 443:443
|
||||
volumes:
|
||||
- ./script/docker:/etc/meshtasticd:rw
|
||||
restart: unless-stopped
|
||||
container_name: meshtasticd
|
||||
image: meshtastic/meshtasticd:daily-debian
|
||||
networks:
|
||||
- meshing-around-network
|
||||
|
||||
ollama:
|
||||
ports:
|
||||
- 11434:11434
|
||||
container_name: ollama
|
||||
image: ollama/ollama:latest
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
networks:
|
||||
- meshing-around-network
|
||||
|
||||
networks:
|
||||
meshing-around-network:
|
||||
external: true
|
||||
458
config.template
458
config.template
@@ -1,46 +1,184 @@
|
||||
#config.ini
|
||||
# type can be serial, tcp, or ble
|
||||
# port is the serial port to use, commented out will try to auto-detect
|
||||
# hostname is the IP address of the device to connect to for tcp type
|
||||
# hostname is the IP/DNS and port for tcp type default is host:4403
|
||||
# mac is the MAC address of the device to connect to for ble type
|
||||
|
||||
[interface]
|
||||
type = serial
|
||||
port = /dev/ttyACM0
|
||||
# port = /dev/ttyUSB0
|
||||
# port = COM1
|
||||
# hostname = 192.168.0.1
|
||||
# hostname = meshtastic.local
|
||||
# mac = 00:11:22:33:44:55
|
||||
|
||||
# Additional interface for dual radio support
|
||||
# Additional interface for multi radio support
|
||||
[interface2]
|
||||
enabled = False
|
||||
type = serial
|
||||
port = /dev/ttyUSB0
|
||||
#port = /dev/ttyACM1
|
||||
# port = /dev/ttyACM1
|
||||
# port = COM1
|
||||
# hostname = meshtastic.local
|
||||
# hostname = localhost
|
||||
# mac = 00:11:22:33:44:55
|
||||
|
||||
# example, the third interface would be [interface3] up to 9
|
||||
|
||||
[general]
|
||||
# if False will respond on all channels but the default channel
|
||||
respond_by_dm_only = True
|
||||
# defaultChannel is the meshtastic default public channel
|
||||
# Allows auto-ping feature in a channel, False forces to 1 ping only
|
||||
autoPingInChannel = False
|
||||
# defaultChannel is the meshtastic default public channel, e.g. LongFast (if none use -1)
|
||||
defaultChannel = 0
|
||||
# ignoreDefaultChannel, the bot will ignore the default channel set above
|
||||
ignoreDefaultChannel = False
|
||||
# ignoreChannels is a comma separated list of channels to ignore, e.g. 4,5
|
||||
ignoreChannels =
|
||||
# require ! to be the first character in a command
|
||||
cmdBang = False
|
||||
# require explicit command, the message will only be processed if it starts with a command word
|
||||
explicitCmd = True
|
||||
# list of favorite nodes numbers ex: 2813308004,4258675309 used by script/addFav.py
|
||||
favoriteNodeList =
|
||||
|
||||
# motd is reset to this value on boot
|
||||
motd = Thanks for using MeshBOT! Have a good day!
|
||||
welcome_message = MeshBot, here for you like a friend who is not. Try sending: ping @foo or, cmd
|
||||
|
||||
# whoami
|
||||
whoami = True
|
||||
|
||||
# enable or disable the Joke module
|
||||
DadJokes = True
|
||||
DadJokesEmoji = False
|
||||
|
||||
# enable or disable the Solar module
|
||||
spaceWeather = True
|
||||
|
||||
# enable or disable the RSS module, and truncate the story
|
||||
rssEnable = True
|
||||
rssFeedURL = http://www.hackaday.com/rss.xml,http://rss.slashdot.org/Slashdot/slashdotMain,http://www.reddit.com/r/meshtastic/.rss
|
||||
# RSS feed names must match the order of the URLs above, default is used if no match
|
||||
rssFeedNames = default,slashdot,mesh
|
||||
rssMaxItems = 3
|
||||
rssTruncate = 100
|
||||
# enable or disable the 'latest' command which uses NewsAPI.org key at https://newsapi.org/register
|
||||
enableNewsAPI = False
|
||||
newsAPI_KEY =
|
||||
newsAPIregion = us
|
||||
# could also be 'relevancy' or 'popularity' or 'publishedAt'
|
||||
sort_by = relevancy
|
||||
|
||||
# enable or disable the wikipedia search module
|
||||
wikipedia = True
|
||||
# Use local Kiwix server instead of online Wikipedia
|
||||
# Set to False to use online Wikipedia, or provide Kiwix server URL
|
||||
useKiwixServer = False
|
||||
# Kiwix server URL (e.g., http://127.0.0.1:8080)
|
||||
kiwixURL = http://127.0.0.1:8080
|
||||
# Kiwix library name (e.g., wikipedia_en_100_nopic_2025-09)
|
||||
kiwixLibraryName = wikipedia_en_100_nopic_2025-09
|
||||
|
||||
# Enable LLM local Ollama integration, set true for any LLM support
|
||||
ollama = False
|
||||
# Ollama server instance to use (defaults to local machine install)
|
||||
ollamaHostName = http://localhost:11434
|
||||
# Use OpenWebUI instead of direct Ollama API / still leave ollama = True
|
||||
useOpenWebUI = False
|
||||
openWebUIURL = http://localhost:3000
|
||||
# OpenWebUI API key/token (required when useOpenWebUI is True)
|
||||
openWebUIAPIKey =
|
||||
|
||||
# Ollama model to use (defaults to gemma3:270m) gemma2 is good for older SYSTEM prompt
|
||||
# ollamaModel is used for both Ollama and OpenWebUI when useOpenWebUI its just the model name
|
||||
# ollamaModel = gemma3:latest
|
||||
# ollamaModel = gemma2:2b
|
||||
# if True, the query is sent raw to the LLM, if False uses internal SYSTEM prompt
|
||||
rawLLMQuery = True
|
||||
|
||||
# If False, the LLM only replies to the "ask:" and "askai" commands. otherwise DM's automatically go to LLM
|
||||
llmReplyToNonCommands = True
|
||||
# Enable Wikipedia/Kiwix integration with LLM for RAG (Retrieval Augmented Generation)
|
||||
# When enabled, LLM will automatically search Wikipedia/Kiwix and include context in responses
|
||||
llmUseWikiContext = False
|
||||
|
||||
# StoreForward Enabled and Limits
|
||||
StoreForward = True
|
||||
StoreLimit = 3
|
||||
reverseSF = False
|
||||
|
||||
# history command
|
||||
enableCmdHistory = True
|
||||
# command history ignore list ex: 2813308004,4258675309
|
||||
lheardCmdIgnoreNodes =
|
||||
|
||||
# 24 hour clock
|
||||
zuluTime = True
|
||||
zuluTime = False
|
||||
# wait time for URL requests
|
||||
URL_TIMEOUT = 10
|
||||
urlTimeout = 15
|
||||
|
||||
# logging to file of the non Bot messages
|
||||
LogMessagesToFile = False
|
||||
# Logging of system messages to file
|
||||
SyslogToFile = True
|
||||
# logging level for the bot (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
sysloglevel = DEBUG
|
||||
# Number of log files to keep in days, 0 to keep all
|
||||
log_backup_count = 32
|
||||
|
||||
#Do not retry enabling interface if it fails, just exit to let OS restart the bot
|
||||
dont_retry_disconnect = False
|
||||
|
||||
#echo command, will echo back your message as the bot
|
||||
enableEcho = False
|
||||
# command will only echo 1:1 if sent on this channel, otherwise it will prepend @yourname
|
||||
echoChannel = 9
|
||||
|
||||
[emergencyHandler]
|
||||
# enable or disable the emergency response handler
|
||||
enabled = False
|
||||
# channel to send a message to when the emergency handler is triggered
|
||||
alert_channel = 2
|
||||
alert_interface = 1
|
||||
|
||||
[sentry]
|
||||
# detect anyone close to the bot
|
||||
SentryEnabled = True
|
||||
# device interface and channel to send the alert message to
|
||||
SentryInterface = 1
|
||||
SentryChannel = 2
|
||||
emailSentryAlerts = False
|
||||
# Enable detection sensor alert, requires external GPIO sensor connected to node
|
||||
detectionSensorAlert = False
|
||||
|
||||
# list of ignored nodes numbers ex: 2813308004,4258675309
|
||||
sentryIgnoreList =
|
||||
# list of watched nodes numbers ex: 2813308004,4258675309
|
||||
sentryWatchList =
|
||||
|
||||
# radius in meters to detect someone close to the bot
|
||||
SentryRadius = 100
|
||||
# holdoff time multiplied by seconds(20) of the watchdog
|
||||
SentryHoldoff = 9
|
||||
|
||||
# Enable running external shell command when sentry alert is triggered
|
||||
cmdShellSentryAlerts = False
|
||||
# External shell command to run when sentry alert is triggered
|
||||
sentryAlertNear = sentry_alert_near.sh
|
||||
sentryAlertAway = sentry_alert_away.sh
|
||||
|
||||
# HighFlying Node alert
|
||||
highFlyingAlert = True
|
||||
# Altitude in meters to trigger the alert
|
||||
highFlyingAlertAltitude = 2000
|
||||
# check with OpenSkyNetwork if highfly detected for aircraft
|
||||
highflyOpenskynetwork = True
|
||||
# Channel to send Alert when the high flying node is detected
|
||||
highFlyingAlertInterface = 1
|
||||
# to disable OTA alert set to unused channel like 9
|
||||
highFlyingAlertChannel = 2
|
||||
# list of nodes numbers to ignore high flying alert ex: 2813308004,4258675309
|
||||
highFlyingIgnoreList =
|
||||
|
||||
[bbs]
|
||||
enabled = True
|
||||
@@ -48,20 +186,120 @@ enabled = True
|
||||
bbs_ban_list =
|
||||
# list of admin nodes numbers ex: 2813308004,4258675309
|
||||
bbs_admin_list =
|
||||
# enable bbs synchronization with other nodes
|
||||
bbslink_enabled = False
|
||||
# list of whitelisted nodes numbers ex: 2813308004,4258675309 empty list allows all
|
||||
bbslink_whitelist =
|
||||
# enable API script access (increases disk i/o)
|
||||
bbsAPI_enabled = False
|
||||
|
||||
# location module
|
||||
[location]
|
||||
enabled = True
|
||||
lat = 48.50
|
||||
lon = -123.0
|
||||
# weather forecast days, the first two rows are today and tonight
|
||||
DAYS_OF_WEATHER = 4
|
||||
# number of weather alerts to display
|
||||
ALERT_COUNT = 2
|
||||
fuzzConfigLocation = True
|
||||
fuzzItAll = False
|
||||
# database file for saved locations
|
||||
locations_db = data/locations.db
|
||||
# if True, only administrators can save public locations
|
||||
public_location_admin_manage = False
|
||||
# if True, only administrators can delete locations
|
||||
delete_public_locations_admins_only = False
|
||||
|
||||
# solar module
|
||||
[solar]
|
||||
enabled = True
|
||||
# Default to metric units rather than imperial
|
||||
useMetric = False
|
||||
|
||||
# repeaterList lookup location (rbook / artsci / False)
|
||||
repeaterLookup = rbook
|
||||
|
||||
# Satalite Pass Prediction
|
||||
# Register for free API https://www.n2yo.com/login/ personal data page at bottom 'Are you developer?'
|
||||
n2yoAPIKey =
|
||||
# NORAD list https://www.n2yo.com/satellites/
|
||||
satList = 25544,7530
|
||||
|
||||
# use Open-Meteo API for weather data not NOAA useful for non US locations
|
||||
UseMeteoWxAPI = False
|
||||
|
||||
# NOAA weather forecast days
|
||||
NOAAforecastDuration = 3
|
||||
# number of weather alerts to display
|
||||
NOAAalertCount = 2
|
||||
|
||||
# NOAA Weather EAS Alert Broadcast
|
||||
wxAlertBroadcastEnabled = False
|
||||
# Enable Ignore any message that includes following word list
|
||||
ignoreEASenable = False
|
||||
ignoreEASwords = test,advisory
|
||||
# Add extra location to the weather alert
|
||||
enableExtraLocationWx = False
|
||||
|
||||
# NOAA Coastal Data Enable NOAA Coastal Waters Forecasts and Tide
|
||||
coastalEnabled = False
|
||||
# Find the correct costal weather directory at https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/
|
||||
# pz = Puget Sound, ph = Honolulu HI, gm = Florida Keys, pk = Alaska
|
||||
# this map can help https://www.weather.gov/marine select location and then look at the 'Forecast-by-Zone Map'
|
||||
# myCoastalZone is the .txt file with the forecast data
|
||||
myCoastalZone = https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/pz/pzz135.txt
|
||||
# number of data points to return, default is 3
|
||||
coastalForecastDays = 3
|
||||
|
||||
# NOAA USGS Hydrology river identifiers, LID or USGS ID https://waterdata.usgs.gov 12484500 Columbia River at The Dalles, OR
|
||||
# for multiple rivers use comma separated list e.g. 12484500,14105700
|
||||
riverList =
|
||||
|
||||
# USA FEMA IPAWS alerts
|
||||
ipawsAlertEnabled = True
|
||||
# comma separated list of FIPS codes to trigger local alert. find your FIPS codes at https://en.wikipedia.org/wiki/Federal_Information_Processing_Standard_state_code
|
||||
myFIPSList = 57,58,53
|
||||
# find your SAME https://www.weather.gov/nwr/counties comma separated list of SAME code to further refine local alert.
|
||||
mySAMEList = 053029,053073
|
||||
# Enable Ignore, headline that includes following word list
|
||||
ignoreFEMAenable = True
|
||||
ignoreFEMAwords = test,exercise
|
||||
|
||||
# USGS Volcano alerts Enable USGS Volcano Alert Broadcast
|
||||
volcanoAlertBroadcastEnabled = False
|
||||
# Enable Ignore any message that includes following word list
|
||||
ignoreUSGSEnable = False
|
||||
ignoreUSGSWords = test,advisory
|
||||
|
||||
# Use Germany/DE Alert Broadcast Data
|
||||
enableDEalerts = False
|
||||
# comma separated list of regional codes trigger local alert.
|
||||
# find your regional codet at https://www.xrepository.de/api/xrepository/urn:de:bund:destatis:bevoelkerungsstatistik:schluessel:rs_2021-07-31/download/Regionalschl_ssel_2021-07-31.json
|
||||
myRegionalKeysDE = 110000000000,120510000000
|
||||
|
||||
# Alerts are sent to the emergency_handler interface and channel duplicate messages are send here if set
|
||||
eAlertBroadcastCh =
|
||||
|
||||
# CheckList Checkin/Checkout
|
||||
[checklist]
|
||||
enabled = False
|
||||
checklist_db = data/checklist.db
|
||||
reverse_in_out = False
|
||||
# Auto approve new checklists
|
||||
auto_approve = True
|
||||
# Check-in reminder interval is 5min
|
||||
# Checkin broadcast interface and channel is emergency_handler interface and channel
|
||||
|
||||
# Inventory and Point of Sale System
|
||||
[inventory]
|
||||
enabled = False
|
||||
inventory_db = data/inventory.db
|
||||
# Set to True to disable penny precision and round to nickels (USA cash sales)
|
||||
# When True: cash sales round down, taxed sales round up to nearest $0.05
|
||||
# When False (default): normal penny precision ($0.01)
|
||||
disable_penny = False
|
||||
|
||||
[qrz]
|
||||
# QRZ Hello to new nodes with message
|
||||
enabled = False
|
||||
qrz_db = data/qrz.db
|
||||
qrz_hello_string = "MeshBot says Hello! DM for more info."
|
||||
# Training mode will not send the hello message to new nodes
|
||||
training = True
|
||||
|
||||
# repeater module
|
||||
[repeater]
|
||||
@@ -70,14 +308,42 @@ enabled = False
|
||||
# and rebroadcasted on the same channel on the other device/node/interface
|
||||
# with great power comes great responsibility, danger could be lurking in use of this feature
|
||||
# if you have the two nodes on the same radio configurations, you could create a feedback loop
|
||||
repeater_channels =
|
||||
repeater_channels =
|
||||
|
||||
[scheduler]
|
||||
# enable or disable the scheduler module
|
||||
enabled = False
|
||||
# interface to send the message to
|
||||
interface = 1
|
||||
# channel to send the message to
|
||||
channel = 2
|
||||
message = "MeshBot says Hello! DM for more info."
|
||||
# enable overides the above and uses the motd as the message
|
||||
schedulerMotd = False
|
||||
# value can be min,hour,day,mon,tue,wed,thu,fri,sat,sun.
|
||||
# value can also be 'joke' (min/interval), 'weather' (time/day), 'link' (hour/interval) for special auto messages
|
||||
# or 'news' (hour/interval), 'readrss' (hour/interval), 'mwx' (time/day), 'sysinfo' (hour/interval),
|
||||
# 'tide' (time/day), 'solar' (time/day) for automated information broadcasts, matching module needs enabled!
|
||||
# 'custom' for module/scheduler.py custom schedule examples
|
||||
value =
|
||||
# interval to use when time is not set (e.g. every 2 days)
|
||||
interval =
|
||||
# time of day in 24:00 hour format when value is 'day' and interval is not set
|
||||
# Process run :00,:20,:40 try and vary the 20 minute offsets to avoid collision
|
||||
time =
|
||||
|
||||
[radioMon]
|
||||
# dx cluster `dx` command
|
||||
dxspotter_enabled = True
|
||||
|
||||
# alerts in this module use the following interface and channel
|
||||
sigWatchBroadcastInterface = 1
|
||||
# broadcast channel can also be a comma separated list of channels
|
||||
sigWatchBroadcastCh = 2
|
||||
|
||||
# using Hamlib rig control will monitor and alert on channel use
|
||||
enabled = False
|
||||
rigControlServerAddress = localhost:4532
|
||||
# brodcast to all nodes on the channel can alsp be = 2,3
|
||||
sigWatchBrodcastCh = 2
|
||||
rigControlServerAddress = 127.0.0.1:4532
|
||||
# minimum SNR as reported by radio via hamlib
|
||||
signalDetectionThreshold = -10
|
||||
# hold time for high SNR
|
||||
@@ -85,3 +351,155 @@ signalHoldTime = 10
|
||||
# the following are combined to reset the monitor
|
||||
signalCooldown = 5
|
||||
signalCycleLimit = 5
|
||||
|
||||
# Enable VOX detection using default input
|
||||
voxDetectionEnabled = False
|
||||
# description to use in the alert message
|
||||
voxDescription = VOX
|
||||
|
||||
useLocalVoxModel = False
|
||||
# default language for VOX detection
|
||||
voxLanguage = en-us
|
||||
# sound.card input device to use for VOX detection, 'default' uses system default
|
||||
voxInputDevice = default
|
||||
# "hey chirpy"
|
||||
voxOnTrapList = True
|
||||
voxTrapList = chirpy
|
||||
# allow use of 'weather' and 'joke' commands via VOX
|
||||
voxEnableCmd = True
|
||||
|
||||
# Meshages Text-to-Speech (TTS) for incoming messages and DM
|
||||
meshagesTTS = False
|
||||
ttsChannels = 2
|
||||
|
||||
# WSJT-X UDP monitoring - listens for decode messages from WSJT-X, FT8/FT4/WSPR etc.
|
||||
wsjtxDetectionEnabled = False
|
||||
# UDP address and port where WSJT-X broadcasts (default: 127.0.0.1:2237)
|
||||
wsjtxUdpServerAddress = 127.0.0.1:2237
|
||||
# Comma-separated list of callsigns to watch (empty = all callsigns)
|
||||
wsjtxWatchedCallsigns =
|
||||
|
||||
# JS8Call TCP monitoring - connects to JS8Call API for message forwarding
|
||||
js8callDetectionEnabled = False
|
||||
# TCP address and port where JS8Call API listens (default: 127.0.0.1:2442)
|
||||
js8callServerAddress = 127.0.0.1:2442
|
||||
# Comma-separated list of callsigns to watch (empty = all callsigns)
|
||||
js8callWatchedCallsigns =
|
||||
|
||||
|
||||
[fileMon]
|
||||
filemon_enabled = False
|
||||
# text file to monitor for changes
|
||||
file_path = alert.txt
|
||||
# channel to send the message to can be 2,3 multiple channels comma separated
|
||||
broadcastCh = 2
|
||||
|
||||
# news command will return the contents of a text file
|
||||
enable_read_news = False
|
||||
news_file_path = ../data/news.txt
|
||||
# only return a single random (head)line from the news file
|
||||
news_random_line = False
|
||||
# only return random news 'block' (seprated by two newlines) randomly (precidence over news_random_line)
|
||||
news_block_mode = True
|
||||
|
||||
# enable the use of exernal shell commands, this enables some data in `sysinfo`
|
||||
enable_runShellCmd = False
|
||||
# if runShellCmd and you think it is safe to allow the x: command to run
|
||||
# direct shell command handler the x: command in DMs
|
||||
allowXcmd = False
|
||||
# Enable 2 factor authentication for x: commands
|
||||
twoFactor_enabled = True
|
||||
# time in seconds to wait for the correct 2FA answer
|
||||
twoFactor_timeout = 100
|
||||
|
||||
[smtp]
|
||||
# enable or disable the SMTP module
|
||||
enableSMTP = False
|
||||
# enable or disable the IMAP module for inbound email
|
||||
enableImap = False
|
||||
# list of Sysop Emails seperate with commas
|
||||
sysopEmails =
|
||||
SMTP_SERVER = smtp.gmail.com
|
||||
# 587 SMTP over TLS/STARTTLS, 25 legacy SMTP, 465 SMTP over SSL
|
||||
SMTP_PORT = 587
|
||||
# Sender email: be mindful of public access, don't use your personal email
|
||||
FROM_EMAIL = none@gmail.com
|
||||
SMTP_AUTH = True
|
||||
SMTP_USERNAME = none@gmail.com
|
||||
SMTP_PASSWORD = none
|
||||
EMAIL_SUBJECT = Meshtastic✉️
|
||||
|
||||
# IMAP not implimented yet
|
||||
IMAP_SERVER = imap.gmail.com
|
||||
# 993 IMAP over TLS/SSL, 143 legacy IMAP
|
||||
IMAP_PORT = 993
|
||||
# IMAP login usually same as SMTP
|
||||
IMAP_USERNAME = none@gmail.com
|
||||
IMAP_PASSWORD = none
|
||||
IMAP_FOLDER = inbox
|
||||
|
||||
[games]
|
||||
# if hop limit for the user exceeds this value, the message will be dropped
|
||||
game_hop_limit = 5
|
||||
disable_emojis = False
|
||||
# enable or disable the games module(s)
|
||||
dopeWars = True
|
||||
lemonade = True
|
||||
blackjack = True
|
||||
videopoker = True
|
||||
mastermind = True
|
||||
golfsim = True
|
||||
hangman = True
|
||||
hamtest = True
|
||||
tictactoe = True
|
||||
wordOfTheDay = True
|
||||
battleShip = True
|
||||
|
||||
# enable or disable the quiz game module questions are in data/quiz.json
|
||||
quiz = False
|
||||
|
||||
# enable or disable the survey game module questions are in data/survey/*_survey.json
|
||||
survey = False
|
||||
# this is the default survey to use when command givcen, from data/survey/example_survey.json
|
||||
defaultSurvey = example
|
||||
# Whether to record user ID in responses
|
||||
surveyRecordID=True
|
||||
# Whether to record location on start of survey
|
||||
surveyRecordLocation=True
|
||||
|
||||
[messagingSettings]
|
||||
# delay in seconds for response to avoid message collision /throttling
|
||||
responseDelay = 2.2
|
||||
# delay in seconds for splits in messages to avoid message collision /throttling
|
||||
splitDelay = 2.5
|
||||
# message chunk size in charcters, chunkr allows exceeding by 3 characters
|
||||
MESSAGE_CHUNK_SIZE = 160
|
||||
# Request Acknowledgement of message OTA
|
||||
wantAck = False
|
||||
# Max limit buffer for radio testing in bytes
|
||||
maxBuffer = 200
|
||||
#Enable Extra logging of Hop count data
|
||||
enableHopLogs = False
|
||||
# Noisy Node Telemetry Logging and packet threshold
|
||||
noisyNodeLogging = False
|
||||
noisyTelemetryLimit = 5
|
||||
logMetaStats = True
|
||||
# Enable detailed packet logging all packets
|
||||
DEBUGpacket = False
|
||||
# metaPacket detailed logging, the filter negates the port ID
|
||||
debugMetadata = False
|
||||
metadataFilter = TELEMETRY_APP,POSITION_APP
|
||||
# Enable or disable automatic banning of nodes
|
||||
autoBanEnabled = False
|
||||
# Number of offenses before auto-ban
|
||||
autoBanThreshold = 5
|
||||
# Throttle value for API requests no ban_hammer
|
||||
apiThrottleValue = 20
|
||||
# Timeframe for offenses (in seconds)
|
||||
autoBanTimeframe = 3600
|
||||
|
||||
[dataPersistence]
|
||||
# Enable or disable the data persistence loop service
|
||||
enabled = True
|
||||
# Interval in seconds for the persistence loop (how often to save data)
|
||||
interval = 300
|
||||
3
data/README.md
Normal file
3
data/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
database admin tool is in [./etc/db_admin.py](../etc/db_admin.py)
|
||||
this folder is populated with install.sh
|
||||
to manually populate ` cp etc/data/* data/. `
|
||||
BIN
etc/3dttt.jpg
Normal file
BIN
etc/3dttt.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
132
etc/README.md
Normal file
132
etc/README.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# etc Directory
|
||||
|
||||
This folder contains supporting files and resources for the Mesh Bot project. Typical contents include:
|
||||
|
||||
- **Images**: Visual assets used in documentation (e.g., `pong-bot.jpg`).
|
||||
- **Custom Scripts**: Example or utility scripts for advanced configuration (e.g., `custom_scheduler.py` for scheduled tasks).
|
||||
- **tmp**: Temp files for install
|
||||
|
||||
## db_admin.py
|
||||
|
||||
**Purpose:**
|
||||
`db_admin.py` is a simple administrative tool for viewing the contents of the Mesh Bot’s data and high score databases. It loads and prints out messages, direct messages, email/SMS records, and game high score tables stored in the `/data` directory.
|
||||
|
||||
**Usage:**
|
||||
Run this script from the command line to display the current contents of the bot’s databases. This is useful for debugging, verifying data integrity, or reviewing stored messages and game scores.
|
||||
|
||||
```sh
|
||||
python etc/db_admin.py
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Attempts to load various `.pkl` and `.pickle` files from the `data` directory.
|
||||
- Prints out the contents of BBS messages, direct messages, email and SMS databases.
|
||||
- Displays high scores for supported games (Lemonade Stand, DopeWars, BlackJack, Video Poker, Mastermind, GolfSim).
|
||||
- If a file is missing, it will print a message indicating so.
|
||||
|
||||
**Note:**
|
||||
This tool is for administrative and debugging purposes only. It does not modify any data.
|
||||
|
||||
## eas_alert_parser.py
|
||||
|
||||
**Purpose:**
|
||||
`eas_alert_parser.py` is a utility script for processing and cleaning up output from `multimon-ng` to extract and convert Emergency Alert System (EAS) messages for further use, such as with EAS2Text.
|
||||
|
||||
**Usage:**
|
||||
This script is intended to be used with piped input, typically from `multimon-ng` decoding SAME/EAS messages. It filters and processes EAS lines, converts them to readable text using EAS2Text, and writes the results to `alert.txt`.
|
||||
|
||||
**Example usage:**
|
||||
```sh
|
||||
multimon-ng -a EAS ... | python etc/eas_alert_parser.py
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Reads input line-by-line (supports piped or redirected input).
|
||||
- Filters for lines starting with `EAS:` or `EAS (part):`.
|
||||
- Avoids duplicate messages and only processes new alerts.
|
||||
- Uses the EAS2Text library to convert EAS codes to human-readable messages.
|
||||
- Writes completed alerts to `alert.txt` for further processing or notification.
|
||||
|
||||
**Note:**
|
||||
This script is intended for experimental or hobbyist use and may require customization for your specific workflow.
|
||||
|
||||
## simulator.py
|
||||
|
||||
**Purpose:**
|
||||
`simulator.py` is a development and testing tool that simulates the behavior of the Mesh Bot in a controlled environment. It allows you to prototype and test handler functions without needing real hardware or a live mesh network.
|
||||
|
||||
**Usage:**
|
||||
Run this script from the command line to interactively test handler functions. You can input messages as if you were a mesh node, and see how your handler responds.
|
||||
|
||||
```sh
|
||||
python etc/simulator.py
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Simulates node IDs, device IDs, and random GPS locations.
|
||||
- Lets you specify which handler function to test (by setting `projectName`).
|
||||
- Prompts for user input, passes it to the handler, and displays the response.
|
||||
- Logs simulated message sending and handler output for review.
|
||||
- Useful for rapid prototyping and debugging new features or message handlers.
|
||||
|
||||
**Note:**
|
||||
Edit the `projectName` variable to match the handler function you want to test. You can expand this script to test additional handlers or scenarios as needed.
|
||||
|
||||
## yolo_vision.py
|
||||
|
||||
**Purpose:**
|
||||
`yolo_vision.py` provides real-time object detection and movement tracking using a Raspberry Pi camera and YOLOv5. It is designed for integration with the Mesh Bot project, outputting alerts to both the console and an optional `alert.txt` file for further use (such as with Meshtastic).
|
||||
|
||||
**Features:**
|
||||
- Ignores specified object classes (e.g., "bed", "chair") to reduce false positives.
|
||||
- Configurable detection confidence threshold and movement sensitivity.
|
||||
- Tracks object movement direction (left, right, stationary).
|
||||
- Fuse counter: only alerts after an object is detected for several consecutive frames.
|
||||
- Optionally writes the latest alert (without timestamp) to a specified file, overwriting previous alerts.
|
||||
|
||||
**Configuration:**
|
||||
- `LOW_RES_MODE`: Use low or high camera resolution for CPU savings.
|
||||
- `IGNORE_CLASSES`: List of object classes to ignore.
|
||||
- `CONFIDENCE_THRESHOLD`: Minimum confidence for reporting detections.
|
||||
- `MOVEMENT_THRESHOLD`: Minimum pixel movement to consider as "moving".
|
||||
- `ALERT_FUSE_COUNT`: Number of consecutive detections before alerting.
|
||||
- `ALERT_FILE_PATH`: Path to alert file (set to `None` to disable file output).
|
||||
|
||||
**Usage:**
|
||||
Run this script to monitor the camera feed and generate alerts for detected and moving objects. Alerts are printed to the console and, if configured, written to `alert.txt` for integration with other systems.
|
||||
|
||||
---
|
||||
|
||||
## icad_tone.py
|
||||
|
||||
**Purpose:**
|
||||
`icad_tone.py` is a utility script for detecting fire and EMS radio tones using the [icad_tone_detection](https://github.com/thegreatcodeholio/icad_tone_detection) library. It analyzes audio from a live stream, soundcard, or WAV file, identifies various tone types (such as two-tone, long tone, hi/low, pulsed, MDC, and DTMF), and writes detected alerts to `alert.txt` for integration with Mesh Bot or Meshtastic.
|
||||
|
||||
**Usage:**
|
||||
Run the script from the command line, specifying a WAV file for offline analysis or configuring it to listen to a stream or soundcard for real-time monitoring.
|
||||
|
||||
```sh
|
||||
python etc/icad_tone.py --wav path/to/file.wav
|
||||
```
|
||||
Or, for live monitoring (after setting `HTTP_STREAM_URL` in the script):
|
||||
```sh
|
||||
python etc/icad_tone.py
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Loads audio from a stream, soundcard, or WAV file.
|
||||
- Uses `icad_tone_detection` to analyze audio for tone patterns.
|
||||
- Prints raw detection results and summaries to the console.
|
||||
- Writes a summary of detected tones to `alert.txt` (overwriting each time).
|
||||
- Handles errors and missing dependencies gracefully.
|
||||
|
||||
**Configuration:**
|
||||
- `ALERT_FILE_PATH`: Path to the alert output file (default: `alert.txt`).
|
||||
- `AUDIO_SOURCE`: Set to `"http"` for streaming or `"soundcard"` for local audio input.
|
||||
- `HTTP_STREAM_URL`: URL of the audio stream (required if using HTTP source).
|
||||
- `SAMPLE_RATE`, `INPUT_CHANNELS`, `CHUNK_DURATION`: Audio processing parameters.
|
||||
|
||||
**Note:**
|
||||
- Requires installation of dependencies (`icad_tone_detection`)
|
||||
- Set `HTTP_STREAM_URL` to a valid stream if using HTTP mode.
|
||||
- Intended for experimental or hobbyist use; may require customization for your workflow.
|
||||
@@ -1,21 +0,0 @@
|
||||
# Load the bbs messages from the database file to screen for admin functions
|
||||
import pickle # pip install pickle
|
||||
|
||||
|
||||
# load the bbs messages from the database file
|
||||
try:
|
||||
with open('../bbsdb.pkl', 'rb') as f:
|
||||
bbs_messages = pickle.load(f)
|
||||
except:
|
||||
print ("\nSystem: bbsdb.pkl not found")
|
||||
|
||||
try:
|
||||
with open('../bbsdm.pkl', 'rb') as f:
|
||||
bbs_dm = pickle.load(f)
|
||||
except:
|
||||
print ("\nSystem: bbsdm.pkl not found")
|
||||
|
||||
print ("\nSystem: bbs_messages")
|
||||
print (bbs_messages)
|
||||
print ("\nSystem: bbs_dm")
|
||||
print (bbs_dm)
|
||||
128
etc/custom_scheduler.template
Normal file
128
etc/custom_scheduler.template
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/python3
|
||||
import schedule
|
||||
from modules.log import logger
|
||||
from modules.settings import MOTD
|
||||
from modules.system import send_message
|
||||
|
||||
def setup_custom_schedules(send_message, tell_joke, welcome_message, handle_wxc, MOTD, schedulerChannel, schedulerInterface):
|
||||
"""
|
||||
Set up custom schedules. Edit the example schedules as needed.
|
||||
|
||||
1. in config.ini set "value" under [scheduler] to: value = custom
|
||||
2. edit this file to add/remove/modify schedules
|
||||
3. restart mesh bot
|
||||
4. verify schedules are working by checking the log file
|
||||
5. Make sure to uncomment (delete the single #) the example schedules down at the end of the file to enable them
|
||||
Python is sensitive to indentation so be careful when editing this file.
|
||||
https://thonny.org is included on pi's image and is a simple IDE to use for editing python files.
|
||||
6. System Tasks run every 20min try and avoid overlapping schedules to reduce API rapid fire issues. use like 8:05
|
||||
|
||||
Available functions you can import and use, be sure they are enabled modules in config.ini:
|
||||
- tell_joke() - Returns a random joke
|
||||
- welcome_message - A welcome message string
|
||||
- handle_wxc(message_from_id, deviceID, cmd, days=None) - Weather information
|
||||
- handleNews(message_from_id, deviceID, message, isDM) - News reader
|
||||
- get_rss_feed(msg) - RSS feed reader
|
||||
- handle_mwx(message_from_id, deviceID, cmd) - Marine weather
|
||||
- sysinfo(message, message_from_id, deviceID, isDM) - System information
|
||||
- handle_tide(message_from_id, deviceID, channel_number) - Tide information
|
||||
- handle_sun(message_from_id, deviceID, channel_number) - Sun information
|
||||
- MOTD - Message of the day string
|
||||
"""
|
||||
try:
|
||||
# Import additional functions for scheduling (optional, depending on your needs)
|
||||
from mesh_bot import handleNews, sysinfo, handle_mwx, handle_tide, handle_sun
|
||||
from modules.rss import get_rss_feed
|
||||
|
||||
# Example task functions, modify as needed the channel and interface parameters default to schedulerChannel and schedulerInterface
|
||||
def send_joke(channel, interface):
|
||||
## uses system.send_message to send the result of tell_joke()
|
||||
send_message(tell_joke(), channel, 0, interface)
|
||||
|
||||
def send_good_morning(channel, interface):
|
||||
## uses system.send_message to send "Good Morning"
|
||||
send_message("Good Morning", channel, 0, interface)
|
||||
|
||||
def send_wx(channel, interface):
|
||||
## uses system.send_message to send the result of handle_wxc(id,id,cmd,days_returned)
|
||||
send_message(handle_wxc(0, 1, 'wx', days=1), channel, 0, interface)
|
||||
|
||||
def send_weather_alert(channel, interface):
|
||||
## uses system.send_message to send string
|
||||
send_message("Weather alerts available on 'Alerts' channel with default 'AQ==' key.", channel, 0, interface)
|
||||
|
||||
def send_config_url(channel, interface):
|
||||
## uses system.send_message to send string
|
||||
send_message("Join us on Medium Fast https://meshtastic.org/e/#CgcSAQE6AggNEg4IARAEOAFAA0gBUB5oAQ", channel, 0, interface)
|
||||
|
||||
def send_net_starting(channel, interface):
|
||||
## uses system.send_message to send string, channel 2, interface 3
|
||||
send_message("Net Starting Now", 2, 0, 3)
|
||||
|
||||
def send_welcome(channel, interface):
|
||||
## uses system.send_message to send string, channel 2, interface 1
|
||||
send_message("Welcome to the group", 2, 0, 1)
|
||||
|
||||
def send_motd(channel, interface):
|
||||
## uses system.send_message to send message of the day string which can be updated in runtime
|
||||
send_message(MOTD, channel, 0, interface)
|
||||
|
||||
def send_news(channel, interface):
|
||||
## uses system.send_message to send the result of handleNews()
|
||||
send_message(handleNews(0, interface, 'readnews', False), channel, 0, interface)
|
||||
|
||||
def send_rss(channel, interface):
|
||||
## uses system.send_message to send the result of get_rss_feed()
|
||||
send_message(get_rss_feed(''), channel, 0, interface)
|
||||
|
||||
def send_marine_weather(channel, interface):
|
||||
## uses system.send_message to send the result of handle_mwx()
|
||||
send_message(handle_mwx(0, interface, 'mwx'), channel, 0, interface)
|
||||
|
||||
def send_sysinfo(channel, interface):
|
||||
## uses system.send_message to send the result of sysinfo()
|
||||
send_message(sysinfo('', 0, interface, False), channel, 0, interface)
|
||||
|
||||
def send_tide(channel, interface):
|
||||
## uses system.send_message to send the result of handle_tide()
|
||||
send_message(handle_tide(0, interface, channel), channel, 0, interface)
|
||||
|
||||
def send_sun(channel, interface):
|
||||
## uses system.send_message to send the result of handle_sun()
|
||||
send_message(handle_sun(0, interface, channel), channel, 0, interface)
|
||||
|
||||
### Send a joke every 2 minutes
|
||||
#schedule.every(2).minutes.do(lambda: send_joke(schedulerChannel, schedulerInterface))
|
||||
### Send a good morning message every day at 9 AM
|
||||
#schedule.every().day.at("09:00").do(lambda: send_good_morning(schedulerChannel, schedulerInterface))
|
||||
### Send a good morning message every day at 9 AM to DM node 4258675309 without above function
|
||||
#schedule.every().day.at("09:00").do(lambda: send_message("Good Morning Jenny", 0, 4258675309, schedulerInterface))
|
||||
### Send weather update every day at 8 AM
|
||||
#schedule.every().day.at("08:00").do(lambda: send_wx(schedulerChannel, schedulerInterface))
|
||||
### Send weather alerts every Wednesday at noon
|
||||
#schedule.every().wednesday.at("12:00").do(lambda: send_weather_alert(schedulerChannel, schedulerInterface))
|
||||
### Send configuration URL every 2 days at 10 AM
|
||||
#schedule.every(2).days.at("10:00").do(lambda: send_config_url(schedulerChannel, schedulerInterface))
|
||||
### Send net starting message every Wednesday at 7 PM
|
||||
#schedule.every().wednesday.at("19:00").do(lambda: send_net_starting(schedulerChannel, schedulerInterface))
|
||||
### Send welcome message every 2 days at 8 AM
|
||||
#schedule.every(2).days.at("08:00").do(lambda: send_welcome(schedulerChannel, schedulerInterface))
|
||||
### Send MOTD every day at 1 PM
|
||||
#schedule.every().day.at("13:00").do(lambda: send_motd(schedulerChannel, schedulerInterface))
|
||||
### Send bbslink message every 2 days at 10 AM
|
||||
#schedule.every(2).days.at("10:00").do(lambda: send_message("bbslink MeshBot looking for peers", schedulerChannel, 0, schedulerInterface))
|
||||
### Send news updates every 6 hours
|
||||
#schedule.every(6).hours.do(lambda: send_news(schedulerChannel, schedulerInterface))
|
||||
### Send RSS feed every day at 9 AM
|
||||
#schedule.every().day.at("09:00").do(lambda: send_rss(schedulerChannel, schedulerInterface))
|
||||
### Send marine weather every day at 6 AM
|
||||
#schedule.every().day.at("06:00").do(lambda: send_marine_weather(schedulerChannel, schedulerInterface))
|
||||
### Send system information every day at 12 PM
|
||||
#schedule.every().day.at("12:00").do(lambda: send_sysinfo(schedulerChannel, schedulerInterface))
|
||||
### Send tide information every day at 5 AM
|
||||
#schedule.every().day.at("05:00").do(lambda: send_tide(schedulerChannel, schedulerInterface))
|
||||
### Send sun information every day at 6 AM
|
||||
#schedule.every().day.at("06:00").do(lambda: send_sun(schedulerChannel, schedulerInterface))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up custom schedules: {e}")
|
||||
7190
etc/data/hamradio/extra.json
Normal file
7190
etc/data/hamradio/extra.json
Normal file
File diff suppressed because it is too large
Load Diff
5078
etc/data/hamradio/general.json
Normal file
5078
etc/data/hamradio/general.json
Normal file
File diff suppressed because it is too large
Load Diff
4934
etc/data/hamradio/technician.json
Normal file
4934
etc/data/hamradio/technician.json
Normal file
File diff suppressed because it is too large
Load Diff
1
etc/data/mesh_news.txt
Normal file
1
etc/data/mesh_news.txt
Normal file
@@ -0,0 +1 @@
|
||||
Today in meshtastic you are looking at the coolest bot on the block.
|
||||
1
etc/data/news.txt
Normal file
1
etc/data/news.txt
Normal file
@@ -0,0 +1 @@
|
||||
no new news is good news!
|
||||
16
etc/data/quiz_questions.json
Normal file
16
etc/data/quiz_questions.json
Normal file
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"question": "Which RFband is commonly used by Meshtastic devices in US regions?",
|
||||
"answers": ["2.4 GHz", "433 MHz", "900 MHz", "5.8 GHz"],
|
||||
"correct": 2
|
||||
},
|
||||
{
|
||||
"question": "Yogi the bear 🐻 likes what food?",
|
||||
"answers": ["Picnic baskets", "Fish", "Burgers", "Hot dogs"],
|
||||
"correct": 0
|
||||
},
|
||||
{
|
||||
"question": "What is the password for the Meshtastic MQTT broker?",
|
||||
"answer": "large4cats"
|
||||
}
|
||||
]
|
||||
15
etc/data/surveys/example_survey.json
Normal file
15
etc/data/surveys/example_survey.json
Normal file
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"type": "multiple_choice",
|
||||
"question": "How Did you hear about us?",
|
||||
"options": ["Meshtastic", "Discord", "Friend", "Other"]
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"question": "How many nodes do you own?"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"question": "What feature would you like to see next?"
|
||||
}
|
||||
]
|
||||
15
etc/data/surveys/snow_survey.json
Normal file
15
etc/data/surveys/snow_survey.json
Normal file
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"type": "multiple_choice",
|
||||
"question": "How often do you experience snowfall in your area?",
|
||||
"options": ["Never", "Rarely", "Sometimes", "Often", "Every winter"]
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"question": "What was the deepest snowfall (in inches) you've measured at your location?"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"question": "Describe any challenges you face during heavy snowfall."
|
||||
}
|
||||
]
|
||||
191
etc/db_admin.py
Normal file
191
etc/db_admin.py
Normal file
@@ -0,0 +1,191 @@
|
||||
# Load the bbs messages from the database file to screen for admin functions
|
||||
import pickle
|
||||
import sqlite3
|
||||
|
||||
print ("\n Meshing-Around Database Admin Tool\n")
|
||||
|
||||
|
||||
# load the bbs messages from the database file
|
||||
try:
|
||||
with open('../data/bbsdb.pkl', 'rb') as f:
|
||||
bbs_messages = pickle.load(f)
|
||||
except Exception as e:
|
||||
try:
|
||||
with open('data/bbsdb.pkl', 'rb') as f:
|
||||
bbs_messages = pickle.load(f)
|
||||
except Exception as e:
|
||||
bbs_messages = "System: data/bbsdb.pkl not found"
|
||||
|
||||
try:
|
||||
with open('../data/bbsdm.pkl', 'rb') as f:
|
||||
bbs_dm = pickle.load(f)
|
||||
except Exception as e:
|
||||
try:
|
||||
with open('data/bbsdm.pkl', 'rb') as f:
|
||||
bbs_dm = pickle.load(f)
|
||||
except Exception as e:
|
||||
bbs_dm = "System: data/bbsdm.pkl not found"
|
||||
|
||||
try:
|
||||
with open('../data/email_db.pickle', 'rb') as f:
|
||||
email_db = pickle.load(f)
|
||||
except Exception as e:
|
||||
try:
|
||||
with open('data/email_db.pickle', 'rb') as f:
|
||||
email_db = pickle.load(f)
|
||||
except Exception as e:
|
||||
email_db = "System: data/email_db.pickle not found"
|
||||
|
||||
try:
|
||||
with open('../data/sms_db.pickle', 'rb') as f:
|
||||
sms_db = pickle.load(f)
|
||||
except Exception as e:
|
||||
try:
|
||||
with open('data/sms_db.pickle', 'rb') as f:
|
||||
sms_db = pickle.load(f)
|
||||
except Exception as e:
|
||||
sms_db = "System: data/sms_db.pickle not found"
|
||||
|
||||
|
||||
# Game HS tables
|
||||
try:
|
||||
with open('../data/lemonstand.pkl', 'rb') as f:
|
||||
lemon_score = pickle.load(f)
|
||||
except Exception as e:
|
||||
try:
|
||||
with open('data/lemonstand.pkl', 'rb') as f:
|
||||
lemon_score = pickle.load(f)
|
||||
except Exception as e:
|
||||
lemon_score = "System: data/lemonstand.pkl not found"
|
||||
|
||||
try:
|
||||
with open('../data/dopewar_hs.pkl', 'rb') as f:
|
||||
dopewar_score = pickle.load(f)
|
||||
except Exception as e:
|
||||
try:
|
||||
with open('data/dopewar_hs.pkl', 'rb') as f:
|
||||
dopewar_score = pickle.load(f)
|
||||
except Exception as e:
|
||||
dopewar_score = "System: data/dopewar_hs.pkl not found"
|
||||
|
||||
try:
|
||||
with open('../data/blackjack_hs.pkl', 'rb') as f:
|
||||
blackjack_score = pickle.load(f)
|
||||
except Exception as e:
|
||||
try:
|
||||
with open('data/blackjack_hs.pkl', 'rb') as f:
|
||||
blackjack_score = pickle.load(f)
|
||||
except Exception as e:
|
||||
blackjack_score = "System: data/blackjack_hs.pkl not found"
|
||||
|
||||
try:
|
||||
with open('../data/videopoker_hs.pkl', 'rb') as f:
|
||||
videopoker_score = pickle.load(f)
|
||||
except Exception as e:
|
||||
try:
|
||||
with open('data/videopoker_hs.pkl', 'rb') as f:
|
||||
videopoker_score = pickle.load(f)
|
||||
except Exception as e:
|
||||
videopoker_score = "System: data/videopoker_hs.pkl not found"
|
||||
|
||||
try:
|
||||
with open('../mmind_hs.pkl', 'rb') as f:
|
||||
mmind_score = pickle.load(f)
|
||||
except Exception as e:
|
||||
try:
|
||||
with open('mmind_hs.pkl', 'rb') as f:
|
||||
mmind_score = pickle.load(f)
|
||||
except Exception as e:
|
||||
mmind_score = "System: mmind_hs.pkl not found"
|
||||
|
||||
try:
|
||||
with open('../data/golfsim_hs.pkl', 'rb') as f:
|
||||
golfsim_score = pickle.load(f)
|
||||
except Exception as e:
|
||||
try:
|
||||
with open('data/golfsim_hs.pkl', 'rb') as f:
|
||||
golfsim_score = pickle.load(f)
|
||||
except Exception as e:
|
||||
golfsim_score = "System: data/golfsim_hs.pkl not found"
|
||||
|
||||
|
||||
# checklist.db admin display
|
||||
print("\nCurrent Check-ins Table\n")
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect('../data/checklist.db')
|
||||
except Exception:
|
||||
conn = sqlite3.connect('data/checklist.db')
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("""
|
||||
SELECT * FROM checkin
|
||||
WHERE removed = 0
|
||||
ORDER BY checkin_id DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
rows = c.fetchall()
|
||||
col_names = [desc[0] for desc in c.description]
|
||||
if rows:
|
||||
# Print header
|
||||
header = " | ".join(f"{name:<15}" for name in col_names)
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
# Print rows
|
||||
for row in rows:
|
||||
print(" | ".join(f"{str(col):<15}" for col in row))
|
||||
else:
|
||||
print("No check-ins found.")
|
||||
except Exception as e:
|
||||
print(f"Error reading check-ins: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# inventory.db admin display
|
||||
print("\nCurrent Inventory Table\n")
|
||||
try:
|
||||
conn = sqlite3.connect('../data/inventory.db')
|
||||
except Exception:
|
||||
conn = sqlite3.connect('data/inventory.db')
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("""
|
||||
SELECT * FROM inventory
|
||||
ORDER BY item_id DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
rows = c.fetchall()
|
||||
col_names = [desc[0] for desc in c.description]
|
||||
if rows:
|
||||
# Print header
|
||||
header = " | ".join(f"{name:<15}" for name in col_names)
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
# Print rows
|
||||
for row in rows:
|
||||
print(" | ".join(f"{str(col):<15}" for col in row))
|
||||
else:
|
||||
print("No inventory items found.")
|
||||
except Exception as e:
|
||||
print(f"Error reading inventory: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# Pickle database displays
|
||||
print ("System: bbs_messages")
|
||||
print (bbs_messages)
|
||||
print ("\nSystem: bbs_dm")
|
||||
print (bbs_dm)
|
||||
print ("\nSystem: email_db")
|
||||
print (email_db)
|
||||
print ("\nSystem: sms_db")
|
||||
print (sms_db)
|
||||
print (f"\n\nGame HS tables\n")
|
||||
print (f"lemon:{lemon_score}")
|
||||
print (f"dopewar:{dopewar_score}")
|
||||
print (f"blackjack:{blackjack_score}")
|
||||
print (f"videopoker:{videopoker_score}")
|
||||
print (f"mmind:{mmind_score}")
|
||||
print (f"golfsim:{golfsim_score}")
|
||||
print ("\n")
|
||||
49
etc/eas_alert_parser.py
Normal file
49
etc/eas_alert_parser.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# Super sloppy multimon-ng output cleaner for processing by EAS2Text
|
||||
# I maed dis, sorta, mostly just mashed code I found or that chatGPT hallucinated
|
||||
# by Mike O'Connell/skrrt, no licence or whatever just be chill yo
|
||||
# enhanced by sheer.cold
|
||||
|
||||
import re
|
||||
from EAS2Text import EAS2Text
|
||||
|
||||
buff=[] # store messages for writing
|
||||
seen=set()
|
||||
pattern = re.compile(r'ZCZC.*?NWS-')
|
||||
|
||||
# alternate regex for parsing multimon-ng output
|
||||
# provided by https://github.com/A-c0rN
|
||||
#reg = r"^.*?(NNNN|ZCZC)(?:-([A-Za-z0-9]{3})-([A-Za-z0-9]{3})-((?:-?[0-9]{6})+)\+([0-9]{4})-([0-9]{7})-(.{8})-)?.*?$"
|
||||
#prog = re.compile(reg, re.MULTILINE)
|
||||
#groups = prog.match(sameData).groups()
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Handle piped input
|
||||
inp=input().strip()
|
||||
except EOFError:
|
||||
break
|
||||
# potentially take multiple lines in one buffered input
|
||||
for line in inp.splitlines():
|
||||
# only want EAS lines
|
||||
if line.startswith("EAS:") or line.startswith("EAS (part):"):
|
||||
content=line.split(":", maxsplit=1)[1].strip()
|
||||
if content=="NNNN": # end of EAS message
|
||||
# write if we have something
|
||||
if buff:
|
||||
print("writing")
|
||||
with open("alert.txt","w") as fh:
|
||||
fh.write('\n'.join(buff))
|
||||
# prepare for new data
|
||||
buff.clear()
|
||||
seen.clear()
|
||||
elif content in seen:
|
||||
# don't need repeats
|
||||
continue
|
||||
else:
|
||||
# check for national weather service
|
||||
match=pattern.search(content)
|
||||
if match:
|
||||
seen.add(content)
|
||||
msg=EAS2Text(content).EASText
|
||||
print("got message", msg)
|
||||
buff.append(msg)
|
||||
102
etc/fakeNode.py
Normal file
102
etc/fakeNode.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# https://github.com/pdxlocations/mudp/blob/main/examples/helloworld-example.py
|
||||
import time
|
||||
import random
|
||||
from pubsub import pub
|
||||
from meshtastic.protobuf import mesh_pb2
|
||||
from mudp import (
|
||||
conn,
|
||||
node,
|
||||
UDPPacketStream,
|
||||
send_nodeinfo,
|
||||
send_text_message,
|
||||
send_device_telemetry,
|
||||
send_position,
|
||||
send_environment_metrics,
|
||||
send_power_metrics,
|
||||
send_waypoint,
|
||||
)
|
||||
|
||||
MCAST_GRP = "224.0.0.69"
|
||||
MCAST_PORT = 4403
|
||||
KEY = "1PG7OiApB1nwvP+rz05pAQ=="
|
||||
|
||||
interface = UDPPacketStream(MCAST_GRP, MCAST_PORT, key=KEY)
|
||||
|
||||
def setup_node():
|
||||
node.node_id = "!deadbeef"
|
||||
node.long_name = "UDP Test"
|
||||
node.short_name = "UDP"
|
||||
node.channel = "LongFast"
|
||||
node.key = "AQ=="
|
||||
conn.setup_multicast(MCAST_GRP, MCAST_PORT)
|
||||
# Convert hex node_id to decimal (strip the '!' first)
|
||||
decimal_id = int(node.node_id[1:], 16)
|
||||
print(f"Node ID: {node.node_id} (decimal: {decimal_id})")
|
||||
print(f"Channel: {node.channel}, Key: {node.key}")
|
||||
|
||||
def demo_send_messages():
|
||||
print("Sending node info...")
|
||||
send_nodeinfo()
|
||||
time.sleep(3)
|
||||
print("Sending text message...")
|
||||
send_text_message("hello world")
|
||||
time.sleep(3)
|
||||
print("Sending device telemetry position...")
|
||||
send_position(latitude=37.7749, longitude=-122.4194, altitude=3000, precision_bits=3, ground_speed=5)
|
||||
time.sleep(3)
|
||||
print("Sending device telemetry local node data...")
|
||||
send_device_telemetry(battery_level=50, voltage=3.7, channel_utilization=25, air_util_tx=15, uptime_seconds=123456)
|
||||
time.sleep(3)
|
||||
print("Sending environment metrics...")
|
||||
send_environment_metrics(
|
||||
temperature=23.072298,
|
||||
relative_humidity=17.5602016,
|
||||
barometric_pressure=995.36261,
|
||||
gas_resistance=229.093369,
|
||||
voltage=5.816,
|
||||
current=-29.3,
|
||||
iaq=66,
|
||||
)
|
||||
time.sleep(3)
|
||||
print("Sending power metrics...")
|
||||
send_power_metrics(
|
||||
ch1_voltage=18.744,
|
||||
ch1_current=11.2,
|
||||
ch2_voltage=2.792,
|
||||
ch2_current=18.4,
|
||||
ch3_voltage=0,
|
||||
ch3_current=0,
|
||||
)
|
||||
time.sleep(3)
|
||||
print("Sending waypoint...")
|
||||
send_waypoint(
|
||||
id=random.randint(1, 2**32 - 1),
|
||||
latitude=45.271394,
|
||||
longitude=-121.736083,
|
||||
expire=0,
|
||||
locked_to=node.node_id,
|
||||
name="Camp",
|
||||
description="Main campsite near the lake",
|
||||
icon=0x1F3D5, # 🏕
|
||||
)
|
||||
|
||||
def main():
|
||||
setup_node()
|
||||
interface.start()
|
||||
print("MUDP Fake Node is running. Press Ctrl+C to exit.")
|
||||
print("You can send demo messages to the network.")
|
||||
try:
|
||||
while True:
|
||||
answer = input("Do you want to send demo messages? (y/n): ").strip().lower()
|
||||
if answer == "y":
|
||||
demo_send_messages()
|
||||
elif answer == "n":
|
||||
print("Exiting.")
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
interface.stop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
222
etc/icad_tone.py
Normal file
222
etc/icad_tone.py
Normal file
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# icad_tone.py - uses icad_tone_detection, for fire and EMS tone detection
|
||||
# https://github.com/thegreatcodeholio/icad_tone_detection
|
||||
# output to alert.txt for meshing-around bot
|
||||
# 2025 K7MHI Kelly Keeton
|
||||
|
||||
# ---------------------------
|
||||
# User Configuration Section
|
||||
# ---------------------------
|
||||
ALERT_FILE_PATH = "alert.txt" # Path to alert log file, or None to disable logging
|
||||
AUDIO_SOURCE = "soundcard" # "soundcard" for mic/line-in, "http" for stream
|
||||
HTTP_STREAM_URL = "" # Set to your stream URL if using "http"
|
||||
SAMPLE_RATE = 16000 # Audio sample rate (Hz)
|
||||
INPUT_CHANNELS = 1 # Number of input channels (1=mono)
|
||||
MIN_SAMPLES = 4096 # Minimum samples per detection window (increase for better accuracy)
|
||||
STREAM_BUFFER = 32000 # Number of bytes to buffer before detection (for MP3 streams)
|
||||
INPUT_DEVICE = 0 # Set to device index or name, or None for default
|
||||
# ---------------------------
|
||||
|
||||
import sys
|
||||
import time
|
||||
from icad_tone_detection import tone_detect
|
||||
from pydub import AudioSegment
|
||||
import requests
|
||||
import sounddevice as sd
|
||||
import numpy as np
|
||||
import argparse
|
||||
import io
|
||||
import warnings
|
||||
warnings.filterwarnings("ignore", message="nperseg = .* is greater than input length")
|
||||
def write_alert(message):
|
||||
if ALERT_FILE_PATH:
|
||||
try:
|
||||
with open(ALERT_FILE_PATH, "w") as f: # overwrite each time
|
||||
f.write(message + "\n")
|
||||
except Exception as e:
|
||||
print(f"Error writing to alert file: {e}", file=sys.stderr)
|
||||
|
||||
def detect_and_alert(audio_data, sample_rate):
|
||||
try:
|
||||
result = tone_detect(audio_data, sample_rate)
|
||||
except Exception as e:
|
||||
print(f"Detection error: {e}", file=sys.stderr)
|
||||
return
|
||||
# Only print if something is detected
|
||||
if result and any(getattr(result, t, []) for t in [
|
||||
"two_tone_result", "long_result", "hi_low_result", "pulsed_result", "mdc_result", "dtmf_result"
|
||||
]):
|
||||
print("Raw detection result:", result)
|
||||
# Prepare alert summary for all relevant tone types
|
||||
summary = []
|
||||
if hasattr(result, "dtmf_result") and result.dtmf_result:
|
||||
for dtmf in result.dtmf_result:
|
||||
summary.append(f"DTMF Digit: {dtmf.get('digit', '?')} | Duration: {dtmf.get('length', '?')}s")
|
||||
if hasattr(result, "hi_low_result") and result.hi_low_result:
|
||||
for hl in result.hi_low_result:
|
||||
summary.append(
|
||||
f"Hi/Low Alternations: {hl.get('alternations', '?')} | Duration: {hl.get('length', '?')}s"
|
||||
)
|
||||
if hasattr(result, "mdc_result") and result.mdc_result:
|
||||
for mdc in result.mdc_result:
|
||||
summary.append(
|
||||
f"MDC UnitID: {mdc.get('unitID', '?')} | Op: {mdc.get('op', '?')} | Duration: {mdc.get('length', '?')}s"
|
||||
)
|
||||
if hasattr(result, "pulsed_result") and result.pulsed_result:
|
||||
for pl in result.pulsed_result:
|
||||
summary.append(
|
||||
f"Pulsed Tone: {pl.get('detected', '?')}Hz | Cycles: {pl.get('cycles', '?')} | Duration: {pl.get('length', '?')}s"
|
||||
)
|
||||
if hasattr(result, "two_tone_result") and result.two_tone_result:
|
||||
for tt in result.two_tone_result:
|
||||
summary.append(
|
||||
f"Two-Tone: {tt.get('detected', ['?','?'])[0]}Hz/{tt.get('detected', ['?','?'])[1]}Hz | Tone A: {tt.get('tone_a_length', '?')}s | Tone B: {tt.get('tone_b_length', '?')}s"
|
||||
)
|
||||
if hasattr(result, "long_result") and result.long_result:
|
||||
for lt in result.long_result:
|
||||
summary.append(
|
||||
f"Long Tone: {lt.get('detected', '?')}Hz | Duration: {lt.get('length', '?')}s"
|
||||
)
|
||||
if summary:
|
||||
write_alert("\n".join(summary))
|
||||
|
||||
def get_supported_sample_rate(device, channels=1):
|
||||
# Try common sample rates
|
||||
for rate in [44100, 48000, 16000, 8000]:
|
||||
try:
|
||||
sd.check_input_settings(device=device, channels=channels, samplerate=rate)
|
||||
return rate
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
def main():
|
||||
print("="*80)
|
||||
print(" iCAD Tone Decoder for Meshing-Around Booting Up!")
|
||||
if AUDIO_SOURCE == "soundcard":
|
||||
try:
|
||||
if INPUT_DEVICE is not None:
|
||||
sd.default.device = INPUT_DEVICE
|
||||
device_info = sd.query_devices(INPUT_DEVICE, kind='input')
|
||||
else:
|
||||
device_info = sd.query_devices(sd.default.device, kind='input')
|
||||
device_name = device_info['name']
|
||||
# Detect supported sample rate
|
||||
detected_rate = get_supported_sample_rate(sd.default.device, INPUT_CHANNELS)
|
||||
if detected_rate:
|
||||
SAMPLE_RATE = detected_rate
|
||||
else:
|
||||
print("No supported sample rate found, using default.", file=sys.stderr)
|
||||
except Exception:
|
||||
device_name = "Unknown"
|
||||
print(f" Mode: Soundcard | Device: {device_name} | Sample Rate: {SAMPLE_RATE} Hz | Channels: {INPUT_CHANNELS}")
|
||||
elif AUDIO_SOURCE == "http":
|
||||
print(f" Mode: HTTP Stream | URL: {HTTP_STREAM_URL} | Buffer: {STREAM_BUFFER} bytes")
|
||||
else:
|
||||
print(f" Mode: {AUDIO_SOURCE}")
|
||||
print("="*80)
|
||||
time.sleep(1)
|
||||
|
||||
parser = argparse.ArgumentParser(description="ICAD Tone Detection")
|
||||
parser.add_argument("--wav", type=str, help="Path to WAV file for detection")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.wav:
|
||||
print(f"Processing WAV file: {args.wav}")
|
||||
try:
|
||||
audio = AudioSegment.from_file(args.wav)
|
||||
if audio.channels > 1:
|
||||
audio = audio.set_channels(1)
|
||||
print(f"AudioSegment: channels={audio.channels}, frame_rate={audio.frame_rate}, duration={len(audio)}ms")
|
||||
detect_and_alert(audio, audio.frame_rate)
|
||||
except Exception as e:
|
||||
print(f"Error processing WAV file: {e}", file=sys.stderr)
|
||||
return
|
||||
|
||||
print("Starting ICAD Tone Detection...")
|
||||
|
||||
if AUDIO_SOURCE == "http":
|
||||
if not HTTP_STREAM_URL or HTTP_STREAM_URL.startswith("http://your-stream-url-here"):
|
||||
print("ERROR: Please set a valid HTTP_STREAM_URL or provide a WAV file using --wav option.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
print(f"Listening to HTTP stream: {HTTP_STREAM_URL}")
|
||||
try:
|
||||
response = requests.get(HTTP_STREAM_URL, stream=True, timeout=10)
|
||||
buffer = io.BytesIO()
|
||||
try:
|
||||
for chunk in response.iter_content(chunk_size=4096):
|
||||
buffer.write(chunk)
|
||||
# Use STREAM_BUFFER for detection window
|
||||
if buffer.tell() > STREAM_BUFFER:
|
||||
buffer.seek(0)
|
||||
audio = AudioSegment.from_file(buffer, format="mp3")
|
||||
if audio.channels > 1:
|
||||
audio = audio.set_channels(1)
|
||||
# --- Simple audio level detection ---
|
||||
samples = np.array(audio.get_array_of_samples())
|
||||
if samples.dtype != np.float32:
|
||||
samples = samples.astype(np.float32) / 32767.0 # Normalize to -1..1
|
||||
rms = np.sqrt(np.mean(samples**2))
|
||||
if rms > 0.01:
|
||||
print(f"Audio detected! RMS: {rms:.3f} ", end='\r')
|
||||
if rms > 0.5:
|
||||
print(f"WARNING: Audio too loud! RMS: {rms:.3f} ", end='\r')
|
||||
# --- End audio level detection ---
|
||||
detect_and_alert(audio, audio.frame_rate)
|
||||
buffer = io.BytesIO()
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped by user.")
|
||||
sys.exit(0)
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Connection error: {e}", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
except Exception as e:
|
||||
print(f"Error processing HTTP stream: {e}", file=sys.stderr)
|
||||
sys.exit(4)
|
||||
elif AUDIO_SOURCE == "soundcard":
|
||||
print("Listening to audio device:")
|
||||
buffer = np.array([], dtype=np.float32)
|
||||
min_samples = MIN_SAMPLES # Use configured minimum samples
|
||||
|
||||
def callback(indata, frames, time_info, status):
|
||||
nonlocal buffer
|
||||
try:
|
||||
samples = indata[:, 0]
|
||||
buffer = np.concatenate((buffer, samples))
|
||||
# --- Simple audio level detection ---
|
||||
rms = np.sqrt(np.mean(samples**2))
|
||||
if rms > 0.01:
|
||||
print(f"Audio detected! RMS: {rms:.3f} ", end='\r')
|
||||
if rms > 0.5:
|
||||
print(f"WARNING: Audio too loud! RMS: {rms:.3f} ", end='\r')
|
||||
# --- End audio level detection ---
|
||||
# Only process when buffer is large enough
|
||||
while buffer.size >= min_samples:
|
||||
int_samples = np.int16(buffer[:min_samples] * 32767)
|
||||
audio = AudioSegment(
|
||||
data=int_samples.tobytes(),
|
||||
sample_width=2,
|
||||
frame_rate=SAMPLE_RATE,
|
||||
channels=1
|
||||
)
|
||||
detect_and_alert(audio, SAMPLE_RATE)
|
||||
buffer = buffer[min_samples:] # keep remainder for next window
|
||||
except Exception as e:
|
||||
print(f"Callback error: {e}", file=sys.stderr)
|
||||
try:
|
||||
with sd.InputStream(samplerate=SAMPLE_RATE, channels=INPUT_CHANNELS, dtype='float32', callback=callback):
|
||||
print("Press Ctrl+C to stop.")
|
||||
import signal
|
||||
signal.pause() # Wait for Ctrl+C, keeps CPU usage minimal
|
||||
except KeyboardInterrupt:
|
||||
print("Stopped by user.")
|
||||
except Exception as e:
|
||||
print(f"Error accessing soundcard: {e}", file=sys.stderr)
|
||||
sys.exit(5)
|
||||
else:
|
||||
print("Unknown AUDIO_SOURCE. Set to 'http' or 'soundcard'.", file=sys.stderr)
|
||||
sys.exit(6)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
173
etc/install_service.sh
Normal file
173
etc/install_service.sh
Normal file
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Install mesh_bot as a systemd service for the current user.
|
||||
# Defaults:
|
||||
# - project path: /opt/meshing-around
|
||||
# - service name: mesh_bot
|
||||
# - service user: invoking user (SUDO_USER when using sudo)
|
||||
|
||||
SERVICE_NAME="mesh_bot"
|
||||
PROJECT_PATH="/opt/meshing-around"
|
||||
SERVICE_USER="${SUDO_USER:-${USER:-}}"
|
||||
SERVICE_GROUP=""
|
||||
USE_LAUNCH_SH=1
|
||||
NEED_MESHTASTICD=1
|
||||
DRY_RUN=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash etc/install_service.sh [options]
|
||||
|
||||
Options:
|
||||
--project-path PATH Project root path (default: /opt/meshing-around)
|
||||
--user USER Linux user to run the service as (default: invoking user)
|
||||
--group GROUP Linux group to run the service as (default: user's primary group)
|
||||
--direct-python Run python3 mesh_bot.py directly (skip launch.sh)
|
||||
--no-meshtasticd Do not require meshtasticd.service to be present
|
||||
--dry-run Print actions without changing the system
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
sudo bash etc/install_service.sh
|
||||
sudo bash etc/install_service.sh --project-path /opt/meshing-around --user $USER
|
||||
EOF
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '[install_service] %s\n' "$*"
|
||||
}
|
||||
|
||||
die() {
|
||||
printf '[install_service] ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--project-path)
|
||||
[[ $# -ge 2 ]] || die "Missing value for --project-path"
|
||||
PROJECT_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
--user)
|
||||
[[ $# -ge 2 ]] || die "Missing value for --user"
|
||||
SERVICE_USER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--group)
|
||||
[[ $# -ge 2 ]] || die "Missing value for --group"
|
||||
SERVICE_GROUP="$2"
|
||||
shift 2
|
||||
;;
|
||||
--direct-python)
|
||||
USE_LAUNCH_SH=0
|
||||
shift
|
||||
;;
|
||||
--no-meshtasticd)
|
||||
NEED_MESHTASTICD=0
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "Unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$SERVICE_USER" ]] || die "Could not determine service user. Use --user USER."
|
||||
[[ "$SERVICE_USER" != "root" ]] || die "Refusing to install service as root. Use --user USER."
|
||||
|
||||
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
die "User '$SERVICE_USER' does not exist"
|
||||
fi
|
||||
|
||||
if [[ -z "$SERVICE_GROUP" ]]; then
|
||||
SERVICE_GROUP="$(id -gn "$SERVICE_USER")"
|
||||
fi
|
||||
|
||||
id -g "$SERVICE_USER" >/dev/null 2>&1 || die "Could not determine group for user '$SERVICE_USER'"
|
||||
[[ -d "$PROJECT_PATH" ]] || die "Project path not found: $PROJECT_PATH"
|
||||
[[ -f "$PROJECT_PATH/mesh_bot.py" ]] || die "mesh_bot.py not found in $PROJECT_PATH"
|
||||
|
||||
if [[ $USE_LAUNCH_SH -eq 1 ]]; then
|
||||
[[ -f "$PROJECT_PATH/launch.sh" ]] || die "launch.sh not found in $PROJECT_PATH"
|
||||
EXEC_START="/usr/bin/bash $PROJECT_PATH/launch.sh mesh"
|
||||
else
|
||||
EXEC_START="/usr/bin/python3 $PROJECT_PATH/mesh_bot.py"
|
||||
fi
|
||||
|
||||
if [[ $NEED_MESHTASTICD -eq 1 ]]; then
|
||||
if ! systemctl list-units --type=service --no-pager --all | grep meshtasticd.service; then
|
||||
die "meshtasticd.service dependency not found. to ignore this check, run with --no-meshtasticd flag."
|
||||
fi
|
||||
MESHTASTICD_DEPENDENCY_LINES=$'\nAfter=meshtasticd.service\nRequires=meshtasticd.service'
|
||||
else
|
||||
MESHTASTICD_DEPENDENCY_LINES=""
|
||||
fi
|
||||
|
||||
SERVICE_FILE_CONTENT="[Unit]
|
||||
Description=MESH-BOT
|
||||
After=network.target${MESHTASTICD_DEPENDENCY_LINES}
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$SERVICE_USER
|
||||
Group=$SERVICE_GROUP
|
||||
WorkingDirectory=$PROJECT_PATH
|
||||
ExecStart=$EXEC_START
|
||||
KillSignal=SIGINT
|
||||
Environment=REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
|
||||
Environment=SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"
|
||||
|
||||
TARGET_SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
|
||||
log "Service user: $SERVICE_USER"
|
||||
log "Service group: $SERVICE_GROUP"
|
||||
log "Project path: $PROJECT_PATH"
|
||||
log "Service file: $TARGET_SERVICE_FILE"
|
||||
log "ExecStart: $EXEC_START"
|
||||
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
log "Dry run mode enabled. Service file content:"
|
||||
printf '\n%s\n' "$SERVICE_FILE_CONTENT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
die "This script needs root privileges. Re-run with: sudo bash etc/install_service.sh"
|
||||
fi
|
||||
|
||||
printf '%s' "$SERVICE_FILE_CONTENT" > "$TARGET_SERVICE_FILE"
|
||||
chmod 644 "$TARGET_SERVICE_FILE"
|
||||
|
||||
# Ensure runtime files are writable by the service account.
|
||||
mkdir -p "$PROJECT_PATH/logs" "$PROJECT_PATH/data"
|
||||
chown -R "$SERVICE_USER:$SERVICE_GROUP" "$PROJECT_PATH/logs" "$PROJECT_PATH/data"
|
||||
if [[ -f "$PROJECT_PATH/config.ini" ]]; then
|
||||
chown "$SERVICE_USER:$SERVICE_GROUP" "$PROJECT_PATH/config.ini"
|
||||
chmod 664 "$PROJECT_PATH/config.ini"
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME.service"
|
||||
systemctl restart "$SERVICE_NAME.service"
|
||||
|
||||
log "Service installed and started."
|
||||
log "Check status with: sudo systemctl status $SERVICE_NAME.service"
|
||||
log "View logs with: sudo journalctl -u $SERVICE_NAME.service -f"
|
||||
@@ -1,5 +1,6 @@
|
||||
# /etc/systemd/system/mesh_bot.service
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable mesh_bot.service
|
||||
# sudo systemctl start mesh_bot.service
|
||||
|
||||
[Unit]
|
||||
@@ -7,15 +8,22 @@ Description=MESH-BOT
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
Group=pi
|
||||
WorkingDirectory=/dir/
|
||||
ExecStart=/usr/bin/python /dir/launch.sh mesh
|
||||
ExecStart=python3 mesh_bot.py
|
||||
ExecStop=
|
||||
KillSignal=SIGINT
|
||||
Environment="REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"
|
||||
Environment="SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"
|
||||
|
||||
# Disable Python's buffering of STDOUT and STDERR, so that output from the
|
||||
# service shows up immediately in systemd's logs
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
Restart=on-failure
|
||||
Type=notify #try simple if any problems
|
||||
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
12
etc/mesh_bot_reporting.timer
Normal file
12
etc/mesh_bot_reporting.timer
Normal file
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=MeshingAround-ReportingTask
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 04:20:00
|
||||
Persistent=true
|
||||
Unit=mesh_bot_reporting.service
|
||||
#OnUnitActiveSec=1h
|
||||
#OnbootSec=5min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
28
etc/mesh_bot_reporting.tmp
Normal file
28
etc/mesh_bot_reporting.tmp
Normal file
@@ -0,0 +1,28 @@
|
||||
# /etc/systemd/system/mesh_bot_reporting.service
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable mesh_bot_reporting.service
|
||||
# sudo systemctl start mesh_bot_reporting.service
|
||||
|
||||
[Unit]
|
||||
Description=MeshingAround-Reporting
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=pi
|
||||
Group=pi
|
||||
WorkingDirectory=/dir/
|
||||
ExecStart=python3 etc/report_generator5.py
|
||||
ExecStop=pkill -f report_generator5.py
|
||||
# ExecStart=python3 etc/report_generator.py
|
||||
# ExecStop=pkill -f report_generator.py
|
||||
|
||||
|
||||
# Disable Python's buffering of STDOUT and STDERR, so that output from the
|
||||
# service shows up immediately in systemd's logs
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
22
etc/mesh_bot_w3_server.tmp
Normal file
22
etc/mesh_bot_w3_server.tmp
Normal file
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=MeshingAround-WebServer
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
Group=pi
|
||||
WorkingDirectory=/dir/
|
||||
ExecStart=python3 modules/web.py
|
||||
ExecStop=
|
||||
KillSignal=SIGINT
|
||||
Environment="REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"
|
||||
Environment="SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# /etc/systemd/system/pong_bot.service
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable pong_bot.service
|
||||
# sudo systemctl start pong_bot.service
|
||||
|
||||
[Unit]
|
||||
@@ -7,15 +8,21 @@ Description=PONG-BOT
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
Group=pi
|
||||
WorkingDirectory=/dir/
|
||||
ExecStart=/usr/bin/python /dir/launch.sh pong
|
||||
ExecStart=python3 pong_bot.py
|
||||
ExecStop=
|
||||
KillSignal=SIGINT
|
||||
Environment="REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"
|
||||
Environment="SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"
|
||||
|
||||
# Disable Python's buffering of STDOUT and STDERR, so that output from the
|
||||
# service shows up immediately in systemd's logs
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
Restart=on-failure
|
||||
Type=notify #try simple if any problems
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
1007
etc/report_generator.py
Normal file
1007
etc/report_generator.py
Normal file
File diff suppressed because it is too large
Load Diff
1302
etc/report_generator5.py
Normal file
1302
etc/report_generator5.py
Normal file
File diff suppressed because it is too large
Load Diff
BIN
etc/reporting.jpg
Normal file
BIN
etc/reporting.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
53
etc/send-environment-metrics.py
Normal file
53
etc/send-environment-metrics.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# file name: send-environment-metrics.py
|
||||
# https://github.com/pdxlocations/Meshtastic-Python-Examples/blob/main/send-environment-metrics.py
|
||||
|
||||
from meshtastic.protobuf import portnums_pb2, telemetry_pb2
|
||||
from meshtastic import BROADCAST_ADDR
|
||||
import time
|
||||
|
||||
# For connection over serial
|
||||
# import meshtastic.serial_interface
|
||||
# interface = meshtastic.serial_interface.SerialInterface()
|
||||
|
||||
# For connection over TCP
|
||||
import meshtastic.tcp_interface
|
||||
interface = meshtastic.tcp_interface.TCPInterface(hostname='127.0.0.1', noProto=False)
|
||||
|
||||
# Create a telemetry data object
|
||||
telemetry_data = telemetry_pb2.Telemetry()
|
||||
telemetry_data.time = int(time.time())
|
||||
#telemetry_data.local_stats.upTime = 0
|
||||
telemetry_data.environment_metrics.temperature = 0
|
||||
# telemetry_data.environment_metrics.voltage = 0
|
||||
# telemetry_data.environment_metrics.current = 0
|
||||
# telemetry_data.environment_metrics.relative_humidity = 0
|
||||
# telemetry_data.environment_metrics.barometric_pressure = 0
|
||||
# telemetry_data.environment_metrics.gas_resistance = 0
|
||||
# telemetry_data.environment_metrics.iaq = 0
|
||||
# telemetry_data.environment_metrics.distance = 0
|
||||
# telemetry_data.environment_metrics.lux = 0
|
||||
# telemetry_data.environment_metrics.white_lux = 0
|
||||
# telemetry_data.environment_metrics.ir_lux = 0
|
||||
# telemetry_data.environment_metrics.uv_lux = 0
|
||||
# telemetry_data.environment_metrics.wind_direction = 0
|
||||
# telemetry_data.environment_metrics.wind_speed = 0
|
||||
# telemetry_data.environment_metrics.wind_gust = 0
|
||||
# telemetry_data.environment_metrics.wind_lull = 0
|
||||
# telemetry_data.environment_metrics.weight = 0
|
||||
|
||||
# Read the uptime
|
||||
# with open('/proc/uptime', 'r') as uptime:
|
||||
# telemetry_data.local_stats.upTime = int(float(uptime.readline().split()[0]))
|
||||
|
||||
# Read the CPU temperature
|
||||
with open('/sys/class/thermal/thermal_zone0/temp', 'r') as cpu_temp:
|
||||
telemetry_data.environment_metrics.temperature = int(cpu_temp.read()) / 1000
|
||||
|
||||
interface.sendData(
|
||||
telemetry_data,
|
||||
destinationId=BROADCAST_ADDR,
|
||||
portNum=portnums_pb2.PortNum.TELEMETRY_APP,
|
||||
wantResponse=False,
|
||||
)
|
||||
|
||||
interface.close()
|
||||
53
etc/set-permissions.sh
Normal file
53
etc/set-permissions.sh
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# Set ownership and permissions for Meshing Around application
|
||||
|
||||
# Check if run as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use first argument as user, or default to meshbot
|
||||
TARGET_USER="${1:-meshbot}"
|
||||
echo "DEBUG: TARGET_USER='$TARGET_USER'"
|
||||
|
||||
# Check if user exists
|
||||
if ! id "$TARGET_USER" >/dev/null 2>&1; then
|
||||
echo "User '$TARGET_USER' does not exist."
|
||||
CUR_USER="$(whoami)"
|
||||
printf "Would you like to use the current user (%s) instead? [y/N]: " "$CUR_USER"
|
||||
read yn
|
||||
if [ "$yn" = "y" ] || [ "$yn" = "Y" ]; then
|
||||
TARGET_USER="$CUR_USER"
|
||||
echo "Using current user: $TARGET_USER"
|
||||
if ! id "$TARGET_USER" >/dev/null 2>&1; then
|
||||
echo "Current user '$TARGET_USER' does not exist or cannot be determined."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Exiting."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
id "$TARGET_USER"
|
||||
|
||||
echo "Setting ownership to $TARGET_USER:$TARGET_USER"
|
||||
|
||||
for dir in "/opt/meshing-around" "/opt/meshing-around/logs" "/opt/meshing-around/data"; do
|
||||
if [ -d "$dir" ]; then
|
||||
chown -R "$TARGET_USER:$TARGET_USER" "$dir"
|
||||
chmod 775 "$dir"
|
||||
else
|
||||
echo "Warning: Directory $dir does not exist, skipping."
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -f "/opt/meshing-around/config.ini" ]; then
|
||||
chown "$TARGET_USER:$TARGET_USER" "/opt/meshing-around/config.ini"
|
||||
chmod 664 "/opt/meshing-around/config.ini"
|
||||
else
|
||||
echo "Warning: /opt/meshing-around/config.ini does not exist, skipping."
|
||||
fi
|
||||
|
||||
echo "Permissions and ownership have been set."
|
||||
89
etc/simulator.py
Normal file
89
etc/simulator.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
# # Simulate meshing-around de K7MHI 2024
|
||||
from modules.log import logger, getPrettyTime # Import the logger; ### --> If you are reading this put the script in the project root <-- ###
|
||||
import time
|
||||
from datetime import datetime
|
||||
import random
|
||||
|
||||
# Initialize the tool
|
||||
projectName = "example_handler" # name of _handler function to match the function name under test
|
||||
randomNode = False # Set to True to use random node IDs
|
||||
|
||||
# bot.py Simulated functions
|
||||
deviceID = 1 # represents the device/node number
|
||||
def get_NodeID():
|
||||
nodeList = [4258675309, 1212121212, 1234567890, 9876543210]
|
||||
if randomNode:
|
||||
nodeID = random.choice(nodeList) # get a random node ID
|
||||
else:
|
||||
nodeID = nodeList[0]
|
||||
return nodeID
|
||||
nodeID = get_NodeID() # assign a nodeID
|
||||
def get_name_from_number(nodeID, length='short', interface=1):
|
||||
# return random name for nodeID
|
||||
names = ["Max","Molly","Jake","Kelly"]
|
||||
return names[nodeID % len(names)]
|
||||
def mesh_bot(message, nodeID, deviceID):
|
||||
return "Meshing-Around Bot at your service!"
|
||||
#simulate GPS locations for testing
|
||||
locations = [
|
||||
(48.200909, -123.25719),
|
||||
(48.330283,-123.260703),
|
||||
(48.342735,-122.987911),
|
||||
(48.205591,-122.998448)
|
||||
]
|
||||
lat, lon = random.choice(locations) # pick a random location
|
||||
location = f"{lat},{lon}"
|
||||
# # end Initialization of the tool
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# # Function to handle, or the project in test
|
||||
#from modules.llm import * # Import the LLM module
|
||||
# # Project handler function code here
|
||||
|
||||
# example handler function canada()
|
||||
def example_handler(message, nodeID, deviceID):
|
||||
if message != "":
|
||||
# put code in test here
|
||||
msg = f"Hello {get_name_from_number(nodeID)}, simulator ready for testing {projectName} project! on device {deviceID}"
|
||||
msg += f" Your location is {location}"
|
||||
msg += f" you said: {message}"
|
||||
# Add timestamp
|
||||
msg += f" [Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]"
|
||||
return msg
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# # end of function test code
|
||||
|
||||
# # Simulate the meshing-around mesh-bot for prototyping new projects
|
||||
if __name__ == '__main__': # represents the bot's main loop
|
||||
packet = ""
|
||||
nodeInt = 1 # represents the device/node number
|
||||
logger.info(f"System: Meshing-Around Simulator Starting for {projectName}")
|
||||
nodeID = get_NodeID() # assign a nodeID
|
||||
projectResponse = globals()[projectName]("", nodeID, deviceID) # call the handler function once to start
|
||||
while True: # represents the onReceive() loop in the bot.py
|
||||
projectResponse = ""
|
||||
responseLength = 0
|
||||
if randomNode:
|
||||
nodeID = get_NodeID() # assign a random nodeID
|
||||
packet = input(f"CLIENT {nodeID} INPUT: " ) # Emulate the client input
|
||||
if packet != "":
|
||||
#try:
|
||||
projectResponse = globals()[projectName](message = packet, nodeID = nodeID, deviceID = deviceID) # call the handler function
|
||||
# except Exception as e:
|
||||
# logger.error(f"System: Handler: {e}")
|
||||
# projectResponse = "Error in handler"
|
||||
if projectResponse:
|
||||
responseLength = len(projectResponse) # Evaluate the response length
|
||||
logger.info(f"Device:{nodeInt} " + CustomFormatter.red + f"Sending {responseLength} long DM: " +\
|
||||
CustomFormatter.white + projectResponse + CustomFormatter.purple + " To: " + CustomFormatter.white + str(nodeID))
|
||||
time.sleep(0.5)
|
||||
nodeID = get_NodeID() # assign a nodeID
|
||||
# # End of launcher
|
||||
20
etc/www/localscripts/chart.js
Normal file
20
etc/www/localscripts/chart.js
Normal file
File diff suppressed because one or more lines are too long
7
etc/www/localscripts/chartjs-adapter-date-fns.bundle.min.js
vendored
Normal file
7
etc/www/localscripts/chartjs-adapter-date-fns.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
252
etc/www/localscripts/css2
Normal file
252
etc/www/localscripts/css2
Normal file
@@ -0,0 +1,252 @@
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7W0Q5nw.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7W0Q5nw.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7W0Q5nw.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7W0Q5n-wU.woff2) format('woff2');
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7W0Q5nw.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
640
etc/www/localscripts/leaflet.css
Normal file
640
etc/www/localscripts/leaflet.css
Normal file
@@ -0,0 +1,640 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg,
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-tile {
|
||||
will-change: opacity;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline: 0;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-container a.leaflet-active {
|
||||
outline: 2px solid orange;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-bar a:hover {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path {
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-container .leaflet-control-attribution,
|
||||
.leaflet-container .leaflet-control-scale {
|
||||
font-size: 11px;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 19px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 18px 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 4px 4px 0 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 18px;
|
||||
height: 14px;
|
||||
font: 16px/14px Tahoma, Verdana, sans-serif;
|
||||
color: #c3c3c3;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover {
|
||||
color: #999;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
border-bottom: 1px solid #ddd;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip-container {
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-clickable {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
6
etc/www/localscripts/leaflet.js
Normal file
6
etc/www/localscripts/leaflet.js
Normal file
File diff suppressed because one or more lines are too long
213
etc/yolo_vision.py
Normal file
213
etc/yolo_vision.py
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
# YOLOv5 Object Detection with Movement Tracking using Raspberry Pi AI Camera or USB Webcam
|
||||
# YOLOv5 Requirements: yolo5 https://docs.ultralytics.com/yolov5/quickstart_tutorial/
|
||||
# PiCamera2 Requirements: picamera2 https://github.com/raspberrypi/picamera2 `sudo apt install imx500-all`
|
||||
# NVIDIA GPU PyTorch: https://developer.nvidia.com/cuda-downloads
|
||||
# OCR with Tesseract: https://tesseract-ocr.github.io/tessdoc/Installation.html. `sudo apt-get install tesseract-ocr`
|
||||
# Adjust settings below as needed, indended for meshing-around alert.txt output to meshtastic
|
||||
# 2025 K7MHI Kelly Keeton
|
||||
|
||||
PI_CAM = 1 # 1 for Raspberry Pi AI Camera, 0 for USB webcam
|
||||
YOLO_MODEL = "yolov5s" # e.g., 'yolov5s', 'yolov5m', 'yolov5l', 'yolov5x'
|
||||
LOW_RES_MODE = 0 # 1 for low res (320x240), 0 for high res (640x480)
|
||||
IGNORE_CLASSES = ["bed", "chair"] # Add object names to ignore
|
||||
CONFIDENCE_THRESHOLD = 0.8 # Only show detections above this confidence
|
||||
MOVEMENT_THRESHOLD = 50 # Pixels to consider as movement (adjust as needed)
|
||||
IGNORE_STATIONARY = True # Whether to ignore stationary objects in output
|
||||
ALERT_FUSE_COUNT = 5 # Number of consecutive detections before alerting
|
||||
ALERT_FILE_PATH = "alert.txt" # e.g., "/opt/meshing-around/alert.txt" or None for no file output
|
||||
OCR_PROCESSING_ENABLED = True # Whether to perform OCR on detected objects
|
||||
SAVE_EVIDENCE_IMAGES = True # Whether to save evidence images when OCR text is found in bbox
|
||||
EVIDENCE_IMAGE_DIR = "." # Change to desired directory, e.g., "/opt/meshing-around/data/images"
|
||||
EVIDENCE_IMAGE_PATTERN = "evidence_{timestamp}.png"
|
||||
|
||||
try:
|
||||
import torch # YOLOv5 https://docs.ultralytics.com/yolov5/quickstart_tutorial/
|
||||
from PIL import Image # pip install pillow
|
||||
import numpy as np # pip install numpy
|
||||
import time
|
||||
import warnings
|
||||
import sys
|
||||
import os
|
||||
import datetime
|
||||
if OCR_PROCESSING_ENABLED:
|
||||
import pytesseract # pip install pytesseract
|
||||
|
||||
if PI_CAM:
|
||||
from picamera2 import Picamera2 # pip install picamera2
|
||||
else:
|
||||
import cv2
|
||||
except ImportError as e:
|
||||
print(f"Missing required module: {e.name}. Please review the comments in program, and try again.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Suppress FutureWarnings from imports upstream noise
|
||||
warnings.filterwarnings("ignore", category=FutureWarning)
|
||||
CAMERA_TYPE = "RaspPi AI-Cam" if PI_CAM else "USB Webcam"
|
||||
RESOLUTION = "320x240" if LOW_RES_MODE else "640x480"
|
||||
|
||||
# Load YOLOv5
|
||||
model = torch.hub.load("ultralytics/yolov5", YOLO_MODEL)
|
||||
|
||||
if PI_CAM:
|
||||
picam2 = Picamera2()
|
||||
if LOW_RES_MODE:
|
||||
picam2.preview_configuration.main.size = (320, 240)
|
||||
else:
|
||||
picam2.preview_configuration.main.size = (640, 480)
|
||||
picam2.preview_configuration.main.format = "RGB888"
|
||||
picam2.configure("preview")
|
||||
picam2.start()
|
||||
else:
|
||||
if LOW_RES_MODE:
|
||||
cam_res = (320, 240)
|
||||
else:
|
||||
cam_res = (640, 480)
|
||||
cap = cv2.VideoCapture(0)
|
||||
cap.set(cv2.CAP_PROP_FRAME_WIDTH, cam_res[0])
|
||||
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, cam_res[1])
|
||||
|
||||
print("="*80)
|
||||
print(f" Sentinal Vision 3000 Booting Up!")
|
||||
print(f" Model: {YOLO_MODEL} | Camera: {CAMERA_TYPE} | Resolution: {RESOLUTION} | OCR: {'Enabled' if OCR_PROCESSING_ENABLED else 'Disabled'}")
|
||||
print("="*80)
|
||||
time.sleep(1)
|
||||
|
||||
def alert_output(msg, alert_file_path=ALERT_FILE_PATH):
|
||||
print(msg)
|
||||
if alert_file_path:
|
||||
# Remove timestamp for file output
|
||||
msg_no_time = " ".join(msg.split("] ")[1:]) if "] " in msg else msg
|
||||
with open(alert_file_path, "w") as f: # Use "a" to append instead of overwrite
|
||||
f.write(msg_no_time + "\n")
|
||||
|
||||
def extract_text_from_bbox(img, bbox):
|
||||
try:
|
||||
cropped = img.crop((bbox[0], bbox[1], bbox[2], bbox[3]))
|
||||
text = pytesseract.image_to_string(cropped, config="--psm 7")
|
||||
text_stripped = text.strip()
|
||||
if text_stripped and SAVE_EVIDENCE_IMAGES:
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
image_path = os.path.join(EVIDENCE_IMAGE_DIR, EVIDENCE_IMAGE_PATTERN.format(timestamp=timestamp))
|
||||
cropped.save(image_path)
|
||||
print(f"Saved evidence image: {image_path}")
|
||||
return f"{text_stripped}"
|
||||
except Exception as e:
|
||||
print(f"Error during OCR: {e}")
|
||||
print("More at https://tesseract-ocr.github.io/tessdoc/Installation.html")
|
||||
return False
|
||||
|
||||
try:
|
||||
i = 0 # Frame counter if zero will be infinite
|
||||
system_normal_printed = False # system nominal flag, if true disables printing
|
||||
while True:
|
||||
i += 1
|
||||
if PI_CAM:
|
||||
frame = picam2.capture_array()
|
||||
else:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
print("Failed to grab frame from webcam.")
|
||||
break
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
img = Image.fromarray(frame)
|
||||
|
||||
results = model(img)
|
||||
df = results.pandas().xyxy[0]
|
||||
df = df[df['confidence'] >= CONFIDENCE_THRESHOLD] # Filter by confidence
|
||||
df = df[~df['name'].isin(IGNORE_CLASSES)] # Filter out ignored classes
|
||||
counts = df['name'].value_counts()
|
||||
if counts.empty:
|
||||
if not system_normal_printed:
|
||||
print("System nominal: No objects detected.")
|
||||
system_normal_printed = True
|
||||
continue # Skip the rest of the loop if nothing detected
|
||||
if counts.sum() > ALERT_FUSE_COUNT:
|
||||
system_normal_printed = False # Reset flag if something is detected
|
||||
|
||||
# Movement tracking
|
||||
if not hasattr(__builtins__, 'prev_centers'):
|
||||
__builtins__.prev_centers = {}
|
||||
if not hasattr(__builtins__, 'stationary_reported'):
|
||||
__builtins__.stationary_reported = set()
|
||||
if not hasattr(__builtins__, 'fuse_counters'):
|
||||
__builtins__.fuse_counters = {}
|
||||
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
current_centers = {}
|
||||
detected_this_frame = set()
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
obj_id = f"{row['name']}_{idx}"
|
||||
x_center = (row['xmin'] + row['xmax']) / 2
|
||||
current_centers[obj_id] = x_center
|
||||
detected_this_frame.add(obj_id)
|
||||
|
||||
prev_x = __builtins__.prev_centers.get(obj_id)
|
||||
direction = ""
|
||||
count = counts[row['name']]
|
||||
|
||||
# Fuse logic
|
||||
fuse_counters = __builtins__.fuse_counters
|
||||
if obj_id not in fuse_counters:
|
||||
fuse_counters[obj_id] = 1
|
||||
else:
|
||||
fuse_counters[obj_id] += 1
|
||||
|
||||
if fuse_counters[obj_id] < ALERT_FUSE_COUNT:
|
||||
continue # Don't alert yet
|
||||
|
||||
# OCR on detected region
|
||||
bbox = [row['xmin'], row['ymin'], row['xmax'], row['ymax']]
|
||||
if OCR_PROCESSING_ENABLED:
|
||||
ocr_text = extract_text_from_bbox(img, bbox)
|
||||
|
||||
if prev_x is not None:
|
||||
delta = x_center - prev_x
|
||||
if abs(delta) < MOVEMENT_THRESHOLD:
|
||||
direction = "stationary"
|
||||
if IGNORE_STATIONARY:
|
||||
if obj_id not in __builtins__.stationary_reported:
|
||||
msg = f"[{timestamp}] {count} {row['name']} {direction}"
|
||||
if OCR_PROCESSING_ENABLED and ocr_text:
|
||||
msg += f" | OCR: {ocr_text}"
|
||||
alert_output(msg)
|
||||
__builtins__.stationary_reported.add(obj_id)
|
||||
else:
|
||||
msg = f"[{timestamp}] {count} {row['name']} {direction}"
|
||||
if OCR_PROCESSING_ENABLED and ocr_text:
|
||||
msg += f" | OCR: {ocr_text}"
|
||||
alert_output(msg)
|
||||
else:
|
||||
direction = "moving right" if delta > 0 else "moving left"
|
||||
msg = f"[{timestamp}] {count} {row['name']} {direction}"
|
||||
if OCR_PROCESSING_ENABLED and ocr_text:
|
||||
msg += f" | OCR: {ocr_text}"
|
||||
alert_output(msg)
|
||||
__builtins__.stationary_reported.discard(obj_id)
|
||||
else:
|
||||
direction = "detected"
|
||||
msg = f"[{timestamp}] {count} {row['name']} {direction}"
|
||||
if OCR_PROCESSING_ENABLED and ocr_text:
|
||||
msg += f" | OCR: {ocr_text}"
|
||||
alert_output(msg)
|
||||
|
||||
# Reset fuse counters for objects not detected in this frame
|
||||
for obj_id in list(__builtins__.fuse_counters.keys()):
|
||||
if obj_id not in detected_this_frame:
|
||||
__builtins__.fuse_counters[obj_id] = 0
|
||||
|
||||
__builtins__.prev_centers = current_centers
|
||||
|
||||
time.sleep(1) # Adjust frame rate as needed
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted by user. Shutting down...")
|
||||
except Exception as e:
|
||||
print(f"\nAn error occurred: {e}", file=sys.stderr)
|
||||
finally:
|
||||
if PI_CAM:
|
||||
picam2.close()
|
||||
print("Camera closed. Goodbye!")
|
||||
else:
|
||||
cap.release()
|
||||
print("Webcam released. Goodbye!")
|
||||
617
install.sh
617
install.sh
@@ -1,78 +1,601 @@
|
||||
#!/bin/bash
|
||||
# meshing-around install helper script
|
||||
# to uninstall, run with --nope
|
||||
|
||||
# install.sh
|
||||
NOPE=0
|
||||
cd "$(dirname "$0")"
|
||||
program_path=$(pwd)
|
||||
|
||||
# add user to groups for serial access
|
||||
sudo usermod -a -G dialout $USER
|
||||
sudo usermod -a -G tty $USER
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "--nope" ]]; then
|
||||
NOPE=1
|
||||
fi
|
||||
done
|
||||
|
||||
# generate config file
|
||||
cp config.template config.ini
|
||||
if [[ $NOPE -eq 1 ]]; then
|
||||
echo "----------------------------------------------"
|
||||
echo "Uninstalling Meshing Around ..."
|
||||
echo "----------------------------------------------"
|
||||
|
||||
sudo systemctl stop mesh_bot || true
|
||||
sudo systemctl disable mesh_bot || true
|
||||
|
||||
# set virtual environment and install dependencies
|
||||
printf "\nMeshing Around Installer\n"
|
||||
echo "Do you want to install the bot in a virtual environment? (y/n)"
|
||||
read venv
|
||||
sudo systemctl stop pong_bot || true
|
||||
sudo systemctl disable pong_bot || true
|
||||
|
||||
if [ $venv == "y" ]; then
|
||||
# set virtual environment
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
sudo systemctl stop mesh_bot_w3_server || true
|
||||
sudo systemctl disable mesh_bot_w3_server || true
|
||||
|
||||
# install dependencies
|
||||
pip install -U -r requirements.txt
|
||||
else
|
||||
printf "\nSkipping virtual environment...\n"
|
||||
# install dependencies
|
||||
echo "Are you on Raspberry Pi? should we add --break-system-packages to the pip install command? (y/n)"
|
||||
read rpi
|
||||
if [ $rpi == "y" ]; then
|
||||
pip install -U -r requirements.txt --break-system-packages
|
||||
sudo systemctl stop mesh_bot_reporting || true
|
||||
sudo systemctl disable mesh_bot_reporting || true
|
||||
|
||||
sudo rm -f /etc/systemd/system/mesh_bot.service
|
||||
sudo rm -f /etc/systemd/system/mesh_bot_reporting
|
||||
sudo rm -f /etc/systemd/system/pong_bot.service
|
||||
sudo rm -f /etc/systemd/system/mesh_bot_w3_server.service
|
||||
sudo rm -f /etc/systemd/system/mesh_bot_reporting.service
|
||||
sudo rm -f /etc/systemd/system/mesh_bot_reporting.timer
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl reset-failed
|
||||
|
||||
sudo gpasswd -d meshbot dialout || true
|
||||
sudo gpasswd -d meshbot tty || true
|
||||
sudo gpasswd -d meshbot bluetooth || true
|
||||
sudo groupdel meshbot || true
|
||||
sudo userdel meshbot || true
|
||||
|
||||
sudo rm -rf /opt/meshing-around/
|
||||
|
||||
# If Ollama was installed and you want to remove it:
|
||||
if [[ -f /etc/systemd/system/ollama.service ]]; then
|
||||
read -p "Ollama service detected. Do you want to remove Ollama and all its data? (y/n): " remove_ollama
|
||||
if [[ "$remove_ollama" =~ ^[Yy] ]]; then
|
||||
sudo systemctl stop ollama || true
|
||||
sudo systemctl disable ollama || true
|
||||
sudo rm -f /etc/systemd/system/ollama.service
|
||||
sudo rm -rf /usr/local/bin/ollama
|
||||
sudo rm -rf ~/.ollama
|
||||
# remove ollama service account if exists
|
||||
if id ollama &>/dev/null; then
|
||||
sudo userdel ollama || true
|
||||
fi
|
||||
# remove ollama group if exists
|
||||
if getent group ollama &>/dev/null; then
|
||||
sudo groupdel ollama || true
|
||||
fi
|
||||
echo "Ollama removed."
|
||||
else
|
||||
echo "Ollama not removed."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Uninstall complete. Hope to see you again! 73"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# install.sh, Meshing Around installer script
|
||||
# Thanks for using Meshing Around!
|
||||
echo "=============================================="
|
||||
echo " Meshing Around Automated Installer "
|
||||
echo "=============================================="
|
||||
echo
|
||||
echo "This script will attempt to install the Meshing Around Bot and its dependencies."
|
||||
echo "Recommended for Raspbian, Debian, Ubuntu, or Foxbuntu embedded systems."
|
||||
echo "If you encounter any issues, try running the installer again."
|
||||
echo
|
||||
echo "----------------------------------------------"
|
||||
echo "Checking for dependencies..."
|
||||
echo "----------------------------------------------"
|
||||
# check if we have an existing installation
|
||||
if [[ -f config.ini ]]; then
|
||||
echo
|
||||
echo "=========================================================="
|
||||
echo " Detected existing installation of Meshing Around."
|
||||
echo " Please backup and remove the existing installation"
|
||||
echo " before proceeding with a new install."
|
||||
echo "=========================================================="
|
||||
exit 1
|
||||
fi
|
||||
# check if we have write access to the install path
|
||||
if [[ ! -w ${program_path} ]]; then
|
||||
echo
|
||||
echo "=========================================================="
|
||||
echo " ERROR: Install path not writable."
|
||||
echo " Try running the installer with sudo?"
|
||||
echo "=========================================================="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# check if we have git and curl installed
|
||||
if ! command -v git &> /dev/null
|
||||
then
|
||||
printf "git not found, trying 'apt-get install git'\n"
|
||||
sudo apt-get install git
|
||||
fi
|
||||
if ! command -v curl &> /dev/null
|
||||
then
|
||||
printf "curl not found, trying 'apt-get install curl'\n"
|
||||
sudo apt-get install curl
|
||||
fi
|
||||
|
||||
# check if we are in /opt/meshing-around
|
||||
if [[ "$program_path" != "/opt/meshing-around" ]]; then
|
||||
echo "----------------------------------------------"
|
||||
echo " Project Path Decision"
|
||||
echo "----------------------------------------------"
|
||||
printf "\nIt is recommended to install Meshing Around in /opt/meshing-around if used as a service.\n"
|
||||
printf "Do you want to move the project to /opt/meshing-around now? (y/n): "
|
||||
read move
|
||||
if [[ $(echo "$move" | grep -i "^y") ]]; then
|
||||
sudo mv "$program_path" /opt/meshing-around
|
||||
cd /opt/meshing-around
|
||||
sudo git config --global --add safe.directory /opt/meshing-around
|
||||
printf "\nProject moved to /opt/meshing-around.\n"
|
||||
printf "Please re-run the installer from the new location.\n"
|
||||
exit 0
|
||||
else
|
||||
pip install -U -r requirements.txt
|
||||
echo "Continuing installation in current directory: $program_path"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf "\n\n"
|
||||
echo "Which bot do you want to install as a service? (pong/mesh/n)"
|
||||
read bot
|
||||
|
||||
#set the correct path in the service file
|
||||
program_path=$(pwd)
|
||||
|
||||
echo "----------------------------------------------"
|
||||
echo "Embedded install? auto answers install stuff..."
|
||||
echo "----------------------------------------------"
|
||||
if [[ $(hostname) == "femtofox" ]]; then
|
||||
printf "\n[INFO] Detected femtofox embedded system.\n"
|
||||
embedded="y"
|
||||
else
|
||||
printf "\nAre you installing on an embedded system (like Luckfox)?\n"
|
||||
printf "Most users should answer 'n' here. (y/n): "
|
||||
read embedded
|
||||
fi
|
||||
|
||||
|
||||
if [[ $(echo "${embedded}" | grep -i "^y") ]]; then
|
||||
printf "\nDetected embedded skipping dependency installation\n"
|
||||
else
|
||||
# Check and install dependencies
|
||||
if ! command -v python3 &> /dev/null
|
||||
then
|
||||
printf "python3 not found, trying 'apt-get install python3 python3-pip'\n"
|
||||
sudo apt-get install python3 python3-pip
|
||||
fi
|
||||
if ! command -v pip &> /dev/null
|
||||
then
|
||||
printf "pip not found, trying 'apt-get install python3-pip'\n"
|
||||
sudo apt-get install python3-pip
|
||||
fi
|
||||
|
||||
# double check for python3 and pip
|
||||
if ! command -v python3 &> /dev/null
|
||||
then
|
||||
printf "python3 not found, please install python3 with your OS\n"
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v pip &> /dev/null
|
||||
then
|
||||
printf "pip not found, please install pip with your OS\n"
|
||||
exit 1
|
||||
fi
|
||||
printf "\nDependencies installed\n"
|
||||
fi
|
||||
|
||||
echo "----------------------------------------------"
|
||||
echo "Installing service files and templates..."
|
||||
echo "----------------------------------------------"
|
||||
# bootstrap
|
||||
mkdir -p "$program_path/logs"
|
||||
mkdir -p "$program_path/data"
|
||||
|
||||
# copy service files
|
||||
cp etc/pong_bot.tmp etc/pong_bot.service
|
||||
cp etc/mesh_bot.tmp etc/mesh_bot.service
|
||||
cp etc/mesh_bot_reporting.tmp etc/mesh_bot_reporting.service
|
||||
cp etc/mesh_bot_w3_server.tmp etc/mesh_bot_w3_server.service
|
||||
|
||||
# set the correct path in the service file
|
||||
replace="s|/dir/|$program_path/|g"
|
||||
sed -i $replace etc/pong_bot.service
|
||||
sed -i $replace etc/mesh_bot.service
|
||||
sed -i "$replace" etc/pong_bot.service
|
||||
sed -i "$replace" etc/mesh_bot.service
|
||||
sed -i "$replace" etc/mesh_bot_reporting.service
|
||||
sed -i "$replace" etc/mesh_bot_w3_server.service
|
||||
|
||||
# copy modules/custom_scheduler.py template if it does not exist
|
||||
if [[ ! -f modules/custom_scheduler.py ]]; then
|
||||
cp etc/custom_scheduler.template modules/custom_scheduler.py
|
||||
printf "\nCustom scheduler template copied to modules/custom_scheduler.py\n"
|
||||
fi
|
||||
|
||||
# copy contents of etc/data to data/
|
||||
printf "\nCopying data templates to data/ directory\n"
|
||||
cp -r etc/data/* data/
|
||||
|
||||
# generate config file, check if it exists
|
||||
if [[ -f config.ini ]]; then
|
||||
printf "\nConfig file already exists, moving to backup config.old\n"
|
||||
mv config.ini config.old
|
||||
fi
|
||||
|
||||
cp config.template config.ini
|
||||
printf "\nConfig files generated!\n"
|
||||
|
||||
echo "----------------------------------------------"
|
||||
echo "Customizing configuration..."
|
||||
echo "----------------------------------------------"
|
||||
|
||||
# update lat,long in config.ini
|
||||
latlong=$(curl --silent --max-time 20 https://ipinfo.io/loc || echo "48.50,-123.0")
|
||||
IFS=',' read -r lat lon <<< "$latlong"
|
||||
sed -i "s|lat = 48.50|lat = $lat|g" config.ini
|
||||
sed -i "s|lon = -123.0|lon = $lon|g" config.ini
|
||||
echo "lat,long updated in config.ini to $latlong"
|
||||
|
||||
# check if running on embedded
|
||||
if [[ $(echo "${embedded}" | grep -i "^y") ]]; then
|
||||
printf "\nDetected embedded skipping venv\n"
|
||||
else
|
||||
printf "\nRecomended install is in a python virtual environment, do you want to use venv? (y/n)"
|
||||
read venv
|
||||
|
||||
if [[ $(echo "${venv}" | grep -i "^y") ]]; then
|
||||
# set virtual environment
|
||||
if ! python3 -m venv --help &> /dev/null; then
|
||||
printf "Python3/venv error, please install python3-venv with your OS\n"
|
||||
exit 1
|
||||
else
|
||||
echo "The Following could be messy, or take some time on slower devices."
|
||||
echo "Creating virtual environment..."
|
||||
#check if python3 has venv module
|
||||
if [[ -f venv/bin/activate ]]; then
|
||||
printf "\nFound virtual environment for python\n"
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
else
|
||||
printf "\nVirtual environment not found, trying `sudo apt-get install python3-venv`\n"
|
||||
sudo apt-get install python3-venv
|
||||
fi
|
||||
# create virtual environment
|
||||
python3 -m venv venv
|
||||
|
||||
# double check for python3-venv
|
||||
if [[ -f venv/bin/activate ]]; then
|
||||
printf "\nFound virtual environment for python\n"
|
||||
source venv/bin/activate
|
||||
else
|
||||
printf "\nPython3 venv module not found, please install python3-venv with your OS\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf "\nVirtual environment created\n"
|
||||
|
||||
# config service files for virtual environment
|
||||
replace="s|python3 mesh_bot.py|/usr/bin/bash launch.sh mesh|g"
|
||||
sed -i "$replace" etc/mesh_bot.service
|
||||
replace="s|python3 pong_bot.py|/usr/bin/bash launch.sh pong|g"
|
||||
sed -i "$replace" etc/pong_bot.service
|
||||
|
||||
# install dependencies to venv
|
||||
pip install -U -r requirements.txt
|
||||
fi
|
||||
else
|
||||
printf "\nSkipping virtual environment...\n"
|
||||
# install dependencies to system
|
||||
printf "Are you on Raspberry Pi(debian/ubuntu)?\nshould we add --break-system-packages to the pip install command? (y/n)"
|
||||
read rpi
|
||||
if [[ $(echo "${rpi}" | grep -i "^y") ]]; then
|
||||
pip install -U -r requirements.txt --break-system-packages
|
||||
else
|
||||
pip install -U -r requirements.txt
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "----------------------------------------------"
|
||||
echo "Installing bot service? - mesh or pong or none"
|
||||
echo "----------------------------------------------"
|
||||
|
||||
# if $1 is passed
|
||||
if [[ $1 == "pong" ]]; then
|
||||
bot="pong"
|
||||
elif [[ $1 == "mesh" ]] || [[ $(echo "${embedded}" | grep -i "^y") ]]; then
|
||||
bot="mesh"
|
||||
else
|
||||
printf "\n\n"
|
||||
echo "Which bot do you want to install as a service? Pong Mesh or None? (pong/mesh/n)"
|
||||
echo "Pong bot is a simple bot for network testing"
|
||||
echo "Mesh bot is a more complex bot more suited for meshing around"
|
||||
echo "None will skip the service install"
|
||||
read bot
|
||||
fi
|
||||
|
||||
# Decide which user to use for the service
|
||||
if [[ $(echo "${bot}" | grep -i "^n") ]]; then
|
||||
# Not installing as a service, use current user
|
||||
bot_user=$(whoami)
|
||||
else
|
||||
# Installing as a service (meshbot or pongbot), always use meshbot account
|
||||
if ! id meshbot &>/dev/null; then
|
||||
sudo useradd -M meshbot
|
||||
sudo usermod -L meshbot
|
||||
if ! getent group meshbot &>/dev/null; then
|
||||
sudo groupadd meshbot
|
||||
fi
|
||||
sudo usermod -a -G meshbot meshbot
|
||||
echo "Added user meshbot with no home directory"
|
||||
else
|
||||
echo "User meshbot already exists"
|
||||
fi
|
||||
bot_user="meshbot"
|
||||
fi
|
||||
|
||||
echo "----------------------------------------------"
|
||||
echo "Finalizing service installation..."
|
||||
echo "----------------------------------------------"
|
||||
|
||||
# set the correct user in the service file
|
||||
replace="s|User=pi|User=$bot_user|g"
|
||||
sed -i "$replace" etc/pong_bot.service
|
||||
sed -i "$replace" etc/mesh_bot.service
|
||||
sed -i "$replace" etc/mesh_bot_reporting.service
|
||||
sed -i "$replace" etc/mesh_bot_reporting.timer
|
||||
# set the correct group in the service file
|
||||
replace="s|Group=pi|Group=$bot_user|g"
|
||||
sed -i "$replace" etc/pong_bot.service
|
||||
sed -i "$replace" etc/mesh_bot.service
|
||||
sed -i "$replace" etc/mesh_bot_reporting.service
|
||||
sed -i "$replace" etc/mesh_bot_reporting.timer
|
||||
printf "\n service files updated\n"
|
||||
|
||||
# ask if emoji font should be installed for linux
|
||||
echo "Do you want to install the emoji font for debian linux? (y/n)"
|
||||
read emoji
|
||||
if [ $emoji == "y" ]; then
|
||||
sudo apt-get install fonts-noto-color-emoji
|
||||
echo "Emoji font installed!, reboot to load the font"
|
||||
# add user to groups for serial access
|
||||
printf "\nAdding user to dialout, bluetooth, and tty groups for serial access\n"
|
||||
sudo usermod -a -G dialout "$bot_user"
|
||||
sudo usermod -a -G tty "$bot_user"
|
||||
sudo usermod -a -G bluetooth "$bot_user"
|
||||
echo "Added user $bot_user to dialout, tty, and bluetooth groups"
|
||||
|
||||
# check and see if some sort of NTP is running
|
||||
if ! systemctl is-active --quiet ntp.service && \
|
||||
! systemctl is-active --quiet systemd-timesyncd.service && \
|
||||
! systemctl is-active --quiet chronyd.service; then
|
||||
printf "\nNo NTP service detected, it is recommended to have NTP running for proper bot operation.\n"
|
||||
fi
|
||||
|
||||
if [ $bot == "pong" ]; then
|
||||
|
||||
if [[ $(echo "${bot}" | grep -i "^p") ]]; then
|
||||
# install service for pong bot
|
||||
sudo cp etc/pong_bot.service /etc/systemd/system/
|
||||
exit 0
|
||||
sudo systemctl enable pong_bot.service
|
||||
sudo systemctl daemon-reload
|
||||
echo "to start pong bot service: systemctl start pong_bot"
|
||||
service="pong_bot"
|
||||
fi
|
||||
|
||||
if [ $bot == "mesh" ]; then
|
||||
if [[ $(echo "${bot}" | grep -i "^m") ]]; then
|
||||
# install service for mesh bot
|
||||
sudo cp etc/mesh_bot.service /etc/systemd/system/
|
||||
exit 0
|
||||
sudo systemctl enable mesh_bot.service
|
||||
sudo systemctl daemon-reload
|
||||
echo "to start mesh bot service: systemctl start mesh_bot"
|
||||
service="mesh_bot"
|
||||
fi
|
||||
|
||||
if [ $bot == "n" ]; then
|
||||
if [ -f launch.sh ]; then
|
||||
printf "\nTo run the bot, use the command: ./launch.sh\n"
|
||||
./launch.sh
|
||||
# install mesh_bot_reporting timer to run daily at 4:20 am
|
||||
echo ""
|
||||
echo "Installing mesh_bot_reporting.timer to run mesh_bot_reporting daily at 4:20 am..."
|
||||
sudo cp etc/mesh_bot_reporting.service /etc/systemd/system/
|
||||
sudo cp etc/mesh_bot_reporting.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable mesh_bot_reporting.timer
|
||||
sudo systemctl start mesh_bot_reporting.timer
|
||||
echo "mesh_bot_reporting.timer installed and enabled"
|
||||
echo "Check timer status with: systemctl status mesh_bot_reporting.timer"
|
||||
echo "List all timers with: systemctl list-timers"
|
||||
echo ""
|
||||
|
||||
# # install mesh_bot_w3_server service
|
||||
# echo "Installing mesh_bot_w3_server.service to run the web3 server..."
|
||||
# sudo cp etc/mesh_bot_w3_server.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable mesh_bot_w3_server.service
|
||||
# sudo systemctl start mesh_bot_w3_server.service
|
||||
# echo "mesh_bot_w3_server.service installed and enabled"
|
||||
# echo "Check service status with: systemctl status mesh_bot_w3_server.service"
|
||||
# echo ""
|
||||
|
||||
echo "----------------------------------------------"
|
||||
echo "Extra options for installation..."
|
||||
echo "----------------------------------------------"
|
||||
|
||||
# check if running on embedded for final steps
|
||||
if [[ $(echo "${embedded}" | grep -i "^n") ]]; then
|
||||
# ask if emoji font should be installed for linux
|
||||
printf "\nDo you want to install the emoji font for debian/ubuntu linux? (y/n)"
|
||||
read emoji
|
||||
if [[ $(echo "${emoji}" | grep -i "^y") ]]; then
|
||||
sudo apt-get install -y fonts-noto-color-emoji
|
||||
echo "Emoji font installed!, reboot to load the font"
|
||||
fi
|
||||
|
||||
printf "\nOptionally if you want to install the LLM Ollama compnents we will execute the following commands\n"
|
||||
printf "\ncurl -fsSL https://ollama.com/install.sh | sh\n"
|
||||
printf "ollama pull gemma3:270m\n"
|
||||
# ask if the user wants to install the LLM Ollama components
|
||||
printf "\nDo you want to install the LLM Ollama components? (y/n)"
|
||||
read ollama
|
||||
if [[ $(echo "${ollama}" | grep -i "^y") ]]; then
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
|
||||
# ask if want to install gemma3:latest
|
||||
printf "\n Ollama install done now we can install the gemma3:270m components\n"
|
||||
echo "Do you want to install the gemma3:270m components? (y/n)"
|
||||
read gemma
|
||||
if [[ $(echo "${gemma}" | grep -i "^y") ]]; then
|
||||
ollama pull gemma3:270m
|
||||
fi
|
||||
fi
|
||||
|
||||
# ask if the user wants to edit the ollama service for API access
|
||||
if [[ -f /etc/systemd/system/ollama.service ]]; then
|
||||
printf "\nEdit /etc/systemd/system/ollama.service and add Environment=OLLAMA_HOST=0.0.0.0 for API? (y/n)"
|
||||
read editollama
|
||||
if [[ $(echo "${editollama}" | grep -i "^y") ]]; then
|
||||
replace="s|\[Service\]|\[Service\]\nEnvironment=\"OLLAMA_HOST=0.0.0.0\"|g"
|
||||
sudo sed -i "$replace" /etc/systemd/system/ollama.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ollama.service
|
||||
printf "\nOllama service updated and restarted\n"
|
||||
fi
|
||||
# assume we want to enable ollama in config.ini
|
||||
if [[ -f config.ini ]]; then
|
||||
replace="s|ollama = False|ollama = True|g"
|
||||
sed -i "$replace" config.ini
|
||||
printf "\nOllama enabled in config.ini\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
# document the service install
|
||||
printf "To install the %s service and keep notes, reference following commands:\n\n" "$service" > install_notes.txt
|
||||
printf "sudo cp %s/etc/%s.service /etc/systemd/system/%s.service\n" "$program_path" "$service" "$service" >> install_notes.txt
|
||||
printf "sudo systemctl daemon-reload\n" >> install_notes.txt
|
||||
printf "sudo systemctl enable %s.service\n" "$service" >> install_notes.txt
|
||||
printf "sudo systemctl start %s.service\n" "$service" >> install_notes.txt
|
||||
printf "sudo systemctl status %s.service\n" "$service" >> install_notes.txt
|
||||
printf "sudo systemctl restart %s.service\n\n" "$service" >> install_notes.txt
|
||||
printf "To see logs and stop the service:\n" >> install_notes.txt
|
||||
printf "sudo journalctl -u %s.service\n" "$service" >> install_notes.txt
|
||||
printf "sudo systemctl stop %s.service\n" "$service" >> install_notes.txt
|
||||
printf "sudo systemctl disable %s.service\n" "$service" >> install_notes.txt
|
||||
printf "sudo systemctl disable %s.service\n" "$service" >> install_notes.txt
|
||||
printf "\n older chron statment to run the report generator hourly:\n" >> install_notes.txt
|
||||
#printf "0 * * * * /usr/bin/python3 $program_path/etc/report_generator5.py" >> install_notes.txt
|
||||
#printf " to edit crontab run 'crontab -e'\n" >> install_notes.txt
|
||||
printf "\nmesh_bot_reporting.timer installed to run daily at 4:20 am\n" >> install_notes.txt
|
||||
printf "Check timer status: systemctl status mesh_bot_reporting.timer\n" >> install_notes.txt
|
||||
printf "List all timers: systemctl list-timers\n" >> install_notes.txt
|
||||
printf "View timer logs: journalctl -u mesh_bot_reporting.timer\n" >> install_notes.txt
|
||||
printf "*** Stay Up to date using 'bash update.sh' ***\n" >> install_notes.txt
|
||||
printf "sudo ./update.sh && sudo -u meshbot ./launch.sh mesh_bot.py\n" >> install_notes.txt
|
||||
|
||||
if [[ $(echo "${venv}" | grep -i "^y") ]]; then
|
||||
printf "\nFor running on venv, virtual launch bot with './launch.sh mesh' in path $program_path\n" >> install_notes.txt
|
||||
fi
|
||||
|
||||
read -p "Press enter to complete the installation, these commands saved to install_notes.txt"
|
||||
else
|
||||
# we are on embedded
|
||||
# replace "type = serial" with "type = tcp" in config.ini
|
||||
replace="s|type = serial|type = tcp|g"
|
||||
sed -i "$replace" config.ini
|
||||
# replace "# hostname = meshtastic.local" with "hostname = localhost" in config.ini
|
||||
replace="s|# hostname = meshtastic.local|hostname = localhost|g"
|
||||
sed -i "$replace" config.ini
|
||||
printf "\nConfig file updated for embedded\n"
|
||||
# add service dependency for meshtasticd into service file
|
||||
#replace="s|After=network.target|After=network.target meshtasticd.service|g"
|
||||
|
||||
# document the service install
|
||||
printf "Reference following commands:\n\n" > install_notes.txt
|
||||
printf "sudo systemctl status %s.service\n" "$service" >> install_notes.txt
|
||||
printf "sudo systemctl start %s.service\n" "$service" >> install_notes.txt
|
||||
printf "sudo systemctl restart %s.service\n\n" "$service" >> install_notes.txt
|
||||
printf "To see logs and stop the service:\n" >> install_notes.txt
|
||||
printf "sudo journalctl -u %s.service\n" "$service" >> install_notes.txt
|
||||
printf "sudo systemctl stop %s.service\n" "$service" >> install_notes.txt
|
||||
printf "sudo systemctl disable %s.service\n" "$service" >> install_notes.txt
|
||||
printf "older crontab to run the report generator hourly:" >> install_notes.txt
|
||||
#printf "0 * * * * /usr/bin/python3 $program_path/etc/report_generator5.py" >> install_notes.txt
|
||||
#printf " to edit crontab run 'crontab -e'" >> install_notes.txt
|
||||
printf "\nmesh_bot_reporting.timer installed to run daily at 4:20 am\n" >> install_notes.txt
|
||||
printf "Check timer status: systemctl status mesh_bot_reporting.timer\n" >> install_notes.txt
|
||||
printf "List all timers: systemctl list-timers\n" >> install_notes.txt
|
||||
printf "*** Stay Up to date using 'bash update.sh' ***\n" >> install_notes.txt
|
||||
printf "sudo ./update.sh && sudo -u meshbot ./launch.sh mesh_bot.py\n" >> install_notes.txt
|
||||
fi
|
||||
|
||||
echo "Goodbye!"
|
||||
echo "----------------------------------------------"
|
||||
echo "Finalizing permissions..."
|
||||
echo "----------------------------------------------"
|
||||
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
|
||||
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
sudo chown -R "$bot_user:$bot_user" "$program_path/logs"
|
||||
sudo chown -R "$bot_user:$bot_user" "$program_path/data"
|
||||
sudo chown "$bot_user:$bot_user" "$program_path/config.ini"
|
||||
sudo chmod 664 "$program_path/config.ini"
|
||||
echo "Permissions set for meshbot on config.ini"
|
||||
sudo chmod 775 "$program_path/logs"
|
||||
sudo chmod 775 "$program_path/data"
|
||||
echo "Permissions set for meshbot on logs and data directories"
|
||||
|
||||
printf "\nGood time to reboot? (y/n)"
|
||||
read reboot
|
||||
if [[ $(echo "${reboot}" | grep -i "^y") ]]; then
|
||||
sudo reboot
|
||||
fi
|
||||
printf "\nInstallation complete! 73\n"
|
||||
exit 0
|
||||
|
||||
# to uninstall the product run the following commands as needed
|
||||
|
||||
# sudo systemctl stop mesh_bot
|
||||
# sudo systemctl disable mesh_bot
|
||||
|
||||
# sudo systemctl stop pong_bot
|
||||
# sudo systemctl disable pong_bot
|
||||
|
||||
# sudo systemctl stop mesh_bot_w3_server
|
||||
# sudo systemctl disable mesh_bot_w3_server
|
||||
|
||||
# sudo systemctl stop mesh_bot_reporting
|
||||
# sudo systemctl disable mesh_bot_reporting
|
||||
|
||||
# sudo rm /etc/systemd/system/mesh_bot.service
|
||||
# sudo rm /etc/systemd/system/mesh_bot_reporting
|
||||
# sudo rm /etc/systemd/system/pong_bot.service
|
||||
# sudo rm /etc/systemd/system/mesh_bot_w3_server.service
|
||||
# sudo rm /etc/systemd/system/mesh_bot_reporting.service
|
||||
# sudo rm /etc/systemd/system/mesh_bot_reporting.timer
|
||||
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl reset-failed
|
||||
|
||||
# sudo gpasswd -d meshbot dialout
|
||||
# sudo gpasswd -d meshbot tty
|
||||
# sudo gpasswd -d meshbot bluetooth
|
||||
# sudo groupdel meshbot
|
||||
# sudo userdel meshbot
|
||||
|
||||
# sudo rm -rf /opt/meshing-around/
|
||||
|
||||
# If Ollama was installed and you want to remove it:
|
||||
# sudo systemctl stop ollama
|
||||
# sudo systemctl disable ollama
|
||||
# sudo rm /etc/systemd/system/ollama.service
|
||||
# sudo rm -rf /usr/local/bin/ollama
|
||||
# sudo rm -rf ~/.ollama
|
||||
|
||||
|
||||
# if install done manually
|
||||
# copy modules/custom_scheduler.py template if it does not exist
|
||||
# copy data files from etc/data to data/
|
||||
|
||||
|
||||
#### after install shenannigans
|
||||
# add 'bee = True' to config.ini General section.
|
||||
# wget https://gist.githubusercontent.com/MattIPv4/045239bc27b16b2bcf7a3a9a4648c08a/raw/2411e31293a35f3e565f61e7490a806d4720ea7e/bee%2520movie%2520script -O bee.txt
|
||||
# place bee.txt in project root
|
||||
|
||||
####
|
||||
# download bible in text from places like https://www.biblesupersearch.com/bible-downloads/
|
||||
# in the project root place bible.txt and use verse = True
|
||||
# to use machine reading format like this
|
||||
# Genesis 1:1 In the beginning God created the heavens and the earth.
|
||||
# Genesis 1:2 And the earth was waste and void..
|
||||
# or simple format like this (less preferred)
|
||||
# Chapter 1
|
||||
# 1 In the beginning God created the heavens and the earth.
|
||||
# 2 And the earth was waste and void..
|
||||
|
||||
|
||||
|
||||
41
launch.sh
41
launch.sh
@@ -1,24 +1,43 @@
|
||||
#!/bin/bash
|
||||
# This script launches the meshing-around bot or the report generator in python virtual environment
|
||||
|
||||
# launch.sh
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# activate the virtual environment if it exists
|
||||
if [ -d "venv" ]; then
|
||||
source venv/bin/activate
|
||||
fi
|
||||
|
||||
if [ ! -f "config.ini" ]; then
|
||||
if [[ ! -f "config.ini" ]]; then
|
||||
cp config.template config.ini
|
||||
fi
|
||||
|
||||
# launch the application
|
||||
if [ "$1" == "pong" ]; then
|
||||
python pong_bot.py
|
||||
elif [ "$1" == "mesh" ]; then
|
||||
python mesh_bot.py
|
||||
# activate the virtual environment if it exists
|
||||
if [[ -d "venv" ]]; then
|
||||
source venv/bin/activate
|
||||
else
|
||||
printf "\nPlease provide a bot to launch (pong/mesh)"
|
||||
echo "Virtual environment not found, this tool just launches the .py in venv"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
|
||||
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
# launch the application
|
||||
if [[ "$1" == pong* ]]; then
|
||||
python3 pong_bot.py
|
||||
elif [[ "$1" == mesh* ]]; then
|
||||
python3 mesh_bot.py
|
||||
elif [[ "$1" == "html" ]]; then
|
||||
python3 etc/report_generator.py
|
||||
elif [[ "$1" == "html5" ]]; then
|
||||
python3 etc/report_generator5.py
|
||||
elif [[ "$1" == add* ]]; then
|
||||
python3 script/addFav.py
|
||||
elif [[ "$1" == "game" ]]; then
|
||||
python3 script/game_serve.py
|
||||
elif [[ "$1" == "display" ]]; then
|
||||
python3 script/game_serve.py
|
||||
else
|
||||
echo "Please provide a bot to launch (pong/mesh/display) or a report to generate (html/html5) or addFav"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
deactivate
|
||||
47
logs/README.md
Normal file
47
logs/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Logs and Reports
|
||||
|
||||
This directory stores log files generated by the Mesh Bot. To generate useful reports, ensure you have at least a day's worth of logs or a substantial number of messages.
|
||||
|
||||
## Reporting
|
||||
|
||||
Reports are generated using [`../etc/report_generator5.py`](../etc/report_generator5.py), which produces modern HTML5 reports. The output (`index.html`) is saved in [`../etc/www`](../etc/www) by default. A `.cfg` configuration file is created on first run, allowing you to customize settings such as the web root directory.
|
||||
|
||||
- Ensure `SyslogToFile = True` and `sysloglevel = DEBUG` in your configuration to enable full reporting.
|
||||
- If using a virtual environment and `launch.sh`, you can run:
|
||||
```sh
|
||||
launch.sh html5
|
||||
```
|
||||
|
||||

|
||||
|
||||
## Settings
|
||||
Logging messages to disk or 'Syslog' to disk uses the python native logging function.
|
||||
```conf
|
||||
[general]
|
||||
# logging to file of the non Bot messages only
|
||||
LogMessagesToFile = False
|
||||
# Logging of system messages to file, needed for reporting engine
|
||||
SyslogToFile = True
|
||||
# logging level for the bot (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
sysloglevel = DEBUG
|
||||
# Number of log files to keep in days, 0 to keep all
|
||||
log_backup_count = 32
|
||||
```
|
||||
## Web Reporting WebServer
|
||||
There is a web-server module. You can run `python3 modules/web.py` from the project root directory and it will serve up the web content.
|
||||
|
||||
find it at. http://localhost:8420
|
||||
|
||||
If you have linux-native running and errors such as..
|
||||
```bash
|
||||
File "/usr/lib/python3.11/http/server.py", line 136, in server_bind
|
||||
socketserver.TCPServer.server_bind(self)
|
||||
File "/usr/lib/python3.11/socketserver.py", line 472, in server_bind
|
||||
self.socket.bind(self.server_address)
|
||||
```
|
||||
modify the modules/web.py to use a real IP address, meshtasticD-native is binding to 127.0.0.1
|
||||
|
||||
```python
|
||||
# Set the desired IP address
|
||||
server_ip = '127.0.0.1'
|
||||
```
|
||||
2558
mesh_bot.py
2558
mesh_bot.py
File diff suppressed because it is too large
Load Diff
1406
modules/README.md
Normal file
1406
modules/README.md
Normal file
File diff suppressed because it is too large
Load Diff
207
modules/adding_more.md
Normal file
207
modules/adding_more.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Modules and Adding Features
|
||||
|
||||
This document explains how to add new modules and commands to your Meshtastic mesh-bot project.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Adding a New Command](#adding-a-new-command)
|
||||
- [Running a Shell Command](#running-a-shell-command)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Technical Assistance & Troubleshooting](#technical-assistance--troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
For code testing, see `etc/simulator.py` to simulate a bot.
|
||||
You can also use `meshtasticd` (Linux-native) in `noradio` mode with MQTT server and client to emulate a mesh network.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Command
|
||||
|
||||
Follow these steps to add a new BBS option or command to the bot:
|
||||
|
||||
### 1. Define the Command Handler
|
||||
|
||||
Add a new function in `mesh_bot.py` to handle your command.
|
||||
Example for a command called `newcommand`:
|
||||
|
||||
```python
|
||||
def handle_newcommand(message, message_from_id, deviceID):
|
||||
return "This is a response from the new command."
|
||||
```
|
||||
|
||||
If your command is complex, consider creating a new module (e.g., `modules/newcommand.py`).
|
||||
Import your new module where needed (see `modules/system.py` for examples).
|
||||
|
||||
---
|
||||
|
||||
### 2. Add the Command to the Auto Response
|
||||
|
||||
Update the `auto_response` function in `mesh_bot.py` to include your new command:
|
||||
|
||||
```python
|
||||
def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_number, deviceID, isDM):
|
||||
#...
|
||||
"newcommand": lambda: handle_newcommand(message, message_from_id, deviceID),
|
||||
#...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Update the Trap List and Help
|
||||
|
||||
Edit `modules/system.py` to include your new command in the trap list and help message:
|
||||
|
||||
```python
|
||||
#...
|
||||
trap_list = ("cmd", "cmd?", "newcommand") # Add your command here
|
||||
help_message = "Bot CMD?:newcommand, "
|
||||
#...
|
||||
```
|
||||
|
||||
**Preferred method:**
|
||||
Add a configuration block below `ping` (around line 28):
|
||||
|
||||
```python
|
||||
# newcommand Configuration
|
||||
newcommand_enabled = True # settings.py handles config.ini values; this is a placeholder
|
||||
if newcommand_enabled:
|
||||
trap_list_newcommand = ("newcommand",)
|
||||
trap_list = trap_list + trap_list_newcommand
|
||||
help_message = help_message + ", newcommand"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Test the New Command
|
||||
|
||||
Run MeshBot and test your new command by sending a message with `newcommand` to ensure it responds correctly.
|
||||
|
||||
---
|
||||
|
||||
## Running a Shell Command
|
||||
|
||||
You can make a command that calls a bash script on the system (requires the `filemon` module):
|
||||
|
||||
```python
|
||||
def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_number, deviceID, isDM):
|
||||
#...
|
||||
"switchON": lambda: call_external_script(message)
|
||||
```
|
||||
|
||||
This will call the default script located at `script/runShell.sh` and return its output.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Modularize:** Place complex or reusable logic in its own module.
|
||||
- **Document:** Add docstrings and comments to your functions.
|
||||
- **Test:** Use the simulator or a test mesh to verify new features.
|
||||
- **Update Help:** Keep the help message and trap list up to date for users.
|
||||
- **Configuration:** Use `settings.py` and `config.ini` for feature toggles and settings.
|
||||
|
||||
---
|
||||
|
||||
## Technical Assistance & Troubleshooting
|
||||
|
||||
- **Debug Logging:**
|
||||
Use the `logger` module for debug output. Check logs for errors or unexpected behavior.
|
||||
- **Common Issues:**
|
||||
- *Module Import Errors:* Ensure your new module is in the `modules/` directory and imported correctly.
|
||||
- *Command Not Responding:* Verify your command is in the trap list and auto_response dictionary.
|
||||
- *Configuration Problems:* Double-check `settings.py` and `config.ini` for typos or missing entries.
|
||||
- **Testing:**
|
||||
- Use `etc/simulator.py` for local testing without radio hardware.
|
||||
- Use `meshtasticd` in `noradio` mode for network emulation.
|
||||
- **Python Environment:**
|
||||
- Use a virtual environment (`python3 -m venv venv`) to manage dependencies.
|
||||
- Install requirements with `pip install -r requirements.txt`.
|
||||
- **Updating Dependencies:**
|
||||
- try not to I want to remove some.
|
||||
- **Getting Help:**
|
||||
- Check the project wiki or issues page for common questions.
|
||||
- Use inline comments and docstrings for clarity.
|
||||
- If you’re stuck, ask for help on the project’s GitHub Discussions or Issues tab.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Overview Unit Tests
|
||||
|
||||
Your test_bot.py file contains a comprehensive suite of unit tests for the various modules the project. The tests are organized using Python’s `unittest` framework and cover both core utility modules and all major game modules.
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
- **Imports & Setup:**
|
||||
The script sets up the environment, imports all necessary modules, and suppresses certain warnings for clean test output.
|
||||
|
||||
- **TestBot Class:**
|
||||
All tests are methods of the `TestBot` class, which inherits from `unittest.TestCase`.
|
||||
|
||||
---
|
||||
|
||||
## Core Module Tests
|
||||
|
||||
- **Database & Checklist:**
|
||||
- `test_load_bbsdb`, `test_bbs_list_messages`, `test_initialize_checklist_database`
|
||||
- **News & Alerts:**
|
||||
- `test_init_news_sources`, `test_get_nina_alerts`
|
||||
- **LLM & Wikipedia:**
|
||||
- `test_llmTool_get_google`, `test_send_ollama_query`, `test_get_wikipedia_summary`, `test_get_kiwix_summary`
|
||||
- **Space & Weather:**
|
||||
- `test_get_moon_phase`, `test_get_sun_times`, `test_hf_band_conditions`
|
||||
- **Radio & Location:**
|
||||
- `test_get_hamlib`, `test_get_rss_feed`, `get_openskynetwork`, `test_initalize_qrz_database`
|
||||
|
||||
---
|
||||
|
||||
## Game Module Tests
|
||||
|
||||
Each game module has a dedicated test that simulates a typical user interaction:
|
||||
|
||||
- **Tic-Tac-Toe:**
|
||||
Starts a game and makes one move.
|
||||
- **Video Poker:**
|
||||
Starts a session and places a bet.
|
||||
- **Blackjack:**
|
||||
Starts a game and places a bet.
|
||||
- **Hangman:**
|
||||
Starts a game and guesses a letter.
|
||||
- **Lemonade Stand:**
|
||||
Starts a game and buys a box of cups.
|
||||
- **GolfSim:**
|
||||
Starts a hole and takes a shot.
|
||||
- **DopeWars:**
|
||||
Starts a game, selects a city, and checks the list.
|
||||
- **MasterMind:**
|
||||
Starts a game and makes one guess.
|
||||
- **Quiz:**
|
||||
Starts a quiz, joins as a player, answers one question, and ends the quiz.
|
||||
- **Survey:**
|
||||
Starts a survey, answers one question, and ends the survey.
|
||||
- **HamTest:**
|
||||
Starts a ham radio test and answers one question.
|
||||
|
||||
---
|
||||
|
||||
## Extended API Tests
|
||||
|
||||
If the `.checkall` file is present, additional API and data-fetching tests are run for:
|
||||
- RepeaterBook, ArtSciRepeaters, NOAA tides/weather, USGS earthquakes/volcanoes, satellite passes, and more.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tests are designed to be **non-destructive** and **idempotent**.
|
||||
- Some tests require specific data files (e.g., for quiz, survey, hamtest).
|
||||
- The suite is intended to be run from the main program directory.
|
||||
|
||||
|
||||
|
||||
|
||||
Happy hacking!
|
||||
234
modules/bbstools.md
Normal file
234
modules/bbstools.md
Normal file
@@ -0,0 +1,234 @@
|
||||
|
||||
---
|
||||
|
||||
# 📡 meshBBS: How-To & API Documentation
|
||||
|
||||
This document covers the Bulliten Board System or BBS componment of the meshing-around project.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [BBS Core Functions](#1-bbs-core-functions)
|
||||
- [Central Message Store](#11-central-message-store)
|
||||
- [Direct Mail (DM) Messages](#12-direct-mail-dm-messages)
|
||||
- [BBS Commands](#bbs-commands)
|
||||
2. [Synchronization bot2bot: Full Sync Workflow](#2-synchronization-bot2bot--full-sync-workflow)
|
||||
- [BBS Database Sync: File-Based (Out-of-Band)](#21-bbs-database-sync-file-based-out-of-band)
|
||||
- [BBS Over-the-Air (OTA) Sync: Linking](#22-bbs-over-the-air-ota-sync-linking)
|
||||
- [Scheduling BBS Auto Sync](#23-scheduling-bbs-auto-sync)
|
||||
3. [Troubleshooting](#4-troubleshooting)
|
||||
4. [API Reference: BBS Sync](#5-api-reference-bbs-sync)
|
||||
5. [Best Practices](#5-best-practices)
|
||||
|
||||
## 1. **BBS Core Functions**
|
||||
The mesh-bot provides a basic message mail system for Meshtastic
|
||||
|
||||
## 1.1 Central Message Store
|
||||
|
||||
- **Shared public message space** for all nodes.
|
||||
- Classic BBS list with a simple, one-level message tree.
|
||||
- Messages are stored in `data/bbsdb.pkl`.
|
||||
- Each entry typically includes:
|
||||
`[id, subject, body, fromNode, timestamp, threadID, replytoID]`
|
||||
|
||||
### Posting to Public
|
||||
|
||||
To post a public message:
|
||||
```sh
|
||||
bbspost $Subject #Message
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1.2 Direct Mail (DM) Messages
|
||||
- **DMs are private messages** sent from one node to another.
|
||||
- Stored separately from public posts in `data/bbsdm.pkl`.
|
||||
- Each DM entry typically includes:
|
||||
`[id, toNode, message, fromNode, timestamp, threadID, replytoID]`
|
||||
- You can inject DMs directly for automation using the `script/injectDM.py` tool.
|
||||
|
||||
### DM Delivery
|
||||
|
||||
- To post a DM, use:
|
||||
```sh
|
||||
bbspost @USER #Message
|
||||
```
|
||||
- When a DM is posted, it is added to the DM database.
|
||||
- When the bot detects the recipient node on the network, it delivers the DM and then removes it from local storage.
|
||||
|
||||
---
|
||||
|
||||
### BBS Commands
|
||||
|
||||
| Command | Description |
|
||||
|--------------|-----------------------------------------------|
|
||||
| `bbshelp` | Show BBS help |
|
||||
| `bbslist` | List messages |
|
||||
| `bbsread` | Read a message by ID |
|
||||
| `bbspost` | Post a message or DM |
|
||||
| `bbsdelete` | Delete a message |
|
||||
| `bbsinfo` | BBS stats (sysop) |
|
||||
| `bbslink` | Link messages between BBS systems |
|
||||
|
||||
---
|
||||
|
||||
## 2. **Synchronization bot2bot : Full Sync Workflow**
|
||||
|
||||
1. **Set up a dedicated sync channel** (e.g., channel bot-admin).
|
||||
2. **Configure both nodes** with `bbs_link_enabled = True` and add each other to `bbs_link_whitelist`.
|
||||
3. **Schedule sync** every hour:
|
||||
- Node A sends `bbslink 0` to Node B on channel 99.
|
||||
- Node B responds with messages and `bbsack`.
|
||||
4. **Optionally, use SSH/scp** to copy `bbsdb.pkl` for full out-of-band backup.
|
||||
|
||||
|
||||
## 2.1. **BBS Database Sync: File-Based (Out-of-Band)**
|
||||
|
||||
### **Manual/Automated File Sync (e.g., SSH/SCP)**
|
||||
- **Purpose:** Sync BBS data between nodes by copying `bbsdb.pkl` and `bbsdm.pkl` files.
|
||||
```ini
|
||||
[bbs]
|
||||
# The "api" needs enabled which enables file polling
|
||||
bbsAPI_enabled = True
|
||||
```
|
||||
- **How-To:**
|
||||
1. **Locate Files:**
|
||||
- `data/bbsdb.pkl` (public posts)
|
||||
- `data/bbsdm.pkl` (direct messages)
|
||||
2. **Copy Files:**
|
||||
Use `scp` or `rsync` to copy files between nodes:
|
||||
```sh
|
||||
scp user@remote:/path/to/meshing-around/data/bbsdb.pkl ./data/bbsdb.pkl
|
||||
scp user@remote:/path/to/meshing-around/data/bbsdm.pkl ./data/bbsdm.pkl
|
||||
```
|
||||
3. **Reload Database:**
|
||||
After copying, when the "API" is enabled the watchdog will look for changes and injest.
|
||||
|
||||
- **Automating with Cron/Scheduler:**
|
||||
- Set up a cron job or use the bot’s scheduler to periodically pull/push files.
|
||||
|
||||
---
|
||||
|
||||
## 2.2. **BBS Over-the-Air (OTA) Sync: Linking**
|
||||
### **How OTA Sync Works**
|
||||
- Nodes can exchange BBS messages using special commands over the mesh network.
|
||||
- Uses `bbslink` and `bbsack` commands for message exchange.
|
||||
- Future supports compression for bandwidth efficiency.
|
||||
|
||||
### **Enabling BBS Linking**
|
||||
- Set `bbs_link_enabled = True` in your config.
|
||||
- Optionally, set `bbs_link_whitelist` to restrict which nodes can sync.
|
||||
|
||||
### **Manual Sync Command**
|
||||
- To troubleshoot request sync from another node, send:
|
||||
```
|
||||
bbslink <messageID> $<subject> #<body>
|
||||
```
|
||||
- The receiving node will respond with `bbsack <messageID>`.
|
||||
|
||||
### **Out-of-Band Channel**
|
||||
- For high-reliability sync, configure a dedicated channel (not used for chat).
|
||||
---
|
||||
|
||||
## 2.3. **Scheduling BBS Auto Sync**
|
||||
|
||||
### **Using the Bot’s Scheduler**
|
||||
|
||||
- You can schedule periodic sync requests to a peer node.
|
||||
- Example: Every hour, send a `bbslink` request to a peer.
|
||||
see more at [Module Readme](README.md#scheduler)
|
||||
|
||||
---
|
||||
|
||||
#### BBS Link
|
||||
The scheduler also handles the BBS Link Broadcast message, this would be an example of a mesh-admin channel on 8 being used to pass BBS post traffic between two bots as the initiator, one direction pull. The message just needs to have bbslink
|
||||
|
||||
```ini
|
||||
[bbs]
|
||||
bbslink_enabled = True
|
||||
bbslink_whitelist = # list of whitelisted nodes numbers ex: 2813308004,4258675309 empty list allows all
|
||||
|
||||
[scheduler]
|
||||
enabled = True
|
||||
interface = 1
|
||||
channel = 2
|
||||
value = link
|
||||
interval = 12 # 12 hours
|
||||
```
|
||||
|
||||
```python
|
||||
# Custom Schedule Example if using custom for [scheduler]
|
||||
# Send bbslink looking for peers every 2 days at 10 AM
|
||||
schedule.every(2).days.at("10:00").do(send_message("bbslink MeshBot looking for peers", schedulerChannel, 0, schedulerInterface))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 4. **Troubleshooting**
|
||||
|
||||
- **Messages not syncing?**
|
||||
- Check `bbs_link_enabled` and whitelist settings.
|
||||
- Ensure both nodes are on the same sync channel.
|
||||
- Check logs for errors.
|
||||
|
||||
- **File sync issues?**
|
||||
- Verify file permissions and paths.
|
||||
- Ensure the bot reloads the database after file copy.
|
||||
|
||||
- **Custom file problems?**
|
||||
- remove the custom_scheduler.py and replace it with [etc/custom_scheduler.py](etc/custom_scheduler.py)
|
||||
|
||||
The bbs link command should include `bbslink`
|
||||
`.do(send_message("bbslink MeshBot looking for peers", schedulerChannel, 0, schedulerInterface))`
|
||||
|
||||
```ini
|
||||
[bbs]
|
||||
# The "api" needs enabled which enables file polling and use of `script/injectDM.py`
|
||||
bbsAPI_enabled = True
|
||||
```
|
||||
|
||||
## 5. **API Reference: BBS Sync**
|
||||
|
||||
### **Key Functions in Python**
|
||||
| Function | Purpose | Usage Example |
|
||||
|-------------------------|-------------------------------------------|----------------------------------------------------|
|
||||
| `bbs_post_message()` | Post a new public message | `bbs_post_message(subject, body, fromNode)` |
|
||||
| `bbs_read_message()` | Read a message by ID | `bbs_read_message(messageID)` |
|
||||
| `bbs_delete_message()` | Delete a message (admin/owner only) | `bbs_delete_message(messageID, fromNode)` |
|
||||
| `bbs_list_messages()` | List all message subjects | `bbs_list_messages()` |
|
||||
| `bbs_post_dm()` | Post a direct message | `bbs_post_dm(toNode, message, fromNode)` |
|
||||
| `bbs_check_dm()` | Check for DMs for a node | `bbs_check_dm(toNode)` |
|
||||
| `bbs_delete_dm()` | Delete a DM after reading | `bbs_delete_dm(toNode, message)` |
|
||||
| `get_bbs_stats()` | Get stats on BBS and DMs | `get_bbs_stats()` |
|
||||
|
||||
|
||||
| Function | Purpose |
|
||||
|---------------------------|-------------------------------------------|
|
||||
| `bbs_sync_posts()` | Handles incoming/outgoing sync requests |
|
||||
| `bbs_receive_compressed()`| Handles compressed sync data |
|
||||
| `compress_data()` | Compresses data for OTA transfer |
|
||||
| `decompress_data()` | Decompresses received data |
|
||||
|
||||
|
||||
### **Handle Incoming Sync**
|
||||
- The bot automatically processes incoming `bbslink` and `bbsack` commands via `bbs_sync_posts()`.
|
||||
|
||||
### **Compressed Sync**
|
||||
Future Use
|
||||
- If `useSynchCompression` is enabled, use:
|
||||
```python
|
||||
compressed = compress_data(msg)
|
||||
send_raw_bytes(peerNode, compressed)
|
||||
```
|
||||
- Receiving node uses `bbs_receive_compressed()`.
|
||||
|
||||
---
|
||||
### 5. **Best Practices**
|
||||
|
||||
- **Backup:** Regularly back up `bbsdb.pkl` and `bbsdm.pkl`.
|
||||
- **Security:** Use SSH keys for file transfer; restrict OTA sync to trusted nodes.
|
||||
- **Reliability:** Use a dedicated channel for BBS sync to avoid chat congestion.
|
||||
- **Automation:** Use the scheduler for regular syncs, both file-based and OTA.
|
||||
|
||||
---
|
||||
@@ -2,32 +2,62 @@
|
||||
# K7MHI Kelly Keeton 2024
|
||||
|
||||
import pickle # pip install pickle
|
||||
from modules.log import *
|
||||
from modules.log import logger
|
||||
from modules.settings import bbs_admin_list, bbs_ban_list, MESSAGE_CHUNK_SIZE, bbs_link_enabled, bbs_link_whitelist, responseDelay
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
trap_list_bbs = ("bbslist", "bbspost", "bbsread", "bbsdelete", "bbshelp")
|
||||
useSynchCompression = False
|
||||
|
||||
if useSynchCompression:
|
||||
import zlib
|
||||
from modules.system import send_raw_bytes
|
||||
|
||||
trap_list_bbs = ("bbslist", "bbspost", "bbsread", "bbsdelete", "bbshelp", "bbsinfo", "bbslink", "bbsack")
|
||||
|
||||
# global message list, later we will use a pickle on disk
|
||||
bbs_messages = []
|
||||
bbs_dm = []
|
||||
|
||||
|
||||
def load_bbsdb():
|
||||
global bbs_messages
|
||||
# load the bbs messages from the database file
|
||||
try:
|
||||
with open('bbsdb.pkl', 'rb') as f:
|
||||
bbs_messages = pickle.load(f)
|
||||
except:
|
||||
bbs_messages = [[1, "Welcome to meshBBS", "Welcome to the BBS, please post a message!",0]]
|
||||
logger.debug("\nSystem: Creating new bbsdb.pkl")
|
||||
with open('bbsdb.pkl', 'wb') as f:
|
||||
pickle.dump(bbs_messages, f)
|
||||
with open('data/bbsdb.pkl', 'rb') as f:
|
||||
new_bbs_messages = pickle.load(f)
|
||||
if isinstance(new_bbs_messages, list):
|
||||
for msg in new_bbs_messages:
|
||||
msgHash = hash(tuple(msg[1:3]))
|
||||
if all(hash(tuple(existing_msg[1:3])) != msgHash for existing_msg in bbs_messages):
|
||||
new_id = len(bbs_messages) + 1
|
||||
bbs_messages.append([new_id, msg[1], msg[2], msg[3]])
|
||||
return True # Loaded successfully, regardless of whether new messages were added
|
||||
return False # File existed but did not contain a valid list of messages (possibly corrupted)
|
||||
except FileNotFoundError:
|
||||
# create a new bbsdb.pkl with a welcome message
|
||||
# template ([messageID, subject, message, fromNode, now, thread, replyto])
|
||||
bbs_messages = [[1, "Welcome to meshBBS", "Welcome to the BBS, please post a message!",0,time.strftime('%Y-%m-%d %H:%M:%S'),0,0]]
|
||||
logger.debug("System: bbsdb.pkl not found, creating new one")
|
||||
try:
|
||||
with open('data/bbsdb.pkl', 'wb') as f:
|
||||
pickle.dump(bbs_messages, f)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"System: Error creating bbsdb.pkl: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"System: Error loading bbsdb.pkl: {e}")
|
||||
return False
|
||||
|
||||
def save_bbsdb():
|
||||
global bbs_messages
|
||||
# save the bbs messages to the database file
|
||||
logger.debug("System: Saving bbsdb.pkl\n")
|
||||
with open('bbsdb.pkl', 'wb') as f:
|
||||
pickle.dump(bbs_messages, f)
|
||||
try:
|
||||
logger.debug("System: Saving data/bbsdb.pkl")
|
||||
with open('data/bbsdb.pkl', 'wb') as f:
|
||||
pickle.dump(bbs_messages, f)
|
||||
except Exception as e:
|
||||
logger.error(f"System: Error saving bbsdb: {e}")
|
||||
|
||||
def bbs_help():
|
||||
# help message
|
||||
@@ -39,7 +69,7 @@ def bbs_list_messages():
|
||||
message_list = ""
|
||||
for message in bbs_messages:
|
||||
# message[0] is the messageID, message[1] is the subject
|
||||
message_list += "Msg #" + str(message[0]) + " " + message[1] + "\n"
|
||||
message_list += "[#" + str(message[0]) + "] " + message[1] + "\n"
|
||||
|
||||
# last newline removed
|
||||
message_list = message_list[:-1]
|
||||
@@ -69,7 +99,11 @@ def bbs_delete_message(messageID = 0, fromNode = 0):
|
||||
else:
|
||||
return "Please specify a message number to delete."
|
||||
|
||||
def bbs_post_message(subject, message, fromNode):
|
||||
def bbs_post_message(subject, message, fromNode, threadID=0, replytoID=0):
|
||||
# post a message to the bbsdb
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
thread = threadID
|
||||
replyto = replytoID
|
||||
# post a message to the bbsdb and assign a messageID
|
||||
messageID = len(bbs_messages) + 1
|
||||
|
||||
@@ -77,9 +111,17 @@ def bbs_post_message(subject, message, fromNode):
|
||||
if str(fromNode) in bbs_ban_list:
|
||||
logger.warning(f"System: Naughty node {fromNode}, tried to post a message: {subject}, {message} and was dropped.")
|
||||
return "Message posted. ID is: " + str(messageID)
|
||||
|
||||
# validate message length isnt three times the MESSAGE_CHUNK_SIZE
|
||||
if len(message) > (3 * MESSAGE_CHUNK_SIZE):
|
||||
return "Message too long, max length is " + str(3 * MESSAGE_CHUNK_SIZE) + " characters."
|
||||
# validate not a duplicate message
|
||||
for msg in bbs_messages:
|
||||
if msg[1].strip().lower() == subject.strip().lower() and msg[2].strip().lower() == message.strip().lower():
|
||||
messageID = msg[0]
|
||||
return "Message posted. ID is: " + str(messageID)
|
||||
# validate its not overlength by keeping in chunker limit
|
||||
# append the message to the list
|
||||
bbs_messages.append([messageID, subject, message, fromNode])
|
||||
bbs_messages.append([messageID, subject, message, fromNode, now, thread, replyto])
|
||||
logger.info(f"System: NEW Message Posted, subject: {subject}, message: {message} from {fromNode}")
|
||||
|
||||
# save the bbsdb
|
||||
@@ -92,28 +134,34 @@ def bbs_read_message(messageID = 0):
|
||||
if (messageID - 1) >= len(bbs_messages):
|
||||
return "Message not found."
|
||||
if messageID > 0:
|
||||
fromNode = bbs_messages[messageID - 1][3]
|
||||
fromNodeHex = hex(fromNode)[-4:]
|
||||
message = bbs_messages[messageID - 1]
|
||||
return f"Msg #{message[0]}\nMsg Body: {message[2]}"
|
||||
return f"Msg #{message[0]}\nFrom:{fromNodeHex}\n{message[2]}"
|
||||
else:
|
||||
return "Please specify a message number to read."
|
||||
|
||||
def save_bbsdm():
|
||||
global bbs_dm
|
||||
# save the bbs messages to the database file
|
||||
logger.debug("System: Saving Updated BBS Direct Messages bbsdm.pkl")
|
||||
with open('bbsdm.pkl', 'wb') as f:
|
||||
logger.debug("System: Saving Updated BBS Direct Messages data/bbsdm.pkl")
|
||||
with open('data/bbsdm.pkl', 'wb') as f:
|
||||
pickle.dump(bbs_dm, f)
|
||||
|
||||
def load_bbsdm():
|
||||
global bbs_dm
|
||||
# load the bbs messages from the database file
|
||||
try:
|
||||
with open('bbsdm.pkl', 'rb') as f:
|
||||
bbs_dm = pickle.load(f)
|
||||
with open('data/bbsdm.pkl', 'rb') as f:
|
||||
new_bbs_dm = pickle.load(f)
|
||||
if isinstance(new_bbs_dm, list):
|
||||
for msg in new_bbs_dm:
|
||||
if msg not in bbs_dm:
|
||||
bbs_dm.append(msg)
|
||||
except:
|
||||
bbs_dm = [[1234567890, "Message", 1234567890]]
|
||||
logger.debug("\nSystem: Creating new bbsdm.pkl")
|
||||
with open('bbsdm.pkl', 'wb') as f:
|
||||
logger.debug("System: Creating new data/bbsdm.pkl")
|
||||
with open('data/bbsdm.pkl', 'wb') as f:
|
||||
pickle.dump(bbs_dm, f)
|
||||
|
||||
def bbs_post_dm(toNode, message, fromNode):
|
||||
@@ -122,6 +170,14 @@ def bbs_post_dm(toNode, message, fromNode):
|
||||
if str(fromNode) in bbs_ban_list:
|
||||
logger.warning(f"System: Naughty node {fromNode}, tried to post a message: {message} and was dropped.")
|
||||
return "DM Posted for node " + str(toNode)
|
||||
|
||||
# validate message length isnt three times the MESSAGE_CHUNK_SIZE
|
||||
if len(message) > (3 * MESSAGE_CHUNK_SIZE):
|
||||
return "Message too long, max length is " + str(3 * MESSAGE_CHUNK_SIZE) + " characters."
|
||||
# validate not a duplicate message
|
||||
for msg in bbs_dm:
|
||||
if msg[0] == int(toNode) and msg[1].strip().lower() == message.strip().lower():
|
||||
return "DM Posted for node " + str(toNode)
|
||||
|
||||
# append the message to the list
|
||||
bbs_dm.append([int(toNode), message, int(fromNode)])
|
||||
@@ -130,6 +186,11 @@ def bbs_post_dm(toNode, message, fromNode):
|
||||
save_bbsdm()
|
||||
return "BBS DM Posted for node " + str(toNode)
|
||||
|
||||
def get_bbs_stats():
|
||||
global bbs_messages, bbs_dm
|
||||
# Return some stats on the bbs pending messages and total posted messages
|
||||
return f"📡BBSdb has {len(bbs_messages)} messages.\nDirect ✉️ Messages waiting: {(len(bbs_dm) - 1)}"
|
||||
|
||||
def bbs_check_dm(toNode):
|
||||
global bbs_dm
|
||||
# Check for any messages for toNode
|
||||
@@ -151,6 +212,99 @@ def bbs_delete_dm(toNode, message):
|
||||
return "System: cleared mail for" + str(toNode)
|
||||
return "System: No DM found for node " + str(toNode)
|
||||
|
||||
def compress_data(data_to_compress):
|
||||
# Prepare message as bytes
|
||||
compressed = zlib.compress(data_to_compress.encode('utf-8'))
|
||||
return compressed
|
||||
|
||||
def decompress_data(data_bytes):
|
||||
try:
|
||||
decompressed = zlib.decompress(data_bytes)
|
||||
msg = decompressed.decode('utf-8')
|
||||
return msg
|
||||
except Exception as e:
|
||||
logger.warning(f"Error decompressing data: {e}")
|
||||
return False
|
||||
|
||||
def bbs_receive_compressed(data_bytes, fromNode, RxNode):
|
||||
try:
|
||||
decompressed = zlib.decompress(data_bytes)
|
||||
msg = decompressed.decode('utf-8')
|
||||
|
||||
bbs_sync_posts(msg, fromNode, RxNode)
|
||||
|
||||
return msg
|
||||
except Exception as e:
|
||||
logger.error(f"Error decompressing BBS message: {e}")
|
||||
return None
|
||||
|
||||
def bbs_sync_posts(input, peerNode, RxNode):
|
||||
messageID = 0
|
||||
|
||||
# check if the bbs link is enabled
|
||||
if bbs_link_whitelist != ['']:
|
||||
if str(peerNode) not in bbs_link_whitelist:
|
||||
logger.warning(f"System: BBS Link is disabled for node {peerNode}.")
|
||||
return "System: BBS Link is disabled for your node."
|
||||
if bbs_link_enabled == False:
|
||||
return "System: BBS Link is disabled."
|
||||
|
||||
# respond when another bot asks for the bbs posts to sync
|
||||
if "bbslink" in input.lower():
|
||||
if "$" in input and "#" in input:
|
||||
#store the message
|
||||
subject = input.split("$")[1].split("#")[0]
|
||||
body = input.split("#")[1]
|
||||
fromNodeHex = body.split("@")[1]
|
||||
#validate the fromNodeHex is a valid hex number
|
||||
try:
|
||||
int(fromNodeHex, 16)
|
||||
except ValueError:
|
||||
logger.error(f"System: Invalid fromNodeHex in bbslink from node {peerNode}: {input}")
|
||||
fromNodeHex = hex(peerNode)
|
||||
#validate the subject and body are not empty
|
||||
if subject.strip() == "" or body.strip() == "":
|
||||
logger.error(f"System: Empty subject or body in bbslink from node {peerNode}: {input}")
|
||||
return "System: Invalid bbslink format."
|
||||
|
||||
#store the message in the bbsdb
|
||||
try:
|
||||
bbs_post_message(subject, body, int(fromNodeHex, 16))
|
||||
except:
|
||||
logger.error(f"System: Error parsing bbslink from node {peerNode}: {input}")
|
||||
fromNodeHex = hex(peerNode)
|
||||
messageID = input.split(" ")[1]
|
||||
return f"bbsack {messageID}"
|
||||
elif "bbsack" in input.lower():
|
||||
# increment the messageID
|
||||
if len(input.split(" ")) > 1:
|
||||
try:
|
||||
messageID = int(input.split(" ")[1]) + 1
|
||||
except:
|
||||
return "link error"
|
||||
else:
|
||||
return "link error"
|
||||
|
||||
# send message with delay to keep chutil happy
|
||||
if messageID < len(bbs_messages):
|
||||
logger.debug(f"System: wait to bbslink with peer " + str(peerNode))
|
||||
fromNodeHex = hex(bbs_messages[messageID][3])
|
||||
time.sleep(5 + responseDelay)
|
||||
# every 5 messages add extra delay
|
||||
if messageID % 5 == 0:
|
||||
time.sleep(10 + responseDelay)
|
||||
logger.debug(f"System: Sending bbslink message {messageID} of {len(bbs_messages)} to peer " + str(peerNode))
|
||||
msg = f"bbslink {messageID} ${bbs_messages[messageID][1]} #{bbs_messages[messageID][2]} @{fromNodeHex}"
|
||||
if useSynchCompression:
|
||||
compressed = compress_data(msg)
|
||||
send_raw_bytes(peerNode, compressed)
|
||||
logger.debug("System: Sent compressed bbslink message to peer " + str(peerNode))
|
||||
else:
|
||||
return msg
|
||||
else:
|
||||
logger.debug("System: bbslink sync complete with peer " + str(peerNode))
|
||||
|
||||
|
||||
#initialize the bbsdb's
|
||||
load_bbsdb()
|
||||
load_bbsdm()
|
||||
|
||||
442
modules/checklist.md
Normal file
442
modules/checklist.md
Normal file
@@ -0,0 +1,442 @@
|
||||
# Enhanced Check-in/Check-out System
|
||||
|
||||
## Overview
|
||||
|
||||
The enhanced checklist module provides asset tracking and accountability features with advanced safety monitoring capabilities. This system is designed for scenarios where tracking people, equipment, or assets is critical for safety, accountability, or logistics.
|
||||
|
||||
## Key Features
|
||||
|
||||
### 🔐 Basic Check-in/Check-out
|
||||
- Simple interface for tracking when people or assets are checked in or out
|
||||
- Automatic duration calculation
|
||||
- Location tracking (GPS coordinates if available)
|
||||
- Notes support for additional context
|
||||
|
||||
### ⏰ Safety Monitoring with Time Intervals
|
||||
- Set expected check-in intervals for safety (minimal 20min)
|
||||
- Automatic tracking of overdue check-ins
|
||||
- Ideal for solo activities, remote work, or high-risk operations
|
||||
- Get alerts when someone hasn't checked in within their expected timeframe
|
||||
|
||||
### ✅ Approval Workflow
|
||||
- Admin approval system for check-ins
|
||||
- Deny/remove unauthorized check-ins
|
||||
- Maintain accountability and control
|
||||
|
||||
### 📍 Location Tracking
|
||||
- Automatic GPS location capture when checking in/out
|
||||
- View last known location in checklist
|
||||
|
||||
- **Time Window Monitoring**: Check-in with safety intervals (e.g., `checkin 60 Hunting in tree stand`)
|
||||
- Tracks if users don't check in within expected timeframe
|
||||
- Ideal for solo activities, remote work, or safety accountability
|
||||
- Provides `get_overdue_checkins()` function for alert integration
|
||||
|
||||
- **Approval Workflow**:
|
||||
- `approvecl <id>` - Approve pending check-ins (admin)
|
||||
- `denycl <id>` - Deny/remove check-ins (admin)
|
||||
- Support for approval-based workflows
|
||||
|
||||
#### New Commands:
|
||||
- `approvecl <id>` - Approve a check-in
|
||||
- `denycl <id>` - Deny a check-in
|
||||
- Enhanced `checkin [interval] [note]` - Now supports interval parameter
|
||||
|
||||
### Enhanced Check Out Options
|
||||
|
||||
You can now check out in three ways:
|
||||
|
||||
#### 1. Check Out the Most Recent Active Check-in
|
||||
```
|
||||
checkout [notes]
|
||||
```
|
||||
Checks out your most recent active check-in.
|
||||
*Example:*
|
||||
```
|
||||
checkout Heading back to camp
|
||||
```
|
||||
|
||||
#### 2. Check Out All Active Check-ins
|
||||
```
|
||||
checkout all [notes]
|
||||
```
|
||||
Checks out **all** of your active check-ins at once.
|
||||
*Example:*
|
||||
```
|
||||
checkout all Done for the day
|
||||
```
|
||||
*Response:*
|
||||
```
|
||||
Checked out 2 check-ins for Hunter1. Durations: 01:23:45, 00:15:30
|
||||
```
|
||||
|
||||
#### 3. Check Out a Specific Check-in by ID
|
||||
```
|
||||
checkout <checkin_id> [notes]
|
||||
```
|
||||
Checks out a specific check-in using its ID (as shown in the `checklist` command).
|
||||
*Example:*
|
||||
```
|
||||
checkout 123 Leaving early
|
||||
```
|
||||
*Response:*
|
||||
```
|
||||
Checked out check-in ID 123 for Hunter1. Duration: 00:45:12
|
||||
```
|
||||
|
||||
**Tip:**
|
||||
- Use `checklist` to see your current check-in IDs and durations.
|
||||
- You can always add a note to any checkout command for context.
|
||||
|
||||
---
|
||||
|
||||
These options allow you to manage your check-ins more flexibly, whether you want to check out everything at once or just a specific session.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add to your `config.ini`:
|
||||
|
||||
```ini
|
||||
[checklist]
|
||||
enabled = True
|
||||
checklist_db = data/checklist.db
|
||||
# Set to True to reverse the meaning of checkin/checkout
|
||||
reverse_in_out = False
|
||||
```
|
||||
|
||||
## Commands Reference
|
||||
|
||||
### Basic Commands
|
||||
|
||||
#### Check In
|
||||
```
|
||||
checkin [interval] [notes]
|
||||
```
|
||||
|
||||
Check in to the system. Optionally specify a monitoring interval in minutes.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
checkin Arrived at base camp
|
||||
checkin 30 Solo hiking on north trail
|
||||
checkin 60 Working alone in tree stand
|
||||
checkin Going hunting
|
||||
```
|
||||
|
||||
#### Check Out
|
||||
```
|
||||
checkout [notes]
|
||||
```
|
||||
|
||||
Check out from the system. Shows duration since check-in.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
checkout Heading back
|
||||
checkout Mission complete
|
||||
checkout
|
||||
```
|
||||
|
||||
#### View Checklist
|
||||
```
|
||||
checklist
|
||||
```
|
||||
|
||||
Shows all active check-ins with durations.
|
||||
|
||||
**Example Response:**
|
||||
```
|
||||
ID: Hunter1 checked-In for 01:23:45📝Solo hunting
|
||||
ID: Tech2 checked-In for 00:15:30📝Equipment repair
|
||||
```
|
||||
|
||||
|
||||
### Admin Commands
|
||||
|
||||
#### Approve Check-in
|
||||
```
|
||||
approvecl <checkin_id>
|
||||
```
|
||||
|
||||
Approve a pending check-in (requires admin privileges).
|
||||
|
||||
**Example:**
|
||||
```
|
||||
approvecl 123
|
||||
```
|
||||
|
||||
#### Deny Check-in
|
||||
```
|
||||
denycl <checkin_id>
|
||||
```
|
||||
|
||||
Deny and remove a check-in (requires admin privileges).
|
||||
|
||||
**Example:**
|
||||
```
|
||||
denycl 456
|
||||
```
|
||||
|
||||
## Safety Monitoring Feature
|
||||
|
||||
### How Time Intervals Work
|
||||
|
||||
When checking in with an interval parameter, the system will track whether you check in again or check out within that timeframe.
|
||||
|
||||
```
|
||||
checkin 60 Hunting in remote area
|
||||
```
|
||||
|
||||
This tells the system:
|
||||
- You're checking in now
|
||||
- You expect to check in again or check out within 60 minutes
|
||||
- If 60 minutes pass without activity, you'll be marked as overdue alert
|
||||
|
||||
### Use Cases for Time Intervals
|
||||
|
||||
1. **Solo Activities**: Hunting, hiking, or working alone
|
||||
```
|
||||
checkin 30 Solo patrol north sector
|
||||
```
|
||||
|
||||
2. **High-Risk Operations**: Tree work, equipment maintenance
|
||||
```
|
||||
checkin 45 Climbing tower for antenna work
|
||||
```
|
||||
|
||||
3. **Remote Work**: Working in isolated areas
|
||||
```
|
||||
checkin 120 Survey work in remote canyon
|
||||
```
|
||||
|
||||
4. **Check-in Points**: Regular status updates during long operations
|
||||
```
|
||||
checkin 15 Descending cliff
|
||||
```
|
||||
|
||||
5. **Check-in a reminder**: Reminders to check in on something like a pot roast
|
||||
```
|
||||
checkin 30 🍠🍖
|
||||
```
|
||||
|
||||
### Overdue Check-ins
|
||||
|
||||
The system tracks all check-ins with time intervals and can identify who is overdue. The module provides the `get_overdue_checkins()` function that returns a list of overdue users. It alerts on the 20min watchdog.
|
||||
|
||||
## Practical Examples
|
||||
|
||||
### Example 1: Hunting Scenario
|
||||
|
||||
Hunter checks in before going into the field:
|
||||
```
|
||||
checkin 60 Hunting deer stand #3, north 40
|
||||
```
|
||||
|
||||
System response:
|
||||
```
|
||||
Checked✅In: Hunter1 (monitoring every 60min)
|
||||
```
|
||||
|
||||
If the hunter doesn't check out or check in again within 60 minutes, they will appear on the overdue list.
|
||||
|
||||
When done hunting:
|
||||
```
|
||||
checkout Heading back to camp
|
||||
```
|
||||
|
||||
System response:
|
||||
```
|
||||
Checked⌛️Out: Hunter1 duration 02:15:30
|
||||
```
|
||||
|
||||
### Example 2: Emergency Response Team
|
||||
|
||||
Team leader tracks team members:
|
||||
|
||||
```
|
||||
# Team members check in
|
||||
checkin 30 Search grid A-1
|
||||
checkin 30 Search grid A-2
|
||||
checkin 30 Search grid A-3
|
||||
```
|
||||
|
||||
Team leader views status:
|
||||
```
|
||||
checklist
|
||||
```
|
||||
|
||||
Response shows all active searchers with their durations.
|
||||
|
||||
### Example 3: Equipment Checkout
|
||||
|
||||
Track equipment loans:
|
||||
|
||||
```
|
||||
checkin Radio #5 for field ops
|
||||
```
|
||||
|
||||
When equipment is returned:
|
||||
```
|
||||
checkout Equipment returned
|
||||
```
|
||||
|
||||
### Example 4: Site Survey
|
||||
|
||||
Field technicians checking in at locations:
|
||||
|
||||
```
|
||||
# At first site
|
||||
checkin 45 Site survey tower location 1
|
||||
|
||||
# Moving to next site (automatically checks out from first)
|
||||
checkin 45 Site survey tower location 2
|
||||
```
|
||||
|
||||
## Integration with Other Systems
|
||||
|
||||
### Geo-Location Awareness
|
||||
|
||||
The checklist system automatically captures GPS coordinates when available. This can be used for:
|
||||
- Tracking last known position
|
||||
- Asset location management
|
||||
|
||||
### Alert Systems
|
||||
|
||||
The overdue check-in feature can trigger:
|
||||
- Notifications to supervisors
|
||||
- Automated messages to response teams
|
||||
- Email/SMS notifications (if configured)
|
||||
|
||||
### Scheduling Integration
|
||||
|
||||
Combine with the scheduler module to:
|
||||
- Send reminders to check in
|
||||
- Schedule periodic check-in requirements
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Users
|
||||
|
||||
1. **Always Include Context**: Add notes when checking in
|
||||
```
|
||||
checkin 30 North trail maintenance
|
||||
```
|
||||
Not just:
|
||||
```
|
||||
checkin
|
||||
```
|
||||
|
||||
2. **Set Realistic Intervals**: Don't set intervals too short or too long
|
||||
- Too short: False alarms
|
||||
- Too long: Defeats safety purpose
|
||||
|
||||
3. **Check Out Promptly**: Always check out when done to clear your status
|
||||
|
||||
4. **Use Consistent Naming**: If tracking equipment, use consistent names
|
||||
|
||||
### For Administrators
|
||||
|
||||
1. **Review Checklist Regularly**: Monitor who is checked in
|
||||
```
|
||||
checklist
|
||||
```
|
||||
|
||||
The list will show ✅ approved and ☑️ unapproved
|
||||
The alarm will only alert on approved.
|
||||
|
||||
in config.ini
|
||||
```ini
|
||||
# Auto approve new checklists
|
||||
auto_approve = True
|
||||
# Check-in reminder interval is 5min
|
||||
# Checkin broadcast interface and channel is emergency_handler interface and channel
|
||||
```
|
||||
|
||||
2. **Respond to Overdue Situations**: Act on overdue check-ins promptly
|
||||
|
||||
3. **Set Clear Policies**: Establish when and how to use the system
|
||||
|
||||
4. **Train Users**: Ensure everyone knows how to use time intervals
|
||||
|
||||
5. **Test the System**: Regularly verify the system is working
|
||||
|
||||
## Safety Scenarios
|
||||
|
||||
### Scenario 1: Tree Stand Hunting
|
||||
```
|
||||
checkin 60 Hunting from tree stand at north plot
|
||||
```
|
||||
If hunter falls or has medical emergency, they'll be marked overdue after 60 minutes.
|
||||
|
||||
### Scenario 2: Equipment Maintenance
|
||||
```
|
||||
checkin 30 Generator maintenance at remote site
|
||||
```
|
||||
If technician encounters danger, overdue status can be detected. Note: Requires alert system integration to send notifications.
|
||||
|
||||
### Scenario 3: Hiking
|
||||
```
|
||||
checkin 120 Day hike to mountain peak
|
||||
```
|
||||
Longer interval for extended activity, but still provides safety net.
|
||||
|
||||
### Scenario 4: Watchstanding
|
||||
```
|
||||
checkin 240 Night watch duty
|
||||
```
|
||||
Regular check-ins every 4 hours ensure person is alert and safe.
|
||||
|
||||
## Database Schema
|
||||
|
||||
### checkin Table
|
||||
```sql
|
||||
CREATE TABLE checkin (
|
||||
checkin_id INTEGER PRIMARY KEY,
|
||||
checkin_name TEXT,
|
||||
checkin_date TEXT,
|
||||
checkin_time TEXT,
|
||||
location TEXT,
|
||||
checkin_notes TEXT,
|
||||
approved INTEGER DEFAULT 1,
|
||||
expected_checkin_interval INTEGER DEFAULT 0
|
||||
)
|
||||
```
|
||||
|
||||
### checkout Table
|
||||
```sql
|
||||
CREATE TABLE checkout (
|
||||
checkout_id INTEGER PRIMARY KEY,
|
||||
checkout_name TEXT,
|
||||
checkout_date TEXT,
|
||||
checkout_time TEXT,
|
||||
location TEXT,
|
||||
checkout_notes TEXT
|
||||
)
|
||||
```
|
||||
|
||||
## Reverse Mode
|
||||
|
||||
Setting `reverse_in_out = True` in config swaps the meaning of checkin and checkout commands. This is useful if your organization uses opposite terminology.
|
||||
|
||||
With `reverse_in_out = True`:
|
||||
- `checkout` command performs a check-in
|
||||
- `checkin` command performs a check-out
|
||||
|
||||
## Migration from Basic Checklist
|
||||
|
||||
The enhanced checklist is backward compatible with the basic version. Existing check-ins will continue to work, and new features are optional. The database will automatically upgrade to add new columns when first accessed.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Not Seeing Overdue Alerts
|
||||
The overdue detection is built into the module, but alerts need to be configured in the main bot scheduler. Check your scheduler configuration.
|
||||
|
||||
### Wrong Duration Shown
|
||||
Duration is calculated from check-in time to current time. If system clock is wrong, durations will be incorrect. Ensure system time is accurate.
|
||||
|
||||
### Can't Approve/Deny Check-ins
|
||||
These are admin-only commands. Check that your node ID is in the `bbs_admin_list`.
|
||||
|
||||
## Support
|
||||
|
||||
For issues or feature requests, please file an issue on the GitHub repository.
|
||||
471
modules/checklist.py
Normal file
471
modules/checklist.py
Normal file
@@ -0,0 +1,471 @@
|
||||
# Checkin Checkout database module for the bot
|
||||
# K7MHI Kelly Keeton 2024
|
||||
|
||||
import sqlite3
|
||||
from modules.log import logger
|
||||
from modules.settings import checklist_db, reverse_in_out, bbs_ban_list, bbs_admin_list, checklist_auto_approve
|
||||
import time
|
||||
|
||||
trap_list_checklist = ("checkin", "checkout", "checklist", "approvecl", "denycl",)
|
||||
|
||||
def initialize_checklist_database():
|
||||
try:
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
logger.debug("System: Checklist: Initializing database...")
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS checkin
|
||||
(checkin_id INTEGER PRIMARY KEY, checkin_name TEXT, checkin_date TEXT,
|
||||
checkin_time TEXT, location TEXT, checkin_notes TEXT,
|
||||
approved INTEGER DEFAULT 1, expected_checkin_interval INTEGER DEFAULT 0,
|
||||
removed INTEGER DEFAULT 0)''')
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS checkout
|
||||
(checkout_id INTEGER PRIMARY KEY, checkout_name TEXT, checkout_date TEXT,
|
||||
checkout_time TEXT, location TEXT, checkout_notes TEXT,
|
||||
checkin_id INTEGER, removed INTEGER DEFAULT 0)''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Checklist: Failed to initialize database: {e} Please delete old checklist database file. rm data/checklist.db")
|
||||
return False
|
||||
|
||||
def checkin(name, date, time, location, notes):
|
||||
location = ", ".join(map(str, location))
|
||||
# Auto-approve if setting is enabled
|
||||
approved_value = 1 if checklist_auto_approve else 0
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute(
|
||||
"INSERT INTO checkin (checkin_name, checkin_date, checkin_time, location, checkin_notes, removed, approved) VALUES (?, ?, ?, ?, ?, 0, ?)",
|
||||
(name, date, time, location, notes, approved_value)
|
||||
)
|
||||
except sqlite3.OperationalError as e:
|
||||
if "no such table" in str(e):
|
||||
initialize_checklist_database()
|
||||
c.execute(
|
||||
"INSERT INTO checkin (checkin_name, checkin_date, checkin_time, location, checkin_notes, removed, approved) VALUES (?, ?, ?, ?, ?, 0, ?)",
|
||||
(name, date, time, location, notes, approved_value)
|
||||
)
|
||||
else:
|
||||
raise
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if reverse_in_out:
|
||||
return "Checked✅Out: " + str(name)
|
||||
else:
|
||||
return "Checked✅In: " + str(name)
|
||||
|
||||
def checkout(name, date, time_str, location, notes, all=False, checkin_id=None):
|
||||
location = ", ".join(map(str, location))
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
checked_out_ids = []
|
||||
durations = []
|
||||
try:
|
||||
if checkin_id is not None:
|
||||
# Check out a specific check-in by ID
|
||||
c.execute("""
|
||||
SELECT checkin_id, checkin_time, checkin_date FROM checkin
|
||||
WHERE checkin_id = ? AND checkin_name = ?
|
||||
""", (checkin_id, name))
|
||||
row = c.fetchone()
|
||||
if row:
|
||||
c.execute("INSERT INTO checkout (checkout_name, checkout_date, checkout_time, location, checkout_notes, checkin_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(name, date, time_str, location, notes, row[0]))
|
||||
checkin_time, checkin_date = row[1], row[2]
|
||||
checkin_datetime = time.strptime(checkin_date + " " + checkin_time, "%Y-%m-%d %H:%M:%S")
|
||||
time_checked_in_seconds = time.time() - time.mktime(checkin_datetime)
|
||||
durations.append(time.strftime("%H:%M:%S", time.gmtime(time_checked_in_seconds)))
|
||||
checked_out_ids.append(row[0])
|
||||
elif all:
|
||||
# Check out all active check-ins for this user
|
||||
c.execute("""
|
||||
SELECT checkin_id, checkin_time, checkin_date FROM checkin
|
||||
WHERE checkin_name = ?
|
||||
AND removed = 0
|
||||
AND checkin_id NOT IN (
|
||||
SELECT checkin_id FROM checkout WHERE checkin_id IS NOT NULL
|
||||
)
|
||||
""", (name,))
|
||||
rows = c.fetchall()
|
||||
for row in rows:
|
||||
c.execute("INSERT INTO checkout (checkout_name, checkout_date, checkout_time, location, checkout_notes, checkin_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(name, date, time_str, location, notes, row[0]))
|
||||
checkin_time, checkin_date = row[1], row[2]
|
||||
checkin_datetime = time.strptime(checkin_date + " " + checkin_time, "%Y-%m-%d %H:%M:%S")
|
||||
time_checked_in_seconds = time.time() - time.mktime(checkin_datetime)
|
||||
durations.append(time.strftime("%H:%M:%S", time.gmtime(time_checked_in_seconds)))
|
||||
checked_out_ids.append(row[0])
|
||||
else:
|
||||
# Default: check out the most recent active check-in
|
||||
c.execute("""
|
||||
SELECT checkin_id, checkin_time, checkin_date FROM checkin
|
||||
WHERE checkin_name = ?
|
||||
AND removed = 0
|
||||
AND checkin_id NOT IN (
|
||||
SELECT checkin_id FROM checkout WHERE checkin_id IS NOT NULL
|
||||
)
|
||||
ORDER BY checkin_date DESC, checkin_time DESC
|
||||
LIMIT 1
|
||||
""", (name,))
|
||||
row = c.fetchone()
|
||||
if row:
|
||||
c.execute("INSERT INTO checkout (checkout_name, checkout_date, checkout_time, location, checkout_notes, checkin_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(name, date, time_str, location, notes, row[0]))
|
||||
checkin_time, checkin_date = row[1], row[2]
|
||||
checkin_datetime = time.strptime(checkin_date + " " + checkin_time, "%Y-%m-%d %H:%M:%S")
|
||||
time_checked_in_seconds = time.time() - time.mktime(checkin_datetime)
|
||||
durations.append(time.strftime("%H:%M:%S", time.gmtime(time_checked_in_seconds)))
|
||||
checked_out_ids.append(row[0])
|
||||
except sqlite3.OperationalError as e:
|
||||
if "no such table" in str(e):
|
||||
conn.close()
|
||||
initialize_checklist_database()
|
||||
return checkout(name, date, time_str, location, notes, all=all, checkin_id=checkin_id)
|
||||
else:
|
||||
conn.close()
|
||||
raise
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if checked_out_ids:
|
||||
if all:
|
||||
return f"Checked out {len(checked_out_ids)} check-ins for {name}. Durations: {', '.join(durations)}"
|
||||
elif checkin_id is not None:
|
||||
return f"Checked out check-in ID {checkin_id} for {name}. Duration: {durations[0]}"
|
||||
else:
|
||||
if reverse_in_out:
|
||||
return f"Checked⌛️In: {name} duration {durations[0]}"
|
||||
else:
|
||||
return f"Checked⌛️Out: {name} duration {durations[0]}"
|
||||
else:
|
||||
return f"None found for {name}"
|
||||
|
||||
def approve_checkin(checkin_id):
|
||||
"""Approve a pending check-in"""
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("UPDATE checkin SET approved = 1 WHERE checkin_id = ?", (checkin_id,))
|
||||
if c.rowcount == 0:
|
||||
conn.close()
|
||||
return f"Check-in ID {checkin_id} not found."
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"✅ Check-in {checkin_id} approved."
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Checklist: Error approving check-in: {e}")
|
||||
return "Error approving check-in."
|
||||
|
||||
def deny_checkin(checkin_id):
|
||||
"""Deny/delete a pending check-in"""
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("DELETE FROM checkin WHERE checkin_id = ?", (checkin_id,))
|
||||
if c.rowcount == 0:
|
||||
conn.close()
|
||||
return f"Check-in ID {checkin_id} not found."
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"❌ Check-in {checkin_id} denied and removed."
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Checklist: Error denying check-in: {e}")
|
||||
return "Error denying check-in."
|
||||
|
||||
def set_checkin_interval(name, interval_minutes):
|
||||
"""Set expected check-in interval for a user (for safety monitoring)"""
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
# Update the most recent active check-in for this user
|
||||
c.execute("""
|
||||
UPDATE checkin
|
||||
SET expected_checkin_interval = ?
|
||||
WHERE checkin_name = ?
|
||||
AND checkin_id NOT IN (
|
||||
SELECT checkin_id FROM checkout
|
||||
WHERE checkout_name = checkin_name
|
||||
AND (checkout_date > checkin_date OR (checkout_date = checkin_date AND checkout_time > checkin_time))
|
||||
)
|
||||
ORDER BY checkin_date DESC, checkin_time DESC
|
||||
LIMIT 1
|
||||
""", (interval_minutes, name))
|
||||
|
||||
if c.rowcount == 0:
|
||||
conn.close()
|
||||
return f"No active check-in found for {name}."
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"⏰ Check-in interval set to {interval_minutes} minutes for {name}."
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Checklist: Error setting check-in interval: {e}")
|
||||
return "Error setting check-in interval."
|
||||
|
||||
def get_overdue_checkins():
|
||||
"""Get list of users who haven't checked in within their expected interval"""
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
current_time = time.time()
|
||||
|
||||
try:
|
||||
c.execute("""
|
||||
SELECT checkin_id, checkin_name, checkin_date, checkin_time, expected_checkin_interval, location, checkin_notes
|
||||
FROM checkin
|
||||
WHERE expected_checkin_interval > 0
|
||||
AND approved = 1
|
||||
AND checkin_id NOT IN (
|
||||
SELECT checkin_id FROM checkout
|
||||
WHERE checkout_name = checkin_name
|
||||
AND (checkout_date > checkin_date OR (checkout_date = checkin_date AND checkout_time > checkin_time))
|
||||
)
|
||||
""")
|
||||
|
||||
active_checkins = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
overdue_list = []
|
||||
for checkin_id, name, date, time_str, interval, location, notes in active_checkins:
|
||||
checkin_datetime = time.mktime(time.strptime(f"{date} {time_str}", "%Y-%m-%d %H:%M:%S"))
|
||||
time_since_checkin = (current_time - checkin_datetime) / 60 # in minutes
|
||||
|
||||
if time_since_checkin > interval:
|
||||
overdue_minutes = int(time_since_checkin - interval)
|
||||
overdue_list.append({
|
||||
'id': checkin_id,
|
||||
'name': name,
|
||||
'location': location,
|
||||
'overdue_minutes': overdue_minutes,
|
||||
'interval': interval,
|
||||
'checkin_notes': notes
|
||||
})
|
||||
|
||||
return overdue_list
|
||||
except sqlite3.OperationalError as e:
|
||||
conn.close()
|
||||
if "no such table" in str(e):
|
||||
initialize_checklist_database()
|
||||
return get_overdue_checkins()
|
||||
logger.error(f"Checklist: Error getting overdue check-ins: {e}")
|
||||
return []
|
||||
|
||||
def format_overdue_alert():
|
||||
header = "⚠️ OVERDUE CHECK-INS:\a\n"
|
||||
alert = ""
|
||||
try:
|
||||
"""Format overdue check-ins as an alert message"""
|
||||
overdue = get_overdue_checkins()
|
||||
if not overdue:
|
||||
return None
|
||||
for entry in overdue:
|
||||
hours = entry['overdue_minutes'] // 60
|
||||
minutes = entry['overdue_minutes'] % 60
|
||||
if hours > 0:
|
||||
alert += f"{entry['name']}: {hours}h {minutes}m overdue"
|
||||
else:
|
||||
alert += f"{entry['name']}: {minutes}m overdue"
|
||||
# if entry['location']:
|
||||
# alert += f" @ {entry['location']}"
|
||||
if entry['checkin_notes']:
|
||||
alert += f" 📝{entry['checkin_notes']}"
|
||||
alert += "\n"
|
||||
if alert:
|
||||
return header + alert.rstrip()
|
||||
except Exception as e:
|
||||
logger.error(f"Checklist: Error formatting overdue alert: {e}")
|
||||
return None
|
||||
|
||||
def list_checkin():
|
||||
# list checkins
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("""
|
||||
SELECT * FROM checkin
|
||||
WHERE removed = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM checkout
|
||||
WHERE checkout.checkin_id = checkin.checkin_id
|
||||
)
|
||||
""")
|
||||
rows = c.fetchall()
|
||||
except sqlite3.OperationalError as e:
|
||||
if "no such table" in str(e):
|
||||
conn.close()
|
||||
initialize_checklist_database()
|
||||
return list_checkin()
|
||||
else:
|
||||
conn.close()
|
||||
initialize_checklist_database()
|
||||
return "Error listing checkins."
|
||||
conn.close()
|
||||
|
||||
# Get overdue info
|
||||
overdue = {entry['id']: entry for entry in get_overdue_checkins()}
|
||||
|
||||
checkin_list = ""
|
||||
for row in rows:
|
||||
checkin_id = row[0]
|
||||
# Calculate length of time checked in, including days
|
||||
total_seconds = time.time() - time.mktime(time.strptime(row[2] + " " + row[3], "%Y-%m-%d %H:%M:%S"))
|
||||
days = int(total_seconds // 86400)
|
||||
hours = int((total_seconds % 86400) // 3600)
|
||||
minutes = int((total_seconds % 3600) // 60)
|
||||
seconds = int(total_seconds % 60)
|
||||
if days > 0:
|
||||
timeCheckedIn = f"{days}d {hours:02}:{minutes:02}:{seconds:02}"
|
||||
else:
|
||||
timeCheckedIn = f"{hours:02}:{minutes:02}:{seconds:02}"
|
||||
|
||||
# Add ⏰ if routine check-ins are required
|
||||
routine = ""
|
||||
if len(row) > 7 and row[7] and int(row[7]) > 0:
|
||||
routine = f" ⏰({row[7]}m)"
|
||||
|
||||
# Indicate approval status
|
||||
approved_marker = "✅" if row[6] == 1 else "☑️"
|
||||
|
||||
# Check if overdue
|
||||
if checkin_id in overdue:
|
||||
overdue_minutes = overdue[checkin_id]['overdue_minutes']
|
||||
overdue_hours = overdue_minutes // 60
|
||||
overdue_mins = overdue_minutes % 60
|
||||
if overdue_hours > 0:
|
||||
overdue_str = f"overdue by {overdue_hours}h {overdue_mins}m"
|
||||
else:
|
||||
overdue_str = f"overdue by {overdue_mins}m"
|
||||
status = f"{row[1]} {overdue_str}{routine}"
|
||||
else:
|
||||
status = f"{row[1]} checked-In for {timeCheckedIn}{routine}"
|
||||
|
||||
checkin_list += f"ID: {checkin_id} {approved_marker} {status}"
|
||||
if row[5] != "":
|
||||
checkin_list += " 📝" + row[5]
|
||||
if row != rows[-1]:
|
||||
checkin_list += "\n"
|
||||
# if empty list
|
||||
if checkin_list == "":
|
||||
return "No data to display."
|
||||
return checkin_list
|
||||
|
||||
def process_checklist_command(nodeID, message, name="none", location="none"):
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
current_time = time.strftime("%H:%M:%S")
|
||||
# if user on bbs_ban_list reject command
|
||||
if str(nodeID) in bbs_ban_list:
|
||||
logger.warning("System: Checklist attempt from the ban list")
|
||||
return "unable to process command"
|
||||
is_admin = False
|
||||
if str(nodeID) in bbs_admin_list:
|
||||
is_admin = True
|
||||
|
||||
message_lower = message.lower()
|
||||
parts = message.split()
|
||||
|
||||
try:
|
||||
comment = message.split(" ", 1)[1] if len(parts) > 1 else ""
|
||||
except IndexError:
|
||||
comment = ""
|
||||
|
||||
# handle checklist commands
|
||||
if ("checkin" in message_lower and not reverse_in_out) or ("checkout" in message_lower and reverse_in_out):
|
||||
# Check if interval is specified: checkin 60 comment
|
||||
interval = 0
|
||||
actual_comment = comment
|
||||
if comment and parts[1].isdigit():
|
||||
interval = int(parts[1])
|
||||
actual_comment = " ".join(parts[2:]) if len(parts) > 2 else ""
|
||||
|
||||
result = checkin(name, current_date, current_time, location, actual_comment)
|
||||
|
||||
# Set interval if specified
|
||||
if interval > 0:
|
||||
set_checkin_interval(name, interval)
|
||||
result += f" (monitoring every {interval}min)"
|
||||
|
||||
return result
|
||||
|
||||
elif ("checkout" in message_lower and not reverse_in_out) or ("checkin" in message_lower and reverse_in_out):
|
||||
# Support: checkout all, checkout <id>, or checkout [note]
|
||||
all_flag = False
|
||||
checkin_id = None
|
||||
actual_comment = comment
|
||||
|
||||
# Split the command into parts after the keyword
|
||||
checkout_args = parts[1:] if len(parts) > 1 else []
|
||||
|
||||
if checkout_args:
|
||||
if checkout_args[0].lower() == "all":
|
||||
all_flag = True
|
||||
actual_comment = " ".join(checkout_args[1:]) if len(checkout_args) > 1 else ""
|
||||
elif checkout_args[0].isdigit():
|
||||
checkin_id = int(checkout_args[0])
|
||||
actual_comment = " ".join(checkout_args[1:]) if len(checkout_args) > 1 else ""
|
||||
else:
|
||||
actual_comment = " ".join(checkout_args)
|
||||
|
||||
return checkout(name, current_date, current_time, location, actual_comment, all=all_flag, checkin_id=checkin_id)
|
||||
|
||||
# elif "purgein" in message_lower:
|
||||
# return mark_checkin_removed_by_name(name)
|
||||
|
||||
# elif "purgeout" in message_lower:
|
||||
# return mark_checkout_removed_by_name(name)
|
||||
|
||||
elif "approvecl " in message_lower:
|
||||
if not is_admin:
|
||||
return "You do not have permission to approve check-ins."
|
||||
try:
|
||||
checkin_id = int(parts[1])
|
||||
return approve_checkin(checkin_id)
|
||||
except (ValueError, IndexError):
|
||||
return "Usage: checklistapprove <checkin_id>"
|
||||
|
||||
elif "denycl " in message_lower:
|
||||
if not is_admin:
|
||||
return "You do not have permission to deny check-ins."
|
||||
try:
|
||||
checkin_id = int(parts[1])
|
||||
return deny_checkin(checkin_id)
|
||||
except (ValueError, IndexError):
|
||||
return "Usage: checklistdeny <checkin_id>"
|
||||
|
||||
elif "?" in message_lower:
|
||||
if not reverse_in_out:
|
||||
return ("Command: checklist followed by\n"
|
||||
"checkin [interval] [note]\n"
|
||||
"checkout [all] [note]\n"
|
||||
"Example: checkin 60 Leaving for a hike")
|
||||
else:
|
||||
return ("Command: checklist followed by\n"
|
||||
"checkout [all] [interval] [note]\n"
|
||||
"checkin [note]\n"
|
||||
"Example: checkout 60 Leaving for a hike")
|
||||
|
||||
elif message_lower.strip() == "checklist":
|
||||
return list_checkin()
|
||||
|
||||
else:
|
||||
return "Invalid command."
|
||||
|
||||
def mark_checkin_removed_by_name(name):
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
c.execute("UPDATE checkin SET removed = 1 WHERE checkin_name = ?", (name,))
|
||||
affected = c.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"Marked {affected} check-in(s) as removed for {name}."
|
||||
|
||||
def mark_checkout_removed_by_name(name):
|
||||
conn = sqlite3.connect(checklist_db)
|
||||
c = conn.cursor()
|
||||
c.execute("UPDATE checkout SET removed = 1 WHERE checkout_name = ?", (name,))
|
||||
affected = c.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"Marked {affected} checkout(s) as removed for {name}."
|
||||
201
modules/dxspot.py
Normal file
201
modules/dxspot.py
Normal file
@@ -0,0 +1,201 @@
|
||||
# meshing-around modules/dxspot.py - Handles DX Spotter integration
|
||||
# Fetches DX spots from Spothole API based on user commands
|
||||
# 2025 K7MHI Kelly Keeton
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from modules.log import logger
|
||||
from modules.settings import latitudeValue, longitudeValue
|
||||
|
||||
trap_list_dxspotter = ["dx"]
|
||||
|
||||
def handledxcluster(message, nodeID, deviceID):
|
||||
from modules.dxspot import get_spothole_spots
|
||||
if "DX" in message.upper():
|
||||
logger.debug(f"System: DXSpotter: Device:{deviceID} Handler: DX Spot Request Received from Node {nodeID}")
|
||||
band = None
|
||||
mode = None
|
||||
source = None
|
||||
dx_call = None
|
||||
parts = message.split()
|
||||
for part in parts:
|
||||
if part.lower().startswith("band="):
|
||||
band = part.split("=")[1]
|
||||
elif part.lower().startswith("mode="):
|
||||
mode = part.split("=")[1]
|
||||
elif part.lower().startswith("ota="):
|
||||
source = part.split("=")[1]
|
||||
elif part.lower().startswith("of="):
|
||||
dx_call = part.split("=")[1]
|
||||
# Build params dict
|
||||
params = {}
|
||||
if source:
|
||||
params["source"] = source.upper()
|
||||
if band:
|
||||
params["band"] = band.lower()
|
||||
if mode:
|
||||
params["mode"] = mode.upper()
|
||||
if dx_call:
|
||||
params["dx_call"] = dx_call.upper()
|
||||
|
||||
# Fetch spots
|
||||
spots = get_spothole_spots(**params)
|
||||
if spots:
|
||||
response_lines = []
|
||||
for spot in spots[:5]:
|
||||
callsign = spot.get('dx_call', spot.get('callsign', 'N/A'))
|
||||
freq_hz = spot.get('freq', spot.get('frequency', None))
|
||||
frequency = f"{float(freq_hz)/1e6:.3f} MHz" if freq_hz else "N/A"
|
||||
mode_val = spot.get('mode', 'N/A')
|
||||
comment = spot.get('comment') or ''
|
||||
if len(comment) > 111: # Truncate comment to 111 chars
|
||||
comment = comment[:111] + '...'
|
||||
sig = spot.get('sig', '')
|
||||
de_grid = spot.get('de_grid', '')
|
||||
de_call = spot.get('de_call', '')
|
||||
sig_ref_name = spot.get('sig_refs_names', [''])[0] if spot.get('sig_refs_names') else ''
|
||||
line = f"{callsign} @{frequency} {mode_val} {sig} {sig_ref_name} by:{de_call} {de_grid} {comment}"
|
||||
response_lines.append(line)
|
||||
response = "\n".join(response_lines)
|
||||
else:
|
||||
response = "No DX spots found."
|
||||
return response
|
||||
return "Error: No DX command found."
|
||||
|
||||
def get_spothole_spots(source=None, band=None, mode=None, date=None, dx_call=None, de_continent=None, de_location=None):
|
||||
"""
|
||||
Fetches spots from https://spothole.app/api/v1/spots with optional filters.
|
||||
Returns a list of spot dicts.
|
||||
"""
|
||||
url = "https://spothole.app/api/v1/spots"
|
||||
params = {}
|
||||
fetched_count = 0
|
||||
|
||||
# Add administrative filters if provided
|
||||
qrt = False # Always fetch active spots
|
||||
needs_sig = False # Always need spots wth a group ike Xota
|
||||
limit = 4
|
||||
dedupe = True
|
||||
|
||||
params["dedupe"] = str(dedupe).lower()
|
||||
params["limit"] = limit
|
||||
params["qrt"] = str(qrt).lower()
|
||||
params["needs_sig"] = str(needs_sig).lower()
|
||||
params["needs_sig_ref"] = 'true'
|
||||
# Only get spots from last 9 hours
|
||||
received_since_dt = datetime.utcnow() - timedelta(hours=9)
|
||||
received_since = int(received_since_dt.timestamp())
|
||||
params["received_since"] = received_since
|
||||
|
||||
# Add spot filters if provided
|
||||
if source:
|
||||
params["source"] = source
|
||||
if band:
|
||||
params["band"] = band
|
||||
if mode:
|
||||
params["mode"] = mode
|
||||
if date:
|
||||
# date should be a string in YYYY-MM-DD or datetime.date
|
||||
if isinstance(date, datetime.date):
|
||||
params["date"] = date.isoformat()
|
||||
else:
|
||||
params["date"] = date
|
||||
|
||||
try:
|
||||
headers = {"User-Agent": "meshing-around-dxspotter/1.0"}
|
||||
response = requests.get(url, params=params, headers=headers)
|
||||
response.raise_for_status()
|
||||
spots = response.json()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error fetching spots: {e}")
|
||||
spots = []
|
||||
|
||||
fetched_count = len(spots)
|
||||
|
||||
# Admin Filters done via config.ini
|
||||
de_grid = None # e.g., "EM00"
|
||||
de_dxcc_id = None # e.g., "291"
|
||||
de_call = None # e.g., "K7MHI"
|
||||
|
||||
dx_itu_zone = None # e.g., "3"
|
||||
dx_cq_zone = None # e.g., "4"
|
||||
dx_dxcc_id = None # e.g., "291"
|
||||
|
||||
# spotter filters
|
||||
|
||||
# location filter
|
||||
de_latitude = None # e.g., 34.05
|
||||
de_longitude = None # e.g., -118.25
|
||||
if de_location:
|
||||
de_latitude, de_longitude = de_location
|
||||
elif de_latitude is not None and de_longitude is not None:
|
||||
de_latitude = latitudeValue
|
||||
de_longitude = longitudeValue
|
||||
if de_latitude and de_longitude:
|
||||
lat_range = (de_latitude - 1.0, de_latitude + 1.0)
|
||||
lon_range = (de_longitude - 1.0, de_longitude + 1.0)
|
||||
spots = [spot for spot in spots if lat_range[0] <= spot.get('de_latitude', 0) <= lat_range[1] and
|
||||
lon_range[0] <= spot.get('de_longitude', 0) <= lon_range[1]]
|
||||
# grid filter
|
||||
if de_grid:
|
||||
spots = [spot for spot in spots if spot.get('de_grid', '').upper() == de_grid.upper()]
|
||||
# DXCC Filters
|
||||
if de_dxcc_id:
|
||||
spots = [spot for spot in spots if str(spot.get('de_dxcc_id', '')) == str(de_dxcc_id)]
|
||||
# By reporting callsign
|
||||
if de_call:
|
||||
spots = [spot for spot in spots if spot.get('de_call', '').upper() == de_call.upper()]
|
||||
# DX spotted in zone
|
||||
if dx_itu_zone:
|
||||
spots = [spot for spot in spots if str(spot.get('dx_itu_zone', '')) == str(dx_itu_zone)]
|
||||
if dx_cq_zone:
|
||||
spots = [spot for spot in spots if str(spot.get('dx_cq_zone', '')) == str(dx_cq_zone)]
|
||||
if dx_dxcc_id:
|
||||
spots = [spot for spot in spots if str(spot.get('dx_dxcc_id', '')) == str(dx_dxcc_id)]
|
||||
|
||||
# User Runtime Filters
|
||||
|
||||
# Filter by dx_call if provided
|
||||
if dx_call:
|
||||
spots = [spot for spot in spots if spot.get('dx_call', '').upper() == dx_call.upper()]
|
||||
|
||||
# Filter by de_continent if provided
|
||||
if de_continent:
|
||||
spots = [spot for spot in spots if spot.get('de_continent', '').upper() == de_continent.upper()]
|
||||
|
||||
# Filter by de_location if provided
|
||||
if de_location:
|
||||
spots = [spot for spot in spots if spot.get('de_location', '').upper() == de_location.upper()]
|
||||
logger.debug(f"System: Spothole Returning {len(spots)} spots after filtering (fetched {fetched_count})")
|
||||
return spots
|
||||
|
||||
def handle_post_dxspot():
|
||||
time = int(datetime.utcnow().timestamp())
|
||||
freq = 14200000 # 14 MHz
|
||||
comment = "Test spot please ignore"
|
||||
de_spot = "N0CALL"
|
||||
dx_spot = "N0CALL"
|
||||
spot = {"dx_call": dx_spot, "time": time, "freq": freq, "comment": comment, "de_call": de_spot}
|
||||
try:
|
||||
success = post_spothole_spot(spot)
|
||||
if success:
|
||||
return "Spot posted successfully."
|
||||
else:
|
||||
return "Failed to post spot."
|
||||
except Exception as e:
|
||||
logger.debug(f"Error in handle_post_dxspot: {e}")
|
||||
return "Error occurred while posting spot."
|
||||
|
||||
def post_spothole_spot(spot):
|
||||
"""
|
||||
Posts a new spot to https://spothole.app/api/v1/spot.
|
||||
"""
|
||||
url = "https://spothole.app/api/v1/spot"
|
||||
headers = {"Content-Type": "application/json", "User-Agent": "meshing-around-dxspotter/1.0"}
|
||||
try:
|
||||
response = requests.post(url, json=spot, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
logger.debug(f"Spot posted successfully: {response.json()}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Error posting spot: {e}")
|
||||
return False
|
||||
273
modules/filemon.py
Normal file
273
modules/filemon.py
Normal file
@@ -0,0 +1,273 @@
|
||||
# File monitor module for the meshing-around bot
|
||||
# 2024 Kelly Keeton K7MHI
|
||||
|
||||
from modules.log import logger
|
||||
from modules.settings import (
|
||||
file_monitor_file_path,
|
||||
news_file_path,
|
||||
news_random_line_only,
|
||||
news_block_mode,
|
||||
allowXcmd,
|
||||
bbs_admin_list,
|
||||
xCmd2factorEnabled,
|
||||
xCmd2factor_timeout,
|
||||
enable_runShellCmd
|
||||
)
|
||||
import asyncio
|
||||
import random
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
trap_list_filemon = ("readnews",)
|
||||
|
||||
NEWS_DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data')
|
||||
newsSourcesList = []
|
||||
|
||||
def read_file(file_monitor_file_path, random_line_only=False, news_block_mode=False, verse_only=False):
|
||||
try:
|
||||
if not os.path.exists(file_monitor_file_path):
|
||||
if file_monitor_file_path == "bee.txt":
|
||||
return "🐝buzz 💐buzz buzz🍯"
|
||||
if file_monitor_file_path == 'bible.txt':
|
||||
return "🐝Go, and make disciples of all nations."
|
||||
if verse_only:
|
||||
# process verse/bible file
|
||||
verse = get_verses(file_monitor_file_path)
|
||||
return verse
|
||||
elif news_block_mode:
|
||||
with open(file_monitor_file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read().replace('\r\n', '\n').replace('\r', '\n')
|
||||
blocks = []
|
||||
block = []
|
||||
for line in content.split('\n'):
|
||||
if line.strip() == '':
|
||||
if block:
|
||||
blocks.append('\n'.join(block).strip())
|
||||
block = []
|
||||
else:
|
||||
block.append(line)
|
||||
if block:
|
||||
blocks.append('\n'.join(block).strip())
|
||||
blocks = [b for b in blocks if b]
|
||||
return random.choice(blocks) if blocks else None
|
||||
elif random_line_only:
|
||||
# read a random line from the file
|
||||
with open(file_monitor_file_path, 'r', encoding='utf-8') as f:
|
||||
lines = [line.strip() for line in f if line.strip()]
|
||||
return random.choice(lines) if lines else None
|
||||
else:
|
||||
# read the whole file
|
||||
with open(file_monitor_file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.warning(f"FileMon: Error reading file: {file_monitor_file_path}")
|
||||
return None
|
||||
|
||||
def read_news(source=None, random_line_only=False, news_block_mode=False):
|
||||
# Reads the news file. If a source is provided, reads {source}_news.txt.
|
||||
if source:
|
||||
file_path = os.path.join(NEWS_DATA_DIR, f"{source}_news.txt")
|
||||
else:
|
||||
file_path = os.path.join(NEWS_DATA_DIR, news_file_path)
|
||||
# Block mode takes precedence over line mode
|
||||
if news_block_mode:
|
||||
return read_file(file_path, random_line_only=False, news_block_mode=True)
|
||||
elif random_line_only:
|
||||
return read_file(file_path, random_line_only=True, news_block_mode=False)
|
||||
else:
|
||||
return read_file(file_path)
|
||||
|
||||
def read_verse():
|
||||
# Reads a random verse from the file bible.txt in the data/ directory
|
||||
verses = get_verses('bible.txt')
|
||||
if verses:
|
||||
return random.choice(verses)
|
||||
return None
|
||||
|
||||
def get_verses(file_monitor_file_path):
|
||||
# Handles both "4 ..." and "1 Timothy 4:15 ..." style verse starts
|
||||
verses = []
|
||||
current_verse = []
|
||||
with open(file_monitor_file_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
stripped = line.strip()
|
||||
# Check for "number space" OR "Book Chapter:Verse" at start
|
||||
is_numbered = stripped and len(stripped) > 1 and stripped[0].isdigit() and stripped[1] == ' '
|
||||
is_reference = (
|
||||
stripped and
|
||||
':' in stripped and
|
||||
any(stripped.startswith(book + ' ') for book in [
|
||||
"Genesis", "Exodus", "Leviticus", "Numbers", "Deuteronomy", "Joshua", "Judges", "Ruth",
|
||||
"1 Samuel", "2 Samuel", "1 Kings", "2 Kings", "1 Chronicles", "2 Chronicles", "Ezra", "Nehemiah",
|
||||
"Esther", "Job", "Psalms", "Proverbs", "Ecclesiastes", "Song of Solomon", "Isaiah", "Jeremiah",
|
||||
"Lamentations", "Ezekiel", "Daniel", "Hosea", "Joel", "Amos", "Obadiah", "Jonah", "Micah",
|
||||
"Nahum", "Habakkuk", "Zephaniah", "Haggai", "Zechariah", "Malachi", "Matthew", "Mark", "Luke",
|
||||
"John", "Acts", "Romans", "1 Corinthians", "2 Corinthians", "Galatians", "Ephesians", "Philippians",
|
||||
"Colossians", "1 Thessalonians", "2 Thessalonians", "1 Timothy", "2 Timothy", "Titus", "Philemon",
|
||||
"Hebrews", "James", "1 Peter", "2 Peter", "1 John", "2 John", "3 John", "Jude", "Revelation"
|
||||
])
|
||||
)
|
||||
if is_numbered or is_reference:
|
||||
if current_verse:
|
||||
verses.append(' '.join(current_verse).strip())
|
||||
current_verse = []
|
||||
# For numbered, drop the number; for reference, keep the whole line
|
||||
if is_numbered:
|
||||
current_verse.append(stripped.split(' ', 1)[1])
|
||||
else:
|
||||
current_verse.append(stripped)
|
||||
elif stripped and not stripped.lower().startswith('psalm'):
|
||||
current_verse.append(stripped)
|
||||
elif not stripped and current_verse:
|
||||
verses.append(' '.join(current_verse).strip())
|
||||
current_verse = []
|
||||
if current_verse:
|
||||
verses.append(' '.join(current_verse).strip())
|
||||
return verses
|
||||
|
||||
def write_news(content, append=False):
|
||||
# write the news file on demand
|
||||
try:
|
||||
file_path = os.path.join(NEWS_DATA_DIR, news_file_path)
|
||||
with open(file_path, 'a' if append else 'w', encoding='utf-8') as f:
|
||||
#f.write(content)
|
||||
logger.info(f"FileMon: Updated {file_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"FileMon: Error writing file: {file_path}")
|
||||
return False
|
||||
|
||||
async def watch_file():
|
||||
# Watch the file for changes and return the new content when it changes
|
||||
if not os.path.exists(file_monitor_file_path):
|
||||
return None
|
||||
else:
|
||||
last_modified_time = os.path.getmtime(file_monitor_file_path)
|
||||
while True:
|
||||
current_modified_time = os.path.getmtime(file_monitor_file_path)
|
||||
if current_modified_time != last_modified_time:
|
||||
# File has been modified
|
||||
content = read_file(file_monitor_file_path)
|
||||
last_modified_time = current_modified_time
|
||||
# Cleanup the content
|
||||
content = content.replace('\n', ' ').replace('\r', '').strip()
|
||||
if content:
|
||||
return content
|
||||
await asyncio.sleep(1) # Check every
|
||||
|
||||
def call_external_script(message, script="runShell.sh"):
|
||||
# If no path is given, assume script/ directory
|
||||
if "/" not in script and "\\" not in script:
|
||||
script = os.path.join("script", script)
|
||||
try:
|
||||
current_working_directory = os.getcwd()
|
||||
script_path = os.path.join(current_working_directory, script)
|
||||
|
||||
if not os.path.exists(script_path):
|
||||
# Try the raw script name
|
||||
script_path = script
|
||||
if not os.path.exists(script_path):
|
||||
logger.warning(f"FileMon: Script not found: {script_path}")
|
||||
return "sorry I can't do that"
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", script_path, message],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"FileMon: Script error: {result.stderr.strip()}")
|
||||
return None
|
||||
|
||||
output = result.stdout.strip()
|
||||
return output if output else None
|
||||
except Exception as e:
|
||||
logger.warning(f"FileMon: Error calling external script: {e}")
|
||||
return None
|
||||
|
||||
|
||||
waitingXroom = {} # {message_from_id: (expected_answer, original_command, timestamp)}
|
||||
def handleShellCmd(message, message_from_id, channel_number, isDM, deviceID):
|
||||
if not allowXcmd:
|
||||
return "x: command is disabled"
|
||||
if str(message_from_id) not in bbs_admin_list:
|
||||
logger.warning(f"FileMon: Unauthorized x: command attempt from {message_from_id}")
|
||||
return "x: command not authorized"
|
||||
if not isDM:
|
||||
return "x: command not authorized in group chat"
|
||||
|
||||
# 2FA logic
|
||||
if xCmd2factorEnabled:
|
||||
timeNOW = datetime.utcnow()
|
||||
# If user is waiting for 2FA, treat message as answer
|
||||
if message_from_id in waitingXroom:
|
||||
answer = message[2:].strip() if message.lower().startswith("x:") else message.strip()
|
||||
expected, orig_command, ts = waitingXroom[message_from_id]
|
||||
if timeNOW - ts > timedelta(seconds=xCmd2factor_timeout):
|
||||
del waitingXroom[message_from_id]
|
||||
return "x2FA timed out, please try again"
|
||||
if answer == str(expected):
|
||||
del waitingXroom[message_from_id]
|
||||
# Run the original command
|
||||
try:
|
||||
logger.info(f"FileMon: Running shell command from {message_from_id}: {orig_command}")
|
||||
result = subprocess.run(orig_command, shell=True, capture_output=True, text=True, timeout=10, start_new_session=True)
|
||||
output = result.stdout.strip()
|
||||
return output if output else "✅ x: processed finished, no output"
|
||||
except Exception as e:
|
||||
logger.warning(f"FileMon: Error running shell command: {e}")
|
||||
logger.debug(f"FileMon: This command is not good for use over the mesh network")
|
||||
return "x: error running command"
|
||||
else:
|
||||
logger.warning(f"FileMon: 🚨Incorrect 2FA answer from {message_from_id}")
|
||||
return "x2FA incorrect, try again"
|
||||
# If not waiting, treat as new command and issue challenge
|
||||
if message.lower().startswith("x:"):
|
||||
command = message[2:].strip()
|
||||
# Generate two random numbers, seed with message_from_id and time of day
|
||||
seed = timeNOW.second + timeNOW.minute * 60 + timeNOW.hour * 3600 + int(message_from_id)
|
||||
rnd = random.Random(seed)
|
||||
a = rnd.randint(10, 99)
|
||||
b = rnd.randint(10, 99)
|
||||
expected = a + b
|
||||
waitingXroom[message_from_id] = (expected, command, timeNOW)
|
||||
return f"x2FA required.\nReply `x: answer`\nWhat is {a} + {b}? "
|
||||
else:
|
||||
return "invalid command format"
|
||||
|
||||
# If we reach here, 2FA is disabled or passed
|
||||
if enable_runShellCmd:
|
||||
if message.lower().startswith("x:"):
|
||||
command = message[2:].strip()
|
||||
else:
|
||||
return "invalid command format"
|
||||
try:
|
||||
logger.info(f"FileMon: Running shell command from {message_from_id}: {command}")
|
||||
result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=10, start_new_session=True)
|
||||
output = result.stdout.strip()
|
||||
return output if output else "x: command executed with no output"
|
||||
except Exception as e:
|
||||
logger.warning(f"FileMon: Error running shell command: {e}")
|
||||
logger.debug(f"FileMon: This command is not good for use over the mesh network")
|
||||
return "error running command"
|
||||
else:
|
||||
logger.debug("FileMon: x: command is disabled by no enable_runShellCmd")
|
||||
return "command is disabled"
|
||||
|
||||
def initNewsSources():
|
||||
#check for the files _news.txt and add to the newsHeadlines list
|
||||
global newsSourcesList
|
||||
newsSourcesList = []
|
||||
for file in os.listdir(NEWS_DATA_DIR):
|
||||
if file.endswith('_news.txt'):
|
||||
source = file[:-9] # remove _news.txt
|
||||
newsSourcesList.append(source)
|
||||
return True
|
||||
logger.info("FileMon: No news sources found")
|
||||
return False
|
||||
|
||||
#initialize the headlines on startup
|
||||
initNewsSources()
|
||||
859
modules/games/README.md
Normal file
859
modules/games/README.md
Normal file
@@ -0,0 +1,859 @@
|
||||
# Meshtastic Mesh-Bot Games
|
||||
|
||||
## Game Index
|
||||
|
||||
- [Blackjack](#blackjack-game-module)
|
||||
- [DopeWars](#dopewars-game-module)
|
||||
- [GolfSim](#golfsim-game-module)
|
||||
- [Lemonade Stand](#lemonade-stand-game-module)
|
||||
- [Tic-Tac-Toe (2D/3D)](#tic-tac-toe-game-module)
|
||||
- [MasterMind](#mastermind-game-module)
|
||||
- [Battleship](#battleship-game-module)
|
||||
- [Video Poker](#video-poker-game-module)
|
||||
- [Hangman](#hangman-game-module)
|
||||
- [Quiz](#quiz-game-module)
|
||||
- [Survey](#survey--module-game)
|
||||
- [Word of the Day Game](#word-of-the-day-game--rules--features)
|
||||
- [Game Server](#game-server-configuration-gameini)
|
||||
- [PyGame Help](#pygame-help)
|
||||
---
|
||||
|
||||
|
||||
# Blackjack Game Module
|
||||
|
||||
This module implements a classic game of Blackjack (Casino 21) for the Meshtastic mesh-bot.
|
||||
|
||||
## How to Play
|
||||
|
||||
- **Start the Game:**
|
||||
Send the command `blackjack` via DM to the bot to start a new game session.
|
||||
- **Place a Bet:**
|
||||
When prompted, enter the amount you wish to wager (e.g., `5`). Minimum bet is 1 chip, maximum is your current chip total.
|
||||
- **Gameplay Commands:**
|
||||
After betting, you will be dealt two cards. The dealer will also have two cards (one face up).
|
||||
- `h` or `hit` — Draw another card.
|
||||
- `s` or `stand` — End your turn and let the dealer play.
|
||||
- `d` or `double` — Double your bet and draw one more card (if you have enough chips).
|
||||
- `f` or `forfit` — Forfeit half your bet and end the round.
|
||||
- `r` or `resend` — Resend your current hand status.
|
||||
- `l` or `leave` — Leave the table and end your session.
|
||||
|
||||
- **Winning:**
|
||||
- Get as close to 21 as possible without going over.
|
||||
- If your hand exceeds 21, you bust and lose your bet.
|
||||
- If you beat the dealer without busting, you win your bet.
|
||||
- If you get a Blackjack (21 with two cards), you win 1.5x your bet.
|
||||
- If you tie the dealer, it's a push (no win/loss).
|
||||
|
||||
- **High Scores:**
|
||||
The module tracks the highest chip total achieved. If you beat the high score, you'll be notified!
|
||||
|
||||
## Notes
|
||||
|
||||
- Each player starts with 100 chips.
|
||||
- If you run out of chips, your balance will reset to 100.
|
||||
- The game state is tracked per player using your node ID.
|
||||
- Game progress and high scores are saved in `data/blackjack_hs.pkl`.
|
||||
- Only one game session per player is supported at a time.
|
||||
- For best results, play via DM to avoid interfering with other users' sessions.
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
You have 100 chips. Whats your bet?
|
||||
> 10
|
||||
|
||||
Player[14] 8♠️, 6♥️
|
||||
Dealer[10] 10♦️
|
||||
🧠Hit: 38% 👎, 62% 👍
|
||||
(H)it,(S)tand,(F)orfit,(D)ouble,(R)esend,(L)eave table
|
||||
> h
|
||||
|
||||
Player[18] 8♠️, 6♥️, 4♣️
|
||||
Dealer[10] 10♦️
|
||||
🧠Hit: 77% 👎, 23% 👍
|
||||
[H,S,F,D]
|
||||
> s
|
||||
|
||||
Player[18] 8♠️, 6♥️, 4♣️
|
||||
Dealer[20] 10♦️, Q♠️
|
||||
👎DEALER WINS
|
||||
📊🏆P:0,D:1,T:0
|
||||
💰You have 90 chips
|
||||
Bet or Leave?
|
||||
```
|
||||
|
||||
## Credits
|
||||
|
||||
- Ported from [Himan10/BlackJack](https://github.com/Himan10/BlackJack)
|
||||
- Adapted for Meshtastic mesh-bot by K7MHI Kelly Keeton 2024
|
||||
|
||||
# DopeWars Game Module
|
||||
|
||||
A text-based trading game inspired by the classic DopeWars/DrugWars, adapted for the Meshtastic mesh-bot.
|
||||
|
||||
## How to Play
|
||||
|
||||
- **Start the Game:**
|
||||
Send the command `dopewars` via DM to the bot to begin a new session.
|
||||
|
||||
- **Objective:**
|
||||
Travel between cities, buy and sell drugs, and try to maximize your cash in 7 days.
|
||||
|
||||
- **Game Flow:**
|
||||
1. **Pick a Starting City:**
|
||||
You’ll be shown a list of cities. Enter the number to choose your starting location.
|
||||
2. **Each Day:**
|
||||
- You’ll see drug prices, your inventory, and your cash.
|
||||
- You can buy, sell, or fly to a new city.
|
||||
- Random events may occur (police, market changes, or finding cash/drugs).
|
||||
3. **Commands:**
|
||||
- **Buy:** `b,drug#,qty#` (e.g., `b,1,10` buys 10 of drug 1)
|
||||
- **Sell:** `s,drug#,qty#` (e.g., `s,2,5` sells 5 of drug 2)
|
||||
- **Max:** Use `m` for max quantity (e.g., `b,1,m`)
|
||||
- **Sell All:** Just `s` to sell everything you have.
|
||||
- **Fly:** `f` to move to a new city (ends the day).
|
||||
- **Price List:** `p` to view current prices and inventory.
|
||||
- **End Game:** `e` to end your run early.
|
||||
4. **Repeat:**
|
||||
Each time you fly, a day passes. After 7 days, your final cash is your score.
|
||||
|
||||
- **Winning:**
|
||||
- Try to finish with as much cash as possible.
|
||||
- Beat the high score to be crowned the top dealer!
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
1. Red Deer 2. Edmonton 3. Calgary 4. Toronto 5. Vancouver 6. St. Johns Where do you want to 🛫?#
|
||||
> 2
|
||||
|
||||
🗺️Edmonton 📆1/7 🎒0/100 💵5,000
|
||||
#1.Cocaine$15,000(0) #2.Heroin$2,500(0) #3.Weed$800(0) ...
|
||||
Buy💸, Sell💰, (F)ly🛫? (P)riceList?
|
||||
> b,2,10
|
||||
|
||||
Heroin: you have🎒 0 The going price is: $2,500
|
||||
You bought 10 Heroin. Remaining cash: $47,500
|
||||
Buy💸, Sell💰, Fly🛫?
|
||||
> f
|
||||
|
||||
🗺️Toronto 📆2/7 🎒10/100 💵47,500
|
||||
...
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- You start with $5,000 and a 100-slot backpack.
|
||||
- Each drug has a random price per city and day.
|
||||
- Special events can spike or crash prices, or cause you to lose/gain cash or inventory.
|
||||
- Police may confiscate your drugs or cash.
|
||||
- High scores are saved in `data/dopewar_hs.pkl`.
|
||||
- Only one game session per player at a time.
|
||||
- Play via DM for best experience.
|
||||
|
||||
## Credits
|
||||
|
||||
- Ported from [Reconfirefly/drugwars](https://github.com/Reconfirefly/drugwars)
|
||||
- Adapted for Meshtastic mesh-bot by K7MHI Kelly Keeton 2024
|
||||
|
||||
# GolfSim Game Module
|
||||
|
||||
A text-based golf simulator for the Meshtastic mesh-bot. Play a full 9-hole round, choose your clubs, and try to set a new course record!
|
||||
|
||||
## How to Play
|
||||
|
||||
- **Start the Game:**
|
||||
Send the command `golf` via DM to the bot to begin a new round.
|
||||
|
||||
- **Objective:**
|
||||
Complete 9 holes in as few strokes as possible.
|
||||
|
||||
- **Game Flow:**
|
||||
1. **Each Hole:**
|
||||
- The bot tells you the hole number, length, par, and any hazards or weather.
|
||||
- Choose your club for each shot by typing its name or initial:
|
||||
- `d` or `driver` — Longest club
|
||||
- `l` or `low` — Low iron
|
||||
- `m` or `mid` — Mid iron
|
||||
- `h` or `high` — High iron
|
||||
- `g` or `gap` — Gap wedge
|
||||
- `w` or `wedge` — Lob wedge
|
||||
- `c` or `caddy` — Get a caddy guess for club distances
|
||||
- The bot will tell you how far you hit and how far remains.
|
||||
- When you’re within 20 yards, you’ll automatically putt to finish the hole.
|
||||
2. **Scoring:**
|
||||
- The bot tracks your strokes and score relative to par.
|
||||
- After each hole, you’ll see your score for the hole and your running total.
|
||||
3. **Hazards & Surprises:**
|
||||
- Hazards (sand, water, trees, etc.) and random events may affect your shots.
|
||||
- Critters or weather can cause unexpected results!
|
||||
4. **End of Round:**
|
||||
- After 9 holes, your total strokes and score to par are shown.
|
||||
- If you set a new low score, you’ll be notified as the new club record holder!
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
⛳️#1 is a 410-yard Par 4.☀️
|
||||
Choose your club.
|
||||
> d
|
||||
🏌️Hit D 260yd.
|
||||
You have 150yd. ⛳️
|
||||
Club?[D, L, M, H, G, W]🏌️
|
||||
> m
|
||||
🏌️Hit M Iron 170yd. Overshot the green!🚀
|
||||
You have 20yd. ⛳️
|
||||
Club?[D, L, M, H, G, W]🏌️
|
||||
> w
|
||||
🏌️Hit L Wedge 30yd. You're on the green! After 2 putt(s), you're in for 5 strokes. +Bogey
|
||||
You've hit a total of 5 strokes today, for +Bogey
|
||||
...
|
||||
🎉Finished 9-hole round⛳️ 🏆New Club Record🏆
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Play via DM for best experience.
|
||||
- Hazards and weather are randomized for each hole.
|
||||
- High scores are saved in `data/golfsim_hs.pkl`.
|
||||
- Only one game session per player at a time.
|
||||
- Commands are not case-sensitive; you can use full club names or initials.
|
||||
|
||||
## Credits
|
||||
|
||||
- Ported from [danfriedman30/pythongame](https://github.com/danfriedman30/pythongame)
|
||||
- Adapted for Meshtastic mesh-bot by K7MHI Kelly Keeton 2024
|
||||
|
||||
# Lemonade Stand Game Module
|
||||
|
||||
A text-based business simulation where you run your own lemonade stand! Buy supplies, set prices, and try to maximize your profits over a summer season.
|
||||
|
||||
## How to Play
|
||||
|
||||
- **Start the Game:**
|
||||
Send the command `lemonade` via DM to the bot to begin a new game.
|
||||
|
||||
- **Objective:**
|
||||
Make as much money as possible in 7 weeks by managing your lemonade stand.
|
||||
|
||||
- **Game Flow:**
|
||||
1. **Each Week:**
|
||||
- The bot will show you the weather, temperature, and sales potential.
|
||||
- Buy supplies: cups, lemons, and sugar. Enter the number of each to purchase, or `n` for none.
|
||||
- Set your selling price per cup.
|
||||
- The bot will simulate sales and show your results, profits, and remaining inventory.
|
||||
- Repeat for each week.
|
||||
2. **Commands:**
|
||||
- Enter a number to buy supplies or set price.
|
||||
- Use `n` to skip buying an item.
|
||||
- Enter `g` during pricing to go back and buy more supplies.
|
||||
- At the end of each week, choose to continue or end the game.
|
||||
3. **Scoring:**
|
||||
- Your score is based on your net profit and efficiency (profit vs. possible profit).
|
||||
- High scores are tracked and displayed at the end of the game.
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
LemonStand🍋Week #1 of 7. 85ºF Sunny ☀️
|
||||
SupplyCost $0.45 a cup.
|
||||
Sales Potential: 60 cups.
|
||||
Inventory: 🥤:0 🍋:0 🍚:0
|
||||
Prices:
|
||||
🥤:$2.50 📦 of 25.
|
||||
🍋:$4.00 🧺 of 8.
|
||||
🍚:$3.00 bag for 15🥤.
|
||||
💵:$30.00
|
||||
🥤 to buy?
|
||||
Have 0 Cost $2.50 a 📦 of 25
|
||||
> 2
|
||||
|
||||
Purchased 2 📦 50 🥤 in inventory. $25.00 remaining
|
||||
🍋 to buy?
|
||||
Have 0🥤 of 🍋 Cost $4.00 a 🧺 for 8🥤
|
||||
> 1
|
||||
|
||||
Purchased 1 🧺 8 🍋 in inventory. $21.00 remaining
|
||||
🍚 to buy?
|
||||
You have 0🥤 of 🍚, Cost $3.00 a bag for 15🥤
|
||||
> 1
|
||||
|
||||
Purchased 1 bag(s) of 🍚 for $3.00. 15🥤🍚 in inventory.
|
||||
Cost of goods is $0.45 per 🥤 $18.00 💵 remaining.
|
||||
Price to Sell? or (G)rocery to buy more 🥤🍋🍚
|
||||
> 1.25
|
||||
|
||||
Results Week📊#1 of 7 Cost/Price:$0.45/$1.25 P.Margin:$0.80 T.Sales:16@$1.25 G.Profit: $20.00 N.Profit:$12.80
|
||||
Remaining 🥤:34 🍋:0 🍚:0 💵:$38.00📊P&L📈$8.00
|
||||
Weekly📊#1. 16 sold x $1.25ea.
|
||||
Play another week🥤? or (E)nd Game
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- You start with $30.00 and must buy supplies each week.
|
||||
- Weather and temperature affect sales potential.
|
||||
- If you run out of any supply, you can't sell more lemonade that week.
|
||||
- High scores are saved in `data/lemonstand.pkl`.
|
||||
- Only one game session per player at a time.
|
||||
- Play via DM for best experience.
|
||||
|
||||
## Credits
|
||||
|
||||
- Ported from [tigerpointe/Lemonade-Stand](https://github.com/tigerpointe/Lemonade-Stand)
|
||||
- Adapted for Meshtastic mesh-bot by K7MHI Kelly Keeton 2024
|
||||
|
||||
# Tic-Tac-Toe Game Module
|
||||
|
||||
A classic Tic-Tac-Toe game for the Meshtastic mesh-bot. Play against the bot, track your stats, and see if you can beat the AI!
|
||||
|
||||

|
||||
|
||||
## How to Play
|
||||
|
||||
- **Start the Game:**
|
||||
Send the command `tictactoe` via DM to the bot to begin a new game.
|
||||
|
||||
- **3D Mode:**
|
||||
You can play in 3D mode by sending `new 3d` during a game session. The board expands to 27 positions (1-27) and supports 3D win lines.
|
||||
|
||||
- **Run as a Game Server (Optional):**
|
||||
For UDP/visual/remote play, you can run the dedicated game server:
|
||||
```sh
|
||||
python3 script/game_serve.py
|
||||
```
|
||||
This enables networked play and visual board updates if supported.
|
||||
[PyGame Help](#pygame-help)
|
||||
|
||||
- **Objective:**
|
||||
Get three of your marks in a row (horizontally, vertically, or diagonally) before the bot does.
|
||||
|
||||
- **Game Flow:**
|
||||
1. **Board Layout:**
|
||||
- The board is numbered 1-9 (2D) or 1-27 (3D), left to right, top to bottom.
|
||||
- Example (2D):
|
||||
```
|
||||
1 | 2 | 3
|
||||
4 | 5 | 6
|
||||
7 | 8 | 9
|
||||
```
|
||||
2. **Making Moves:**
|
||||
- On your turn, type the number (1-9 or 1-27) where you want to place your mark.
|
||||
- The bot will respond with the updated board and make its move.
|
||||
3. **Commands:**
|
||||
- `n` — Start a new game.
|
||||
- `new 2d` or `new 3d` — Start a new game in 2D or 3D mode.
|
||||
- `e` or `q` — End the current game.
|
||||
- `b` — Show the current board.
|
||||
- Enter a number (1-9 or 1-27) to make a move.
|
||||
4. **Winning:**
|
||||
- The first to get three in a row wins.
|
||||
- If the board fills with no winner, it’s a tie.
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
❌ | 2 | 3
|
||||
4 | ⭕️ | 6
|
||||
7 | 8 | 9
|
||||
|
||||
Your turn! Pick 1-9:
|
||||
> 3
|
||||
|
||||
❌ | 2 | ❌
|
||||
4 | ⭕️ | 6
|
||||
7 | 8 | 9
|
||||
|
||||
🤖Bot wins! (n)ew (e)nd
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Emojis are used for X and O unless disabled in settings.
|
||||
- Your win/loss stats are tracked across games.
|
||||
- The bot will try to win, block you, or pick a random move.
|
||||
- Play via DM for best experience, or run the game server for network/visual play.
|
||||
- Only one game session per player at a time.
|
||||
|
||||
## Credits
|
||||
|
||||
- Written for Meshtastic mesh-bot by Martin, refactored by K7MHI
|
||||
|
||||
# MasterMind Game Module
|
||||
|
||||
A text-based version of the classic code-breaking game MasterMind for the Meshtastic mesh-bot. Try to guess the secret color code in as few turns as possible!
|
||||
|
||||
## How to Play
|
||||
|
||||
- **Start the Game:**
|
||||
Send the command `mmind` via DM to the bot to begin a new game.
|
||||
|
||||
- **Objective:**
|
||||
Guess the secret 4-color code in 10 turns or less.
|
||||
|
||||
- **Game Flow:**
|
||||
1. **Choose Difficulty:**
|
||||
- (N)ormal: 4 colors (R🔴, Y🟡, G🟢, B🔵)
|
||||
- (H)ard: 6 colors (R🔴, Y🟡, G🟢, B🔵, O🟠, P🟣)
|
||||
- e(X)pert: 8 colors (R🔴, Y🟡, G🟢, B🔵, O🟠, P🟣, W⚪, K⚫)
|
||||
- Type `n`, `h`, or `x` to select.
|
||||
2. **Guessing:**
|
||||
- Enter a 4-letter code using the color initials (e.g., `RGBY`).
|
||||
- The bot will respond with feedback:
|
||||
- ✅ color ✅ position: correct color in the correct spot
|
||||
- ✅ color 🚫 position: correct color, wrong spot
|
||||
- 🚫No pins: none of your colors are in the code
|
||||
- You have 10 turns to guess the code.
|
||||
3. **Winning:**
|
||||
- Guess the code exactly to win!
|
||||
- Your number of turns is tracked for high scores.
|
||||
- After a win or loss, you can play again by choosing a difficulty.
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
The colors to choose from are:
|
||||
R🔴, Y🟡, G🟢, B🔵
|
||||
Enter your guess (e.g., RGBY):
|
||||
> RGYB
|
||||
|
||||
Turn 1:
|
||||
Guess🔴🟢🟡🔵
|
||||
✅ color ✅ position: 2
|
||||
✅ color 🚫 position: 1
|
||||
|
||||
> RYGB
|
||||
|
||||
Turn 2:
|
||||
🏆Correct🔴🟡🟢🔵
|
||||
You are the master mind!🤯
|
||||
🏆 High Score:2 turns, Difficulty:n
|
||||
Would you like to play again? (N)ormal, (H)ard, or e(X)pert?
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Only one game session per player at a time.
|
||||
- High scores are saved in `data/mmind_hs.pkl`.
|
||||
- Play via DM for best experience.
|
||||
- Input is not case-sensitive, but guesses must be exactly 4 letters.
|
||||
|
||||
## Credits
|
||||
|
||||
- Ported from [pwdkramer/pythonMastermind](https://github.com/pwdkramer/pythonMastermind)
|
||||
- Adapted for Meshtastic mesh-bot by K7MHI Kelly Keeton 2024
|
||||
|
||||
# Video Poker Game Module
|
||||
|
||||
A text-based Video Poker game for the Meshtastic mesh-bot. Play classic five-card draw poker, place your bets, and try to build your bankroll!
|
||||
|
||||
## How to Play
|
||||
|
||||
- **Start the Game:**
|
||||
Send the command `videopoker` via DM to the bot to begin a new session.
|
||||
|
||||
- **Objective:**
|
||||
Win as many coins as possible by making the best poker hands.
|
||||
|
||||
- **Game Flow:**
|
||||
1. **Place Your Bet:**
|
||||
- You start with 20 coins.
|
||||
- Enter your bet (1-5 coins) to begin each hand.
|
||||
2. **Draw Cards:**
|
||||
- You are dealt 5 cards.
|
||||
- The bot will show your hand and a hint about its strength.
|
||||
3. **Redraw:**
|
||||
- Choose which cards to replace:
|
||||
- Enter numbers (e.g., `1,3,4`) to redraw those cards.
|
||||
- Enter `a` to redraw all cards.
|
||||
- Enter `n` to keep your current hand.
|
||||
- Enter `h` to show your hand again.
|
||||
- You can only redraw once per hand.
|
||||
4. **Scoring:**
|
||||
- After the redraw, your hand is scored and winnings are paid out based on the hand type.
|
||||
- If you run out of coins, your balance resets to 20.
|
||||
- High scores are tracked and announced.
|
||||
5. **Continue:**
|
||||
- Place another bet to play again, or enter `l` to leave the table.
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
You have 20 coins,
|
||||
Whats your bet?
|
||||
> 5
|
||||
|
||||
K♠️ 7♦️ 7♣️ 2♥️ 9♠️
|
||||
Showing:Pair👯
|
||||
Deal new card?
|
||||
ex: 1,3,4 or (N)o,(A)ll (H)and
|
||||
> 1,4
|
||||
|
||||
7♦️ 7♣️ 9♠️ 3♣️ Q♥️
|
||||
Your hand, Pair👯. Your bankroll is now 22 coins.
|
||||
Place your Bet, or (L)eave Table.
|
||||
```
|
||||
|
||||
## Hand Rankings & Payouts
|
||||
|
||||
- 👑Royal Flush🚽 — 10x bet
|
||||
- 🧻Straight Flush🚽 — 9x bet
|
||||
- Flush🚽 — 8x bet
|
||||
- Full House🏠 — 7x bet
|
||||
- Four of a Kind👯👯 — 6x bet
|
||||
- Three of a Kind☘️ — 5x bet
|
||||
- Two Pair👯👯 — 4x bet
|
||||
- Straight📏 — 3x bet
|
||||
- Pair👯 — 2x bet
|
||||
- Bad Hand 🙈 — Lose bet
|
||||
|
||||
## Notes
|
||||
|
||||
- Only one game session per player at a time.
|
||||
- High scores are saved in `data/videopoker_hs.pkl`.
|
||||
- Play via DM for best experience.
|
||||
- Bets must be between 1 and 5 coins and not exceed your bankroll.
|
||||
|
||||
## Credits
|
||||
|
||||
- Ported from [devtronvarma/Video-Poker-Terminal-Game](https://github.com/devtronvarma/Video-Poker-Terminal-Game)
|
||||
- Adapted for Meshtastic mesh-bot by K7MHI Kelly Keeton 2024
|
||||
|
||||
|
||||
# Battleship Game Module
|
||||
|
||||
A classic Battleship game for the Meshtastic mesh-bot. Play solo against the AI or challenge another user in peer-to-peer (P2P) mode!
|
||||
|
||||
## How to Play
|
||||
|
||||
- **Start a New Game (vs AI):**
|
||||
Send `battleship` via DM to the bot to start a new game against the AI.
|
||||
|
||||
- **Start a New P2P Game:**
|
||||
Send `battleship new` to create a game and receive a join code.
|
||||
Share the code with another user.
|
||||
|
||||
- **Join a P2P Game:**
|
||||
Send `battleship join <code>` (replace `<code>` with the provided number) to join a waiting game.
|
||||
|
||||
- **View Open Games:**
|
||||
Send `battleship lobby` to see a list of open P2P games waiting for players.
|
||||
|
||||
- **Gameplay:**
|
||||
- Enter your move using coordinates:
|
||||
- Format: `B4` or `B,4` (row letter, column number)
|
||||
- Example: `C7`
|
||||
- The bot will show your radar, ship status, and results after each move.
|
||||
- In P2P, you and your opponent take turns. The bot will notify you when it’s your turn.
|
||||
|
||||
- **End Game:**
|
||||
Send `end` or `exit` to leave your current game.
|
||||
|
||||
## Rules & Features
|
||||
|
||||
- 10x10 grid, classic ship sizes (Carrier, Battleship, Cruiser, Submarine, Destroyer).
|
||||
- Ships are placed randomly.
|
||||
- In P2P, the joining player goes first.
|
||||
- Radar view shows a 4x4 grid centered on your last move.
|
||||
- Game tracks whose turn it is and notifies the next player in P2P mode.
|
||||
- Game ends when all ships of one player are sunk.
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
New 🚢Battleship🤖 game started!
|
||||
Enter your move using coordinates: row-letter, column-number.
|
||||
Example: B5 or C,7
|
||||
Type 'exit' or 'end' to quit the game.
|
||||
|
||||
> B4
|
||||
|
||||
Your move: 💥Hit!
|
||||
AI ships: 5/5 afloat
|
||||
Radar:
|
||||
🗺️3 4 5 6
|
||||
B ~ ~ * ~
|
||||
C ~ ~ ~ ~
|
||||
D ~ ~ ~ ~
|
||||
E ~ ~ ~ ~
|
||||
AI move: D7 (missed)
|
||||
Your ships: 5/5 afloat
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Only one Battleship session per player at a time.
|
||||
- Play via DM for best experience.
|
||||
- In P2P, share the join code with your opponent.
|
||||
- Coordinates are not case-sensitive.
|
||||
|
||||
## Credits
|
||||
|
||||
- Written for Meshtastic mesh-bot by K7MHI Kelly Keeton 2025
|
||||
|
||||
# Word of the Day Game — Rules & Features
|
||||
|
||||
- **Word of the Day:**
|
||||
Each day, a new word is chosen from `data/wotd.json` (or a default list if missing). Mention the word (or its leet/1337 variants) in chat to win and trigger a new word.
|
||||
- **Bingo Mini-Game:**
|
||||
A random 3x3 bingo card of words, drawn from `data/bingo.json` (or a default list if missing). Mention words from the card in chat. Complete a row, column, or diagonal to win BINGO and get a new card.
|
||||
- **Emoji Mini-Game:**
|
||||
Use emojis in chat to:
|
||||
- Play a slot machine: send the same emoji several times in a row to hit the JACKPOT!
|
||||
- **Data Files:**
|
||||
- `data/wotd.json`: List of words and definitions for the Word of the Day.
|
||||
[
|
||||
{
|
||||
"word": "serendipity",
|
||||
"meta": "The occurrence of events by chance in a happy or beneficial way."
|
||||
},
|
||||
{
|
||||
"word": "ephemeral",
|
||||
"meta": "Lasting for a very short time."
|
||||
},
|
||||
{
|
||||
"word": "sonder",
|
||||
"meta": "The realization that each passerby has a life as vivid and complex as your own."
|
||||
}
|
||||
]
|
||||
- `data/bingo.json`: List of words for bingo cards.
|
||||
[
|
||||
"dog",
|
||||
"cat",
|
||||
"fish",
|
||||
"bird",
|
||||
"hamster",
|
||||
"rabbit",
|
||||
"turtle",
|
||||
"lizard",
|
||||
"snake"
|
||||
]
|
||||
|
||||
# Hangman Game Module
|
||||
|
||||
A classic word-guessing game for the Meshtastic mesh-bot. Try to guess the hidden word one letter at a time before you run out of chances!
|
||||
|
||||
## How to Play
|
||||
|
||||
- **Start the Game:**
|
||||
Send the command `hangman` via DM to the bot to begin a new game.
|
||||
|
||||
- **Objective:**
|
||||
Guess the secret word by suggesting letters, one at a time. Each incorrect guess brings you closer to losing!
|
||||
|
||||
- **Game Flow:**
|
||||
1. **New Game:**
|
||||
- The bot picks a random word and shows you its masked form (e.g., `_ _ _ _ _`).
|
||||
- You’ll see your total games played and games won.
|
||||
2. **Guessing:**
|
||||
- Type a single letter to guess.
|
||||
- Correct guesses reveal all instances of that letter in the word.
|
||||
- Incorrect guesses are tracked; you have 6 chances before the game ends.
|
||||
- The bot shows your progress, wrong guesses, and a hangman emoji status.
|
||||
3. **Winning & Losing:**
|
||||
- Guess all letters before reaching 6 wrong guesses to win!
|
||||
- If you lose, the bot reveals the word and starts a new game.
|
||||
|
||||
- **Commands:**
|
||||
- Enter a single letter to guess.
|
||||
- Start a new game by sending `hangman` again.
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
_ _ _ _ _ _ _
|
||||
Guess a letter
|
||||
|
||||
|
||||
🥳
|
||||
Total Games: 1, Won: 1
|
||||
M E S H T A S T I C
|
||||
Guess a letter
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The word list is loaded from `data/hangman.json` if available, or uses a built-in default list. [\"apple\",\"banana\",\"cherry\"]
|
||||
- Game stats are tracked per player.
|
||||
- Only one game session per player at a time.
|
||||
- Play via DM for best experience.
|
||||
|
||||
## Data Files
|
||||
|
||||
- `data/hangman.json`: List of words for Hangman.
|
||||
Example:
|
||||
```
|
||||
[
|
||||
"apple",
|
||||
"banana",
|
||||
"cherry"
|
||||
]
|
||||
```
|
||||
|
||||
## Credits
|
||||
|
||||
- Written for Meshtastic mesh-bot by ZR1RF Johannes le Roux 2025
|
||||
|
||||
# Quiz Game Module
|
||||
|
||||
This module implements a multiplayer quiz game for the Meshtastic mesh-bot.
|
||||
|
||||
## How to Play
|
||||
|
||||
- **Start the Game:**
|
||||
The quizmaster starts the quiz session (usually with `/quiz start` or similar command).
|
||||
- **Join the Game:**
|
||||
Players join by sending `/quiz join` or by answering a question while a quiz is active.
|
||||
- **Answer Questions:**
|
||||
- Use `Q: <answer>` to answer the current question.
|
||||
- For multiple choice, answer with `A`, `B`, `C`, etc.
|
||||
- For free-text, type the answer after `Q: `.
|
||||
- Use `Q: ?` to request the next question.
|
||||
- **Leave the Game:**
|
||||
Players can leave at any time with `/quiz leave`.
|
||||
- **Stop the Game:**
|
||||
The quizmaster stops the quiz session (e.g., `/quiz stop`). Final scores and the top 3 players are announced.
|
||||
|
||||
## Rules & Features
|
||||
|
||||
- Only the quizmaster can start or stop the quiz.
|
||||
- Players can join or leave at any time while the quiz is active.
|
||||
- Questions are loaded from quiz_questions.json and can be multiple choice or free-text.
|
||||
- Players earn 1 point for each correct answer.
|
||||
- The first player to answer each question correctly is noted.
|
||||
- The top 3 players are displayed at the end of the quiz.
|
||||
- The quizmaster can broadcast messages to all players.
|
||||
|
||||
## Example Commands
|
||||
|
||||
- Start quiz:
|
||||
`/quiz start`
|
||||
- Join quiz:
|
||||
`/quiz join`
|
||||
- Answer a question:
|
||||
`Q: B`
|
||||
`Q: Paris`
|
||||
- Next question:
|
||||
`Q: ?`
|
||||
- Leave quiz:
|
||||
`/quiz leave`
|
||||
- Stop quiz:
|
||||
`/quiz stop`
|
||||
|
||||
## Notes
|
||||
|
||||
- Only one quiz can be active at a time.
|
||||
- Players can only answer each question once.
|
||||
- The quizmaster is defined by the `bbs_admin_list` variable.
|
||||
- Questions must be formatted correctly in the JSON file for the game to function.
|
||||
|
||||
---
|
||||
|
||||
**Written for Meshtastic mesh-bot by K7MHI Kelly Keeton 2025**
|
||||
|
||||
Certainly! Here’s documentation for the **Survey Game Module** in the same format as your other game modules:
|
||||
|
||||
---
|
||||
|
||||
# Survey Module "game"
|
||||
This module implements a survey system for the Meshtastic mesh-bot.
|
||||
|
||||
## How to Play
|
||||
|
||||
- **Start the Survey:**
|
||||
Users start a survey by specifying the survey name (e.g., `/survey start example`).
|
||||
The survey will prompt the user with the first question.
|
||||
|
||||
- **Answer Questions:**
|
||||
- For multiple choice: reply with a letter (A, B, C, ...).
|
||||
- For integer: reply with a number.
|
||||
- For text: reply with your answer as text.
|
||||
After each answer, the next question is shown automatically.
|
||||
|
||||
- **End the Survey:**
|
||||
The survey ends automatically after the last question, or the user can send `end` to finish early.
|
||||
Responses are saved to a CSV file.
|
||||
|
||||
## Rules & Features
|
||||
|
||||
- Surveys are defined in JSON files in surveys (e.g., `example_survey.json`).
|
||||
- Each survey can have multiple choice, integer, or text questions.
|
||||
- User responses are saved to a CSV file named `<survey_name>_responses.csv` in the same directory.
|
||||
- Users can only answer each question once per survey session.
|
||||
- Survey results can be summarized and reported by the bot.
|
||||
|
||||
## Example Commands
|
||||
|
||||
- Start a survey:
|
||||
`/survey start example`
|
||||
- Answer a multiple choice question:
|
||||
`A`
|
||||
- Answer an integer question:
|
||||
`42`
|
||||
- Answer a text question:
|
||||
`My favorite color is blue.`
|
||||
- End the survey early:
|
||||
`end`
|
||||
- Get survey results (admin):
|
||||
`/survey results example`
|
||||
|
||||
## Notes
|
||||
|
||||
- Only surveys listed in the surveys directory with the `_survey.json` suffix are available.
|
||||
- Each user’s responses are tracked separately.
|
||||
- Results are summarized and can be displayed by the bot.
|
||||
|
||||
---
|
||||
|
||||
**Written for Meshtastic mesh-bot by K7MHI Kelly Keeton 2025**
|
||||
|
||||
___
|
||||
|
||||
# Game Server Configuration (`game.ini`)
|
||||
|
||||
The game server (`script/game_serve.py`) supports configuration via a `game.ini` file placed in the same directory as the script. This allows you to customize network and node settings without modifying the Python code.
|
||||
|
||||
## How to Use
|
||||
|
||||
1. **Create a `game.ini` file** in the `script/` directory (next to `game_serve.py`).
|
||||
|
||||
If `game.ini` is not present, the server will use built-in default values.
|
||||
|
||||
---
|
||||
|
||||
|
||||
# PyGame Help
|
||||
|
||||
'pygame - Community Edition' ('pygame-ce' for short) is a fork of the original 'pygame' library by former 'pygame' core contributors.
|
||||
|
||||
It offers many new features and optimizations, receives much better maintenance and runs under a better governance model, while being highly compatible with code written for upstream pygame (`import pygame` still works).
|
||||
|
||||
**Details**
|
||||
- [Initial announcement on Reddit](<https://www.reddit.com/r/pygame/comments/1112q10/pygame_community_edition_announcement/>) (or https://discord.com/channels/772505616680878080/772506385304649738/1074593440148500540)
|
||||
- [Why the forking happened](<https://www.reddit.com/r/pygame/comments/18xy7nf/what_was_the_disagreement_that_led_to_pygamece/>)
|
||||
|
||||
**Helpful Links**
|
||||
- https://discord.com/channels/772505616680878080/772506385304649738
|
||||
- [Our GitHub releases](<https://github.com/pygame-community/pygame-ce/releases>)
|
||||
- [Our docs](https://pyga.me/docs/)
|
||||
|
||||
**Installation**
|
||||
```sh
|
||||
pip uninstall pygame # Uninstall pygame first since it would conflict with pygame-ce
|
||||
pip install pygame-ce
|
||||
```
|
||||
-# Because 'pygame' installs to the same location as 'pygame-ce', it must first be uninstalled.
|
||||
-# Note that the `import pygame` syntax has not changed with pygame-ce.
|
||||
|
||||
# mUDP Help
|
||||
|
||||
mUDP library provides UDP-based broadcasting of Meshtastic-compatible packets. MeshBot uses this for the game_server_display server.
|
||||
|
||||
**Details**
|
||||
- [pdxlocations/mudp](https://github.com/pdxlocations/mudp)
|
||||
|
||||
**Installation**
|
||||
```sh
|
||||
pip install mudp
|
||||
```
|
||||
510
modules/games/battleship.py
Normal file
510
modules/games/battleship.py
Normal file
@@ -0,0 +1,510 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# Battleship game module Meshing Around
|
||||
# 2025 K7MHI Kelly Keeton
|
||||
import random
|
||||
import copy
|
||||
import uuid
|
||||
import time
|
||||
from modules.settings import battleshipTracker
|
||||
|
||||
OCEAN = "~"
|
||||
FIRE = "x"
|
||||
HIT = "*"
|
||||
SIZE = 10
|
||||
SHIPS = [5, 4, 3, 3, 2]
|
||||
SHIP_NAMES = ["✈️Carrier", "Battleship", "Cruiser", "Submarine", "Destroyer"]
|
||||
|
||||
class Session:
|
||||
def __init__(self, player1_id, player2_id=None, vs_ai=True):
|
||||
self.session_id = str(uuid.uuid4())
|
||||
self.vs_ai = vs_ai
|
||||
self.player1_id = player1_id
|
||||
self.player2_id = player2_id
|
||||
self.game = Battleship(vs_ai=vs_ai)
|
||||
self.next_turn = player1_id
|
||||
self.last_move = None
|
||||
self.shots_fired = 0
|
||||
self.start_time = time.time()
|
||||
|
||||
class Battleship:
|
||||
sessions = {}
|
||||
short_codes = {}
|
||||
|
||||
@classmethod
|
||||
def _generate_short_code(cls):
|
||||
while True:
|
||||
code = str(random.randint(1000, 9999))
|
||||
if code not in cls.short_codes:
|
||||
return code
|
||||
|
||||
@classmethod
|
||||
def new_game(cls, player_id, vs_ai=True, p2p_id=None):
|
||||
session = Session(player1_id=player_id, player2_id=p2p_id, vs_ai=vs_ai)
|
||||
cls.sessions[session.session_id] = session
|
||||
if not vs_ai:
|
||||
code = cls._generate_short_code()
|
||||
cls.short_codes[code] = session.session_id
|
||||
msg = (
|
||||
"New 🚢Battleship🚢 game started!\n"
|
||||
"Joining player goes first, waiting for them to join...\n"
|
||||
f"Share\n'battleship join {code}'"
|
||||
)
|
||||
return msg, code
|
||||
else:
|
||||
msg = (
|
||||
"New 🚢Battleship🤖 game started!\n"
|
||||
"Enter your move using coordinates: row-letter, column-number.\n"
|
||||
"Example: B5 or C,7\n"
|
||||
"Type 'exit' or 'end' to quit the game."
|
||||
)
|
||||
return msg, session.session_id
|
||||
|
||||
@classmethod
|
||||
def end_game(cls, session_id):
|
||||
if session_id in cls.sessions:
|
||||
del cls.sessions[session_id]
|
||||
return "Thanks for playing 🚢Battleship🚢"
|
||||
|
||||
@classmethod
|
||||
def get_session(cls, code_or_session_id):
|
||||
session_id = cls.short_codes.get(code_or_session_id, code_or_session_id)
|
||||
return cls.sessions.get(session_id)
|
||||
|
||||
def __init__(self, vs_ai=True):
|
||||
if vs_ai:
|
||||
self.player_board = self._blank_board()
|
||||
self.ai_board = self._blank_board()
|
||||
self.player_radar = self._blank_board()
|
||||
self.ai_radar = self._blank_board()
|
||||
self.number_board = self._blank_board()
|
||||
self.player_alive = sum(SHIPS)
|
||||
self.ai_alive = sum(SHIPS)
|
||||
self._place_ships(self.player_board, self.number_board)
|
||||
self._place_ships(self.ai_board)
|
||||
self.ai_targets = []
|
||||
self.ai_last_hit = None
|
||||
self.ai_orientation = None
|
||||
else:
|
||||
# P2P: Each player has their own board and radar
|
||||
self.player1_board = self._blank_board()
|
||||
self.player2_board = self._blank_board()
|
||||
self.player1_radar = self._blank_board()
|
||||
self.player2_radar = self._blank_board()
|
||||
self.player1_alive = sum(SHIPS)
|
||||
self.player2_alive = sum(SHIPS)
|
||||
self._place_ships(self.player1_board)
|
||||
self._place_ships(self.player2_board)
|
||||
|
||||
def _blank_board(self):
|
||||
return [[OCEAN for _ in range(SIZE)] for _ in range(SIZE)]
|
||||
|
||||
def _place_ships(self, board, number_board=None):
|
||||
for idx, ship_len in enumerate(SHIPS):
|
||||
placed = False
|
||||
while not placed:
|
||||
vertical = random.choice([True, False])
|
||||
if vertical:
|
||||
row = random.randint(0, SIZE - ship_len)
|
||||
col = random.randint(0, SIZE - 1)
|
||||
if all(board[row + i][col] == OCEAN for i in range(ship_len)):
|
||||
for i in range(ship_len):
|
||||
board[row + i][col] = str(idx)
|
||||
if number_board is not None:
|
||||
number_board[row + i][col] = idx
|
||||
placed = True
|
||||
else:
|
||||
row = random.randint(0, SIZE - 1)
|
||||
col = random.randint(0, SIZE - ship_len)
|
||||
if all(board[row][col + i] == OCEAN for i in range(ship_len)):
|
||||
for i in range(ship_len):
|
||||
board[row][col + i] = str(idx)
|
||||
if number_board is not None:
|
||||
number_board[row][col + i] = idx
|
||||
placed = True
|
||||
|
||||
def player_move(self, row, col):
|
||||
"""Player fires at AI's board. Returns 'hit', 'miss', or 'sunk:<ship_idx>'."""
|
||||
if self.player_radar[row][col] != OCEAN:
|
||||
return "repeat"
|
||||
if self.ai_board[row][col] not in (OCEAN, FIRE, HIT):
|
||||
self.player_radar[row][col] = HIT
|
||||
ship_idx = int(self.ai_board[row][col])
|
||||
self.ai_board[row][col] = HIT
|
||||
if self._is_ship_sunk(self.ai_board, ship_idx):
|
||||
self.ai_alive -= SHIPS[ship_idx]
|
||||
return f"sunk:{ship_idx}"
|
||||
return "hit"
|
||||
else:
|
||||
self.player_radar[row][col] = FIRE
|
||||
self.ai_board[row][col] = FIRE
|
||||
return "miss"
|
||||
|
||||
def ai_move(self):
|
||||
"""AI fires at player's board. Returns (row, col, result or 'sunk:<ship_idx>')."""
|
||||
while True:
|
||||
row = random.randint(0, SIZE - 1)
|
||||
col = random.randint(0, SIZE - 1)
|
||||
if self.ai_radar[row][col] == OCEAN:
|
||||
break
|
||||
if self.player_board[row][col] not in (OCEAN, FIRE, HIT):
|
||||
self.ai_radar[row][col] = HIT
|
||||
ship_idx = int(self.player_board[row][col])
|
||||
self.player_board[row][col] = HIT
|
||||
if self._is_ship_sunk(self.player_board, ship_idx):
|
||||
self.player_alive -= SHIPS[ship_idx]
|
||||
return row, col, f"sunk:{ship_idx}"
|
||||
return row, col, "hit"
|
||||
else:
|
||||
self.ai_radar[row][col] = FIRE
|
||||
self.player_board[row][col] = FIRE
|
||||
return row, col, "miss"
|
||||
|
||||
def p2p_player_move(self, row, col, attacker, defender, radar, defender_alive_attr):
|
||||
"""P2P: attacker fires at defender's board, updates radar and defender's board."""
|
||||
if radar[row][col] != OCEAN:
|
||||
return "repeat"
|
||||
if defender[row][col] not in (OCEAN, FIRE, HIT):
|
||||
radar[row][col] = HIT
|
||||
ship_idx = int(defender[row][col])
|
||||
defender[row][col] = HIT
|
||||
if self._is_ship_sunk(defender, ship_idx):
|
||||
setattr(self, defender_alive_attr, getattr(self, defender_alive_attr) - SHIPS[ship_idx])
|
||||
return f"sunk:{ship_idx}"
|
||||
return "hit"
|
||||
else:
|
||||
radar[row][col] = FIRE
|
||||
defender[row][col] = FIRE
|
||||
return "miss"
|
||||
|
||||
def _is_ship_sunk(self, board, ship_idx):
|
||||
for row in board:
|
||||
for cell in row:
|
||||
if cell == str(ship_idx):
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_game_over(self, vs_ai=True):
|
||||
if vs_ai:
|
||||
return self.player_alive == 0 or self.ai_alive == 0
|
||||
else:
|
||||
return self.player1_alive == 0 or self.player2_alive == 0
|
||||
|
||||
def get_player_board(self):
|
||||
return copy.deepcopy(self.player_board)
|
||||
|
||||
def get_player_radar(self):
|
||||
return copy.deepcopy(self.player_radar)
|
||||
|
||||
def get_ai_board(self):
|
||||
return copy.deepcopy(self.ai_board)
|
||||
|
||||
def get_ai_radar(self):
|
||||
return copy.deepcopy(self.ai_radar)
|
||||
|
||||
def get_ship_status(self, board):
|
||||
status = {}
|
||||
for idx in range(len(SHIPS)):
|
||||
afloat = any(str(idx) in row for row in board)
|
||||
status[idx] = "Afloat" if afloat else "Sunk"
|
||||
return status
|
||||
|
||||
def display_draw_board(self, board, label="Board"):
|
||||
print(f"{label}")
|
||||
print(" " + " ".join(str(i+1).rjust(2) for i in range(SIZE)))
|
||||
for idx, row in enumerate(board):
|
||||
print(chr(ord('A') + idx) + " " + " ".join(cell.rjust(2) for cell in row))
|
||||
|
||||
def get_short_name(node_id):
|
||||
from mesh_bot import battleshipTracker
|
||||
entry = next((e for e in battleshipTracker if e['nodeID'] == node_id), None)
|
||||
return entry['short_name'] if entry and 'short_name' in entry else str(node_id)
|
||||
|
||||
def playBattleship(message, nodeID, deviceID, session_id=None):
|
||||
if not session_id or session_id not in Battleship.sessions:
|
||||
return Battleship.new_game(nodeID, vs_ai=True)
|
||||
|
||||
session = Battleship.get_session(session_id)
|
||||
game = session.game
|
||||
|
||||
# Check for game over
|
||||
if not session.vs_ai and game.is_game_over(vs_ai=False):
|
||||
winner = None
|
||||
if game.player1_alive == 0:
|
||||
winner = get_short_name(session.player2_id)
|
||||
elif game.player2_alive == 0:
|
||||
winner = get_short_name(session.player1_id)
|
||||
else:
|
||||
winner = "Nobody"
|
||||
elapsed = int(time.time() - session.start_time)
|
||||
mins, secs = divmod(elapsed, 60)
|
||||
time_str = f"{mins}m {secs}s" if mins else f"{secs}s"
|
||||
shots = session.shots_fired
|
||||
return (
|
||||
f"Game over! {winner} wins! 🚢🏆\n"
|
||||
f"Game finished in {shots} shots and {time_str}.\n"
|
||||
)
|
||||
|
||||
if not session.vs_ai and session.player2_id is None:
|
||||
code = next((k for k, v in Battleship.short_codes.items() if v == session.session_id), None)
|
||||
return (
|
||||
f"Waiting for another player to join.\n"
|
||||
f"Share this code: {code}\n"
|
||||
"Type 'end' to cancel this P2P game."
|
||||
)
|
||||
|
||||
if nodeID != session.next_turn:
|
||||
return "It's not your turn!"
|
||||
|
||||
msg = message.strip().lower()
|
||||
if msg.startswith("battleship"):
|
||||
msg = msg[len("battleship"):].strip()
|
||||
if msg.startswith("b:"):
|
||||
msg = msg[2:].strip()
|
||||
msg = msg.replace(" ", "")
|
||||
|
||||
# --- Ping Command ---
|
||||
if msg == "p":
|
||||
import random
|
||||
# 30% chance to fail
|
||||
if random.random() < 0.3:
|
||||
return "I can hear a couple of 🦞lobsters dukin' it out down there..."
|
||||
# Determine center of ping
|
||||
if session.vs_ai:
|
||||
# Use last move if available, else center of board
|
||||
if session.shots_fired > 0:
|
||||
# Find last move coordinates from radar (most recent HIT or FIRE)
|
||||
radar = game.get_player_radar()
|
||||
found = False
|
||||
for i in range(SIZE):
|
||||
for j in range(SIZE):
|
||||
if radar[i][j] in (HIT, FIRE):
|
||||
center_y, center_x = i, j
|
||||
found = True
|
||||
if not found:
|
||||
center_y, center_x = SIZE // 2, SIZE // 2
|
||||
else:
|
||||
center_y, center_x = SIZE // 2, SIZE // 2
|
||||
# Scan 3x3 area on AI board for unsunk ship cells
|
||||
board = game.ai_board
|
||||
else:
|
||||
# For P2P, use player's radar and opponent's board
|
||||
if session.last_move:
|
||||
coord = session.last_move[1]
|
||||
center_y = ord(coord[0]) - ord('A')
|
||||
center_x = int(coord[1:]) - 1
|
||||
else:
|
||||
center_y, center_x = SIZE // 2, SIZE // 2
|
||||
# Scan 3x3 area on opponent's board
|
||||
if nodeID == session.player1_id:
|
||||
board = game.player2_board
|
||||
else:
|
||||
board = game.player1_board
|
||||
|
||||
min_y = max(0, center_y - 1)
|
||||
max_y = min(SIZE, center_y + 2)
|
||||
min_x = max(0, center_x - 1)
|
||||
max_x = min(SIZE, center_x + 2)
|
||||
ship_cells = set()
|
||||
for i in range(min_y, max_y):
|
||||
for j in range(min_x, max_x):
|
||||
cell = board[i][j]
|
||||
if cell.isdigit():
|
||||
ship_cells.add(cell)
|
||||
pong_count = len(ship_cells)
|
||||
if pong_count == 0:
|
||||
return "silence in the deep..."
|
||||
elif pong_count == 1:
|
||||
return "something lurking nearby."
|
||||
else:
|
||||
return f"targets in the area!"
|
||||
|
||||
x = y = None
|
||||
if "," in msg:
|
||||
parts = msg.split(",")
|
||||
if len(parts) == 2 and len(parts[0]) == 1 and parts[0].isalpha() and parts[1].isdigit():
|
||||
y = ord(parts[0]) - ord('a')
|
||||
x = int(parts[1]) - 1
|
||||
else:
|
||||
return "Invalid coordinates. Use format A2 or A,2 (row letter, column number)."
|
||||
elif len(msg) >= 2 and msg[0].isalpha() and msg[1:].isdigit():
|
||||
y = ord(msg[0]) - ord('a')
|
||||
x = int(msg[1:]) - 1
|
||||
else:
|
||||
return "Invalid command. Use format A2 or A,2 (row letter, column number)."
|
||||
|
||||
if x is None or y is None or not (0 <= x < SIZE and 0 <= y < SIZE):
|
||||
return "Coordinates out of range."
|
||||
|
||||
ai_row = ai_col = ai_result = None
|
||||
over = False
|
||||
|
||||
if session.vs_ai:
|
||||
result = game.player_move(y, x)
|
||||
ai_row, ai_col, ai_result = game.ai_move()
|
||||
over = game.is_game_over(vs_ai=True)
|
||||
else:
|
||||
# P2P: determine which player is moving and fire at the other player's board
|
||||
if nodeID == session.player1_id:
|
||||
attacker = "player1"
|
||||
defender = "player2"
|
||||
result = game.p2p_player_move(
|
||||
y, x,
|
||||
game.player1_board, game.player2_board,
|
||||
game.player1_radar, "player2_alive"
|
||||
)
|
||||
else:
|
||||
attacker = "player2"
|
||||
defender = "player1"
|
||||
result = game.p2p_player_move(
|
||||
y, x,
|
||||
game.player2_board, game.player1_board,
|
||||
game.player2_radar, "player1_alive"
|
||||
)
|
||||
over = game.is_game_over(vs_ai=False)
|
||||
coord_str = f"{chr(y+65)}{x+1}"
|
||||
session.last_move = (nodeID, coord_str, result)
|
||||
|
||||
# --- DEBUG DISPLAY ---
|
||||
DEBUG = False
|
||||
if DEBUG:
|
||||
if session.vs_ai:
|
||||
game.display_draw_board(game.player_board, label=f"Player Board ({session.player1_id})")
|
||||
game.display_draw_board(game.player_radar, label="Player Radar")
|
||||
game.display_draw_board(game.ai_board, label="AI Board")
|
||||
game.display_draw_board(game.ai_radar, label="AI Radar")
|
||||
else:
|
||||
p1_id = session.player1_id
|
||||
p2_id = session.player2_id if session.player2_id else "Waiting"
|
||||
game.display_draw_board(game.player1_board, label=f"Player 1 Board ({p1_id})")
|
||||
game.display_draw_board(game.player1_radar, label="Player 1 Radar")
|
||||
game.display_draw_board(game.player2_board, label=f"Player 2 Board ({p2_id})")
|
||||
game.display_draw_board(game.player2_radar, label="Player 2 Radar")
|
||||
|
||||
# Format radar as a 4x4 grid centered on the player's move
|
||||
if session.vs_ai:
|
||||
radar = game.get_player_radar()
|
||||
else:
|
||||
radar = game.player1_radar if nodeID == session.player1_id else game.player2_radar
|
||||
|
||||
window_size = 4
|
||||
half_window = window_size // 2
|
||||
min_row = max(0, min(y - half_window, SIZE - window_size))
|
||||
max_row = min(SIZE, min_row + window_size)
|
||||
min_col = max(0, min(x - half_window, SIZE - window_size))
|
||||
max_col = min(SIZE, min_col + window_size)
|
||||
|
||||
radar_str = "🗺️" + " ".join(str(i+1) for i in range(min_col, max_col)) + "\n"
|
||||
for idx in range(min_row, max_row):
|
||||
radar_str += chr(ord('A') + idx) + " :" + " ".join(radar[idx][j] for j in range(min_col, max_col)) + "\n"
|
||||
|
||||
def format_ship_status(status_dict):
|
||||
afloat = 0
|
||||
for idx, state in status_dict.items():
|
||||
if state == "Afloat":
|
||||
afloat += 1
|
||||
return f"{afloat}/{len(SHIPS)} afloat"
|
||||
|
||||
if session.vs_ai:
|
||||
ai_status_str = format_ship_status(game.get_ship_status(game.ai_board))
|
||||
player_status_str = format_ship_status(game.get_ship_status(game.player_board))
|
||||
else:
|
||||
ai_status_str = format_ship_status(game.get_ship_status(game.player2_board))
|
||||
player_status_str = format_ship_status(game.get_ship_status(game.player1_board))
|
||||
|
||||
def move_result_text(res, is_player=True):
|
||||
if res.startswith("sunk:"):
|
||||
idx = int(res.split(":")[1])
|
||||
name = SHIP_NAMES[idx]
|
||||
return f"Sunk🎯 {name}!"
|
||||
elif res == "hit":
|
||||
return "💥Hit!"
|
||||
elif res == "miss":
|
||||
return "missed"
|
||||
elif res == "repeat":
|
||||
return "📋already targeted"
|
||||
else:
|
||||
return res
|
||||
|
||||
# After a valid move, switch turns
|
||||
if session.vs_ai:
|
||||
session.next_turn = nodeID
|
||||
else:
|
||||
session.next_turn = session.player2_id if nodeID == session.player1_id else session.player1_id
|
||||
|
||||
# Increment shots fired
|
||||
session.shots_fired += 1
|
||||
|
||||
# Waste of ammo comment
|
||||
funny_comment = ""
|
||||
if session.shots_fired % 50 == 0:
|
||||
funny_comment = f"\n🥵{session.shots_fired} rounds!"
|
||||
elif session.shots_fired % 25 == 0:
|
||||
funny_comment = f"\n🥔{session.shots_fired} fired!"
|
||||
|
||||
# Output message
|
||||
if session.vs_ai:
|
||||
msg_out = (
|
||||
f"Your move: {move_result_text(result)}\n"
|
||||
f"AI ships: {ai_status_str}\n"
|
||||
f"Radar:\n{radar_str}"
|
||||
f"AI move: {chr(ai_row+65)}{ai_col+1} ({move_result_text(ai_result, False)})\n"
|
||||
f"Your ships: {player_status_str}"
|
||||
f"{funny_comment}"
|
||||
)
|
||||
else:
|
||||
my_name = get_short_name(nodeID)
|
||||
opponent_id = session.player2_id if nodeID == session.player1_id else session.player1_id
|
||||
opponent_short_name = get_short_name(opponent_id) if opponent_id else "Waiting"
|
||||
opponent_label = f"{opponent_short_name}:"
|
||||
my_move_result_str = f"Your move: {move_result_text(result)}\n"
|
||||
last_move_str = ""
|
||||
if session.last_move and session.last_move[0] != nodeID:
|
||||
last_player_short_name = get_short_name(session.last_move[0])
|
||||
last_coord = session.last_move[1]
|
||||
last_result = move_result_text(session.last_move[2])
|
||||
last_move_str = f"Last move by {last_player_short_name}: {last_coord} ({last_result})\n"
|
||||
if session.next_turn == nodeID:
|
||||
turn_prompt = f"Your turn, {my_name}! Enter your move:"
|
||||
else:
|
||||
turn_prompt = f"Waiting for {opponent_short_name}..."
|
||||
msg_out = (
|
||||
f"{my_move_result_str}"
|
||||
f"{last_move_str}"
|
||||
f"{opponent_label} {ai_status_str}\n"
|
||||
f"Radar:\n{radar_str}"
|
||||
f"Your ships: {player_status_str}\n"
|
||||
f"{turn_prompt}"
|
||||
f"{funny_comment}"
|
||||
)
|
||||
|
||||
if over:
|
||||
elapsed = int(time.time() - session.start_time)
|
||||
mins, secs = divmod(elapsed, 60)
|
||||
time_str = f"{mins}m {secs}s" if mins else f"{secs}s"
|
||||
shots = session.shots_fired
|
||||
if session.vs_ai:
|
||||
if game.player_alive == 0:
|
||||
winner = "AI 🤖"
|
||||
msg_out += f"\nGame over! {winner} wins! Better luck next time.\n"
|
||||
else:
|
||||
winner = get_short_name(nodeID)
|
||||
msg_out += (
|
||||
f"\nGame over! {winner} wins! You sank all the AI's ships! 🎉\n"
|
||||
f"Took {shots} shots in {time_str}.\n"
|
||||
)
|
||||
else:
|
||||
# P2P: Announce winner by short name
|
||||
if game.player1_alive == 0:
|
||||
winner = get_short_name(session.player2_id)
|
||||
elif game.player2_alive == 0:
|
||||
winner = get_short_name(session.player1_id)
|
||||
else:
|
||||
winner = "Nobody"
|
||||
msg_out += (
|
||||
f"\nGame over! {winner} wins! 🚢🏆\n"
|
||||
f"Game finished in {shots} shots and {time_str}.\n"
|
||||
)
|
||||
msg_out += "Type 'battleship' to start a new game."
|
||||
|
||||
return msg_out
|
||||
106
modules/games/battleship_vid.py
Normal file
106
modules/games/battleship_vid.py
Normal file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# Battleship Display Module Meshing Around
|
||||
# 2025 K7MHI Kelly Keeton
|
||||
import pygame
|
||||
import sys
|
||||
import time
|
||||
|
||||
from modules.games.battleship import Battleship, SHIP_NAMES, SIZE, OCEAN, FIRE, HIT
|
||||
|
||||
CELL_SIZE = 40
|
||||
BOARD_MARGIN = 50
|
||||
STATUS_WIDTH = 320
|
||||
|
||||
latest_battleship_board = None
|
||||
latest_battleship_meta = None
|
||||
|
||||
def draw_board(screen, board, top_left, cell_size, show_ships=False):
|
||||
font = pygame.font.Font(None, 28)
|
||||
x0, y0 = top_left
|
||||
for y in range(SIZE):
|
||||
for x in range(SIZE):
|
||||
rect = pygame.Rect(x0 + x*cell_size, y0 + y*cell_size, cell_size, cell_size)
|
||||
pygame.draw.rect(screen, (100, 100, 200), rect, 1)
|
||||
val = board[y][x]
|
||||
# Show ships if requested, otherwise hide ship numbers
|
||||
if not show_ships and val.isdigit():
|
||||
val = OCEAN
|
||||
color = (200, 200, 255) if val == OCEAN else (255, 0, 0) if val == FIRE else (0, 255, 0) if val == HIT else (255,255,255)
|
||||
if val != OCEAN:
|
||||
pygame.draw.rect(screen, color, rect)
|
||||
text = font.render(val, True, (0,0,0))
|
||||
screen.blit(text, rect.move(10, 5))
|
||||
# Draw row/col labels
|
||||
for i in range(SIZE):
|
||||
# Col numbers
|
||||
num_surface = font.render(str(i+1), True, (255, 255, 0))
|
||||
screen.blit(num_surface, (x0 + i*cell_size + cell_size//2 - 8, y0 - 24))
|
||||
# Row letters
|
||||
letter_surface = font.render(chr(ord('A') + i), True, (255, 255, 0))
|
||||
screen.blit(letter_surface, (x0 - 28, y0 + i*cell_size + cell_size//2 - 10))
|
||||
|
||||
def draw_status_panel(screen, game, top_left, width, height, is_player=True):
|
||||
font = pygame.font.Font(None, 32)
|
||||
x0, y0 = top_left
|
||||
pygame.draw.rect(screen, (30, 30, 60), (x0, y0, width, height), border_radius=10)
|
||||
# Title
|
||||
title = font.render("Game Status", True, (255, 255, 0))
|
||||
screen.blit(title, (x0 + 10, y0 + 10))
|
||||
# Ships status
|
||||
ships_title = font.render("Ships Remaining:", True, (200, 200, 255))
|
||||
screen.blit(ships_title, (x0 + 10, y0 + 60))
|
||||
# Get ship status
|
||||
if is_player:
|
||||
status_dict = game.get_ship_status(game.player_board)
|
||||
else:
|
||||
status_dict = game.get_ship_status(game.ai_board)
|
||||
for i, ship in enumerate(SHIP_NAMES):
|
||||
status = status_dict.get(i, "Afloat")
|
||||
name_color = (200, 200, 255)
|
||||
if status.lower() == "sunk":
|
||||
status_color = (255, 0, 0)
|
||||
status_text = "Sunk"
|
||||
else:
|
||||
status_color = (0, 255, 0)
|
||||
status_text = "Afloat"
|
||||
ship_name_surface = font.render(f"{ship}:", True, name_color)
|
||||
screen.blit(ship_name_surface, (x0 + 20, y0 + 100 + i * 35))
|
||||
status_surface = font.render(f"{status_text}", True, status_color)
|
||||
screen.blit(status_surface, (x0 + 180, y0 + 100 + i * 35))
|
||||
|
||||
def battleship_visual_main(game):
|
||||
pygame.init()
|
||||
screen = pygame.display.set_mode((2*SIZE*CELL_SIZE + STATUS_WIDTH + 3*BOARD_MARGIN, SIZE*CELL_SIZE + 2*BOARD_MARGIN))
|
||||
pygame.display.set_caption("Battleship Visualizer")
|
||||
clock = pygame.time.Clock()
|
||||
running = True
|
||||
while running:
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.QUIT or (event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE):
|
||||
running = False
|
||||
screen.fill((20, 20, 30))
|
||||
# Draw radar (left)
|
||||
draw_board(screen, game.get_player_radar(), (BOARD_MARGIN, BOARD_MARGIN+30), CELL_SIZE, show_ships=False)
|
||||
radar_label = pygame.font.Font(None, 36).render("Your Radar", True, (0,255,255))
|
||||
screen.blit(radar_label, (BOARD_MARGIN, BOARD_MARGIN))
|
||||
# Draw player board (right)
|
||||
draw_board(screen, game.get_player_board(), (SIZE*CELL_SIZE + 2*BOARD_MARGIN, BOARD_MARGIN+30), CELL_SIZE, show_ships=True)
|
||||
board_label = pygame.font.Font(None, 36).render("Your Board", True, (0,255,255))
|
||||
screen.blit(board_label, (SIZE*CELL_SIZE + 2*BOARD_MARGIN, BOARD_MARGIN))
|
||||
# Draw status panel (far right)
|
||||
draw_status_panel(screen, game, (2*SIZE*CELL_SIZE + 2*BOARD_MARGIN, BOARD_MARGIN), STATUS_WIDTH, SIZE*CELL_SIZE)
|
||||
pygame.display.flip()
|
||||
clock.tick(30)
|
||||
pygame.quit()
|
||||
sys.exit()
|
||||
|
||||
def parse_battleship_message(msg):
|
||||
# Expected payload:
|
||||
# MBSP|label|timestamp|nodeID|deviceID|sessionID|status|shotsFired|boardType|shipsStatus|boardString
|
||||
print("Parsing Battleship message:", msg)
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# # Example: create a new game and show the boards
|
||||
# game = Battleship(vs_ai=True)
|
||||
# battleship_visual_main(game)
|
||||
489
modules/games/blackjack.py
Normal file
489
modules/games/blackjack.py
Normal file
@@ -0,0 +1,489 @@
|
||||
# Port of https://github.com/Himan10/BlackJack
|
||||
# Adapted for Meshtastic mesh-bot by K7MHI Kelly Keeton 2024
|
||||
|
||||
from random import choices, shuffle
|
||||
from modules.log import logger
|
||||
from modules.settings import jackTracker
|
||||
import time
|
||||
import pickle
|
||||
|
||||
jack_starting_cash = 100 # Replace 100 with your desired starting cash value
|
||||
|
||||
SUITS = ("♥️", "♦️", "♠️", "♣️")
|
||||
RANKS = (
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"10",
|
||||
"J",
|
||||
"Q",
|
||||
"K",
|
||||
"A",
|
||||
)
|
||||
VALUES = {
|
||||
"2": 2,
|
||||
"3": 3,
|
||||
"4": 4,
|
||||
"5": 5,
|
||||
"6": 6,
|
||||
"7": 7,
|
||||
"8": 8,
|
||||
"9": 9,
|
||||
"10": 10,
|
||||
"J": 10,
|
||||
"Q": 10,
|
||||
"K": 10,
|
||||
"A": 11,
|
||||
}
|
||||
|
||||
class jackCard:
|
||||
def __init__(self, suit, rank):
|
||||
self.suit = suit
|
||||
self.rank = rank
|
||||
|
||||
def __str__(self):
|
||||
return self.rank + " of " + self.suit
|
||||
|
||||
class jackDeck:
|
||||
""" Creating a Deck of cards and Deal two cards to both player and dealer. """
|
||||
|
||||
def __init__(self):
|
||||
self.deck = []
|
||||
self.player = []
|
||||
self.dealer = []
|
||||
for suit in SUITS:
|
||||
for rank in RANKS:
|
||||
self.deck.append((suit, rank))
|
||||
|
||||
def shuffle(self):
|
||||
shuffle(self.deck)
|
||||
|
||||
def deal_cards(self):
|
||||
self.player = choices(self.deck, k=2)
|
||||
self.delete_cards(self.player)
|
||||
self.dealer = choices(self.deck, k=2)
|
||||
self.delete_cards(self.dealer) # Delete Drawn Cards
|
||||
return self.player, self.dealer
|
||||
|
||||
def delete_cards(self, total_drawn):
|
||||
""" Delete Drawn cards from the Decks """
|
||||
try:
|
||||
for i in total_drawn:
|
||||
self.deck.remove(i)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
class jackHand:
|
||||
""" Adding the values of player/dealer cards and change the values of Aces acc. to situation. """
|
||||
def __init__(self):
|
||||
self.cards = []
|
||||
self.value = 0
|
||||
self.aces = 0
|
||||
|
||||
def add_cards(self, card):
|
||||
self.cards.extend(card)
|
||||
for count, ele in enumerate(card, 0):
|
||||
if ele[1] == "A":
|
||||
self.aces += 1
|
||||
self.value += VALUES[ele[1]]
|
||||
self.adjust_for_ace()
|
||||
|
||||
def adjust_for_ace(self):
|
||||
while self.aces > 0 and self.value > 21:
|
||||
self.value -= 10
|
||||
self.aces -= 1
|
||||
|
||||
class jackChips:
|
||||
""" Player/dealer chips for making bets and Adding/Deducting amount in/from Player's total. """
|
||||
def __init__(self):
|
||||
self.total = jack_starting_cash
|
||||
self.bet = 0
|
||||
self.winnings = 0
|
||||
|
||||
def win_bet(self):
|
||||
self.total += self.bet
|
||||
self.winnings += 1
|
||||
|
||||
def loss_bet(self):
|
||||
self.total -= self.bet
|
||||
self.winnings -= 1
|
||||
|
||||
def success_rate(next_card, player_hand):
|
||||
# Estimate the chance of a successful 'HIT' (not busting) in blackjack.
|
||||
|
||||
# If player already has 21 or more, hitting will always bust
|
||||
if player_hand.value >= 21:
|
||||
return "\n🧠 What do you think?"
|
||||
|
||||
# Calculate how much more the player can add without busting
|
||||
max_safe = 21 - player_hand.value
|
||||
|
||||
safe_cards = 0
|
||||
total_cards = 0
|
||||
for rank in VALUES:
|
||||
# 4 cards of each rank in a standard deck
|
||||
count = 4
|
||||
card_value = VALUES[rank]
|
||||
# Ace can be 1 or 11, but here we treat it as 1 if 11 would bust
|
||||
if rank == "A":
|
||||
card_value = 1 if player_hand.value + 11 > 21 else 11
|
||||
# Count as safe if it won't bust the player
|
||||
if card_value <= max_safe:
|
||||
safe_cards += count
|
||||
total_cards += count
|
||||
|
||||
# Calculate probability
|
||||
success_chance = int((safe_cards / total_cards) * 100)
|
||||
fail_chance = 100 - success_chance
|
||||
|
||||
return f"\n🧠Hit: {fail_chance}% 👎, {success_chance}% 👍"
|
||||
|
||||
def hits(obj_de):
|
||||
new_card = [obj_de.deal_cards()[0][0]]
|
||||
# obj_h.add_cards(new_card)
|
||||
return new_card
|
||||
|
||||
def display_hand(hand):
|
||||
# Display the cards in the hand nicely
|
||||
d = "" # display
|
||||
for card in hand:
|
||||
d += f"{card[1]}{card[0]}"
|
||||
if card != hand[-1]:
|
||||
d += ", "
|
||||
return d
|
||||
|
||||
def show_some(player_cards, dealer_cards, obj_h):
|
||||
msg = f"Player[{obj_h.value}] {display_hand(player_cards)} "
|
||||
msg += f"\nDealer[{VALUES[dealer_cards[1][1]]}] {dealer_cards[1][1]}{dealer_cards[1][0]} "
|
||||
return msg
|
||||
|
||||
def show_all(player_cards, dealer_cards, obj_h, obj_d):
|
||||
msg = f"Player[{obj_h.value}] {display_hand(player_cards)} "
|
||||
msg += f"\nDealer[{obj_d.value}] {display_hand(dealer_cards)}"
|
||||
return msg
|
||||
|
||||
def player_bust(obj_h, obj_c):
|
||||
if obj_h.value > 21:
|
||||
obj_c.loss_bet()
|
||||
return True
|
||||
return False
|
||||
|
||||
def player_wins(obj_h, obj_d, obj_c):
|
||||
if any((obj_h.value == 21, obj_h.value > obj_d.value and obj_h.value < 21)):
|
||||
obj_c.win_bet()
|
||||
return True
|
||||
return False
|
||||
|
||||
def dealer_bust(obj_d, obj_h, obj_c):
|
||||
if obj_d.value > 21:
|
||||
if obj_h.value < 21:
|
||||
obj_c.win_bet()
|
||||
return True
|
||||
return False
|
||||
|
||||
def dealer_wins(obj_h, obj_d, obj_c):
|
||||
if any((obj_d.value == 21, obj_d.value > obj_h.value and obj_d.value < 21)):
|
||||
obj_c.loss_bet()
|
||||
return True
|
||||
return False
|
||||
|
||||
def push(obj_h, obj_d):
|
||||
if obj_h.value == obj_d.value:
|
||||
return True
|
||||
return False
|
||||
|
||||
def player_surrender(obj_c):
|
||||
obj_c.loss_bet()
|
||||
return True
|
||||
|
||||
def gameStats(p_count, d_count, draw_c):
|
||||
msg = f"\n📊🏆P:{p_count},D:{d_count},T:{draw_c}"
|
||||
return msg
|
||||
|
||||
def getLastCmdJack(nodeID):
|
||||
for i in range(len(jackTracker)):
|
||||
if jackTracker[i]['nodeID'] == nodeID:
|
||||
return jackTracker[i]['cmd']
|
||||
return None
|
||||
|
||||
def setLastCmdJack(nodeID, cmd):
|
||||
for i in range(len(jackTracker)):
|
||||
if jackTracker[i]['nodeID'] == nodeID:
|
||||
jackTracker[i]['cmd'] = cmd
|
||||
return True
|
||||
return False
|
||||
|
||||
def saveHSJack(nodeID, highScore):
|
||||
# Save the game state to pickle
|
||||
highScore = {'nodeID': nodeID, 'highScore': highScore}
|
||||
try:
|
||||
with open('data/blackjack_hs.pkl', 'wb') as file:
|
||||
pickle.dump(highScore, file)
|
||||
except FileNotFoundError:
|
||||
logger.debug("System: BlackJack: Creating new data/blackjack_hs.pkl file")
|
||||
with open('data/blackjack_hs.pkl', 'wb') as file:
|
||||
pickle.dump(highScore, file)
|
||||
|
||||
def loadHSJack():
|
||||
try:
|
||||
with open('data/blackjack_hs.pkl', 'rb') as file:
|
||||
highScore = pickle.load(file)
|
||||
return highScore
|
||||
except FileNotFoundError:
|
||||
logger.debug("System: BlackJack: Creating new data/blackjack_hs.pkl file")
|
||||
highScore = {'nodeID': 0, 'highScore': 0}
|
||||
with open('data/blackjack_hs.pkl', 'wb') as file:
|
||||
pickle.dump(highScore, file)
|
||||
return 0
|
||||
|
||||
def playBlackJack(nodeID, message, last_cmd=None):
|
||||
# Initalize the Game
|
||||
msg, last_cmd = '', None
|
||||
blackJack = False
|
||||
p_win, d_win, draw = 0, 0, 0
|
||||
p_chips = jackChips()
|
||||
p_hand = jackHand()
|
||||
d_hand = jackHand()
|
||||
p_cards, d_cards = [], []
|
||||
bet_money = 0
|
||||
# Initalize the Cards
|
||||
cards_deck = jackDeck()
|
||||
cards_deck.shuffle()
|
||||
p_cards, d_cards = cards_deck.deal_cards()
|
||||
# Deal the cards to player and dealer
|
||||
p_hand.add_cards(p_cards)
|
||||
d_hand.add_cards(d_cards)
|
||||
next_card = hits(cards_deck)
|
||||
|
||||
# Check if player, use tracking
|
||||
for i in range(len(jackTracker)):
|
||||
if jackTracker[i]['nodeID'] == nodeID:
|
||||
last_cmd = jackTracker[i]['cmd']
|
||||
p_chips.total = jackTracker[i]['cash']
|
||||
p_win = jackTracker[i]['gameStats']['p_win']
|
||||
d_win = jackTracker[i]['gameStats']['d_win']
|
||||
draw = jackTracker[i]['gameStats']['draw']
|
||||
bet_money = jackTracker[i]['bet']
|
||||
if last_cmd == "playing":
|
||||
p_chips.bet = bet_money
|
||||
p_cards = jackTracker[i]['p_cards']
|
||||
d_cards = jackTracker[i]['d_cards']
|
||||
p_hand = jackTracker[i]['p_hand']
|
||||
d_hand = jackTracker[i]['d_hand']
|
||||
next_card = jackTracker[i]['next_card']
|
||||
|
||||
if last_cmd is None:
|
||||
# create new player if not in tracker
|
||||
if nodeID != 0:
|
||||
#logger.debug(f"System: BlackJack: New Player {nodeID}")
|
||||
jackTracker.append({'nodeID': nodeID, 'cmd': 'new', 'last_played': time.time(), 'cash': jack_starting_cash,\
|
||||
'bet': 0, 'gameStats': {'p_win': p_win, 'd_win': d_win, 'draw': draw}, 'p_cards':p_cards, 'd_cards':d_cards, 'p_hand':p_hand.cards, 'd_hand':d_hand.cards, 'next_card':next_card})
|
||||
return f"You have {p_chips.total} chips. Whats your bet?"
|
||||
return "Error: Player not found."
|
||||
|
||||
if getLastCmdJack(nodeID) == "new":
|
||||
# Place Bet
|
||||
try:
|
||||
# handle B letter
|
||||
if message.lower() == "b":
|
||||
if bet_money == 0:
|
||||
bet_money = 5
|
||||
elif message.lower() == "r":
|
||||
#resend the hand
|
||||
msg += show_some(p_cards, d_cards, p_hand)
|
||||
return msg
|
||||
elif "blackjack" in message.lower():
|
||||
return f"\nTo place a bet, enter the amount you wish to wager."
|
||||
else:
|
||||
try:
|
||||
bet_money = int(message)
|
||||
except ValueError:
|
||||
return f"\nInvalid Bet, please enter a valid number."
|
||||
|
||||
if bet_money <= p_chips.total and bet_money >= 1:
|
||||
p_chips.bet = bet_money
|
||||
else:
|
||||
return f"\nInvalid Bet, the maximum bet you can place is {p_chips.total} and the minimum bet is 1."
|
||||
except ValueError:
|
||||
return f"\nInvalid Bet, the maximum bet, {p_chips.total}"
|
||||
|
||||
# Show the cards
|
||||
msg += show_some(p_cards, d_cards, p_hand)
|
||||
# check for blackjack 21 and only two cards
|
||||
if p_hand.value == 21 and len(p_hand.cards) == 2:
|
||||
msg += f"\n🎰 BLAAAACKJACKKKK 💰"
|
||||
p_chips.total += round(p_chips.bet * 1.5)
|
||||
setLastCmdJack(nodeID, "dealerTurn")
|
||||
blackJack = True
|
||||
# Save the game state
|
||||
for i in range(len(jackTracker)):
|
||||
if jackTracker[i]['nodeID'] == nodeID:
|
||||
jackTracker[i]['cash'] = int(p_chips.total)
|
||||
break
|
||||
else:
|
||||
# Display the statistics
|
||||
stats = success_rate(next_card, p_hand)
|
||||
msg += stats
|
||||
setLastCmdJack(nodeID, "betPlaced")
|
||||
|
||||
if getLastCmdJack(nodeID) == "betPlaced":
|
||||
setLastCmdJack(nodeID, "playing")
|
||||
msg += f"\n(H)it,(S)tand,(F)orfit,(D)ouble,(R)esend,(L)eave table"
|
||||
|
||||
# save the game state
|
||||
for i in range(len(jackTracker)):
|
||||
if jackTracker[i]['nodeID'] == nodeID:
|
||||
jackTracker[i]['cash'] = p_chips.total
|
||||
jackTracker[i]['bet'] = p_chips.bet
|
||||
jackTracker[i]['p_cards'] = p_cards
|
||||
jackTracker[i]['d_cards'] = d_cards
|
||||
jackTracker[i]['p_hand'] = p_hand
|
||||
jackTracker[i]['d_hand'] = d_hand
|
||||
jackTracker[i]['next_card'] = next_card
|
||||
return msg
|
||||
|
||||
|
||||
while getLastCmdJack(nodeID) == "playing": # Recall var. from hit and stand function
|
||||
next_card = hits(cards_deck)
|
||||
|
||||
# Get the statistics
|
||||
stats = success_rate(next_card, p_hand)
|
||||
|
||||
# Player's Turn
|
||||
choice = message.lower()
|
||||
|
||||
if choice == "hit" or choice == "h":
|
||||
# hits(obj_de, p_hand)
|
||||
p_hand.add_cards(next_card)
|
||||
msg += show_some(p_hand.cards, d_cards, p_hand)
|
||||
elif choice == "stand" or choice == "s":
|
||||
setLastCmdJack(nodeID, "dealerTurn")
|
||||
elif choice == "forfit" or choice == "f":
|
||||
p_chips.bet = p_chips.bet / 2
|
||||
setLastCmdJack(nodeID, "dealerTurn")
|
||||
p_hand.value += 21
|
||||
elif choice == "double" or choice == "d":
|
||||
if p_chips.bet * 2 <= p_chips.total:
|
||||
p_chips.bet *= 2
|
||||
next_d_card = hits(cards_deck)
|
||||
p_hand.add_cards(next_d_card)
|
||||
setLastCmdJack(nodeID, "dealerTurn")
|
||||
else:
|
||||
return "You can't Double Down, dont have enough chips"
|
||||
elif choice == "resend" or choice == "r":
|
||||
msg += show_some(p_hand.cards, d_cards, p_hand)
|
||||
else:
|
||||
return "(H)it,(S)tand,(F)orfit,(D)ouble,(R)esend,(L)eave table"
|
||||
|
||||
# Check if player bust
|
||||
if player_bust(p_hand, p_chips):
|
||||
d_win += 1
|
||||
msg += f"\n💥PlayerBUST💥"
|
||||
setLastCmdJack(nodeID, "dealerTurn")
|
||||
|
||||
if getLastCmdJack(nodeID) == "playing":
|
||||
msg += stats
|
||||
msg += "[H,S,F,D]"
|
||||
|
||||
# Save the game state
|
||||
for i in range(len(jackTracker)):
|
||||
if jackTracker[i]['nodeID'] == nodeID:
|
||||
jackTracker[i]['cash'] = int(p_chips.total)
|
||||
jackTracker[i]['bet'] = int(p_chips.bet)
|
||||
jackTracker[i]['gameStats']['p_win'] = int(p_win)
|
||||
jackTracker[i]['gameStats']['d_win'] = int(d_win)
|
||||
jackTracker[i]['gameStats']['draw'] = int(draw)
|
||||
jackTracker[i]['p_cards'] = p_cards
|
||||
jackTracker[i]['d_cards'] = d_cards
|
||||
jackTracker[i]['p_hand'] = p_hand
|
||||
jackTracker[i]['d_hand'] = d_hand
|
||||
break
|
||||
|
||||
# Exit player's turn
|
||||
if getLastCmdJack(nodeID) == "dealerTurn":
|
||||
break
|
||||
|
||||
return msg
|
||||
|
||||
if getLastCmdJack(nodeID) == "dealerTurn":
|
||||
# Dealers Turn
|
||||
if not blackJack:
|
||||
# recall the game state
|
||||
for i in range(len(jackTracker)):
|
||||
if jackTracker[i]['nodeID'] == nodeID:
|
||||
p_chips.total = jackTracker[i]['cash']
|
||||
p_chips.bet = jackTracker[i]['bet']
|
||||
p_win = jackTracker[i]['gameStats']['p_win']
|
||||
d_win = jackTracker[i]['gameStats']['d_win']
|
||||
draw = jackTracker[i]['gameStats']['draw']
|
||||
p_cards = jackTracker[i]['p_cards']
|
||||
d_cards = jackTracker[i]['d_cards']
|
||||
p_hand = jackTracker[i]['p_hand']
|
||||
d_hand = jackTracker[i]['d_hand']
|
||||
next_card = jackTracker[i]['next_card']
|
||||
break
|
||||
|
||||
if p_hand.value <= 21:
|
||||
# Dealer's Turn
|
||||
while d_hand.value < 17:
|
||||
d_card = hits(cards_deck)
|
||||
d_hand.add_cards(d_card)
|
||||
if dealer_bust(d_hand, p_hand, p_chips):
|
||||
p_win += 1
|
||||
msg += f"\n💰DealerBUST💥"
|
||||
break
|
||||
# Show all cards
|
||||
msg += show_all(p_hand.cards, d_hand.cards, p_hand, d_hand)
|
||||
|
||||
# Check who wins
|
||||
if push(p_hand, d_hand):
|
||||
draw += 1
|
||||
msg += f"\n👌PUSH"
|
||||
elif player_wins(p_hand, d_hand, p_chips):
|
||||
p_win += 1
|
||||
msg += f"\n🎉PLAYER WINS🎰"
|
||||
elif dealer_wins(p_hand, d_hand, p_chips):
|
||||
d_win += 1
|
||||
msg += f"\n👎DEALER WINS"
|
||||
else:
|
||||
msg += f"\n👎DEALER WINS"
|
||||
|
||||
# Display the Game Stats
|
||||
msg += gameStats(str(p_win), str(d_win), str(draw))
|
||||
|
||||
# Display the chips left
|
||||
if p_chips.total < 1:
|
||||
if p_chips.total > 0:
|
||||
msg += f"\n🪙Keep the change you filthy animal!"
|
||||
else:
|
||||
msg += f"\n💸NO MORE CHIPS!🏧💳"
|
||||
p_chips.total = jack_starting_cash
|
||||
else:
|
||||
# check high score
|
||||
highScore = loadHSJack()
|
||||
if highScore != 0 and p_chips.total > highScore['highScore']:
|
||||
msg += f"\n💰HighScore💰{p_chips.total} "
|
||||
saveHSJack(nodeID, p_chips.total)
|
||||
else:
|
||||
msg += f"\n💰You have {p_chips.total} chips "
|
||||
|
||||
msg += f"\nBet or Leave?"
|
||||
|
||||
# Reset the game
|
||||
setLastCmdJack(nodeID, "new")
|
||||
jackTracker[i]['cash'] = p_chips.total
|
||||
jackTracker[i]['gameStats']['p_win'] = p_win
|
||||
jackTracker[i]['gameStats']['d_win'] = d_win
|
||||
jackTracker[i]['gameStats']['draw'] = draw
|
||||
jackTracker[i]['p_cards'] = []
|
||||
jackTracker[i]['d_cards'] = []
|
||||
jackTracker[i]['p_hand'] = []
|
||||
jackTracker[i]['d_hand'] = []
|
||||
jackTracker[i]['last_played'] = time.time()
|
||||
|
||||
return msg
|
||||
680
modules/games/dopewar.py
Normal file
680
modules/games/dopewar.py
Normal file
@@ -0,0 +1,680 @@
|
||||
# Port of https://github.com/Reconfirefly/drugwars/tree/master
|
||||
# Adapted for Meshtastic mesh-bot by K7MHI Kelly Keeton 2024
|
||||
|
||||
import random
|
||||
import time
|
||||
import pickle
|
||||
from modules.log import logger
|
||||
from modules.settings import dwPlayerTracker
|
||||
|
||||
# Global variables
|
||||
total_days = 7 # number of days or rotations the player has to play
|
||||
starting_cash = 5000
|
||||
# Database for the game reset on boot
|
||||
dwInventoryDb = [{'userID': 1234567890, 'inventory': 0, 'priceList': [], 'amount': []}]
|
||||
dwCashDb = [{'userID': 1234567890, 'cash': starting_cash},]
|
||||
dwGameDayDb = [{'userID': 1234567890, 'day': 0},]
|
||||
dwLocationDb = [{'userID': 1234567890, 'location': 'USA', 'loc_choice': 0},]
|
||||
from modules.settings import dwPlayerTracker
|
||||
# high score is saved in a pickle file
|
||||
dwHighScore = {}
|
||||
|
||||
class Drugs:
|
||||
|
||||
def __init__(self, name, price_range):
|
||||
self.name = name
|
||||
self.price_range = price_range
|
||||
self.price_check()
|
||||
|
||||
def price_check(self):
|
||||
# the * is to unpack the touple of values that the random goes between
|
||||
self.price = random.randint(*self.price_range)
|
||||
# print("the price of " + self.name + " is " + str(self.price))
|
||||
return self.price
|
||||
|
||||
class Events:
|
||||
|
||||
def __init__(self, name, text, price_range):
|
||||
self.name = name
|
||||
self.price_range = price_range
|
||||
self.text = text
|
||||
self.price_mod()
|
||||
|
||||
def price_mod(self):
|
||||
self.price = random.randint(*self.price_range)
|
||||
return self.price
|
||||
|
||||
my_drugs = [
|
||||
# Drugs("Name", (min price, max price), amount)
|
||||
Drugs("Cocaine", (15000, 28000)),
|
||||
Drugs("Heroin", (2000, 10000)),
|
||||
Drugs("Weed", (300, 1000)),
|
||||
Drugs("Hash", (200, 1200)),
|
||||
Drugs("Opium", (400, 1800)),
|
||||
Drugs("Acid", (1000, 4200)),
|
||||
Drugs("Ludes", (18, 75)),
|
||||
]
|
||||
|
||||
event_list = [
|
||||
# Events("Name", "Text", (min price, max price))
|
||||
Events("Cocaine", 'El Chapo Arrested! 🚔 Coke price thru the roof! 📈', (40000, 110000)),
|
||||
Events("Heroin", 'Trump cracks down on opiates! Heroin in high demand by addicts📈', (9000, 25000)),
|
||||
Events("Weed", 'The DEA has fully legalized weed! Prices are at an all time low!📉', (50, 400)),
|
||||
Events("Hash", 'Ricky\'s hash driveway burned down! 🚒 Look at the price boys!📈', (800, 2000)),
|
||||
Events("Opium", 'Shenzhen 深圳 Opium 鸦片 Den 塔 was raided! 🚔 Street price is popping off!📈', (1800, 6000)),
|
||||
Events("Acid", 'The Grateful Dead are on tour! Acid prices are skyrocketing!📈', (5000, 15000)),
|
||||
Events("Ludes", 'The Wolf of Wall Street is back! Ludes are in demand!', (100, 300)),
|
||||
Events("Cocaine", "The Biden administration has legalized cocaine! Prices are at an all time low!📉", (3000, 10000)),
|
||||
Events("Heroin", "Oregon has legalized heroin! Prices are at an all time low!📉", (500, 2500)),
|
||||
Events("Weed", "Prices are at an all time HIGH!📈", (1000, 5000)),
|
||||
Events("Hash", "The Middle East has legalised hash! Prices are at an all time low!📉", (50, 1000)),
|
||||
Events("Opium", "The Sackler's flood the market with cheap opium! Prices are at an all time low!📉", (300, 900)),
|
||||
Events("Acid", "The FBI admits to dosing the water supply with LSD! Acid at an all time low!📉", (500, 2000)),
|
||||
Events("Ludes", "The FDA approves ludes for sale! Prices are at an all time low!📉", (3, 45))
|
||||
]
|
||||
|
||||
def generatelocations():
|
||||
# dictionary of locations
|
||||
locs = {'Canada': ('Red Deer', 'Edmonton', 'Calgary', 'Toronto', 'Vancouver', 'St. Johns'),
|
||||
'USA': ('L.A.', 'NYC', 'Chicago', 'Miami', 'Houston', 'Phoenix'), 'Mexico': ('Tijuana', 'Mexico City', 'Cancun', 'Juarez', 'Acapulco', 'Guadalajara'),\
|
||||
'South America': ('Bogota', 'Caracas', 'Lima', 'Santiago', 'Buenos Aires', 'Rio'), 'Europe': ('London', 'Paris', 'Berlin', 'Rome', 'Madrid', 'Moscow')}
|
||||
|
||||
country = list(locs.keys())
|
||||
country = country[random.randint(0, len(country)-1)]
|
||||
|
||||
# return the location list for the user's country
|
||||
location = []
|
||||
for i in range(len(locs[country])):
|
||||
location.append(locs[country][i])
|
||||
return location
|
||||
|
||||
def generate_event():
|
||||
# roll to see if an event happens
|
||||
event_choice = random.randint(0, len(event_list)-1)
|
||||
if random.randint(0, 100) > 35:
|
||||
return event_choice
|
||||
else:
|
||||
return -1
|
||||
|
||||
def officer(nodeID):
|
||||
global dwCashDb, dwInventoryDb
|
||||
|
||||
# get the inventory for the user
|
||||
for i in range(0, len(dwInventoryDb)):
|
||||
if dwInventoryDb[i].get('userID') == nodeID:
|
||||
inventory = dwInventoryDb[i].get('inventory')
|
||||
amount = check_inv(nodeID)
|
||||
|
||||
# get the cash for the user
|
||||
for i in range(0, len(dwCashDb)):
|
||||
if dwCashDb[i].get('userID') == nodeID:
|
||||
cash = dwCashDb[i].get('cash')
|
||||
|
||||
# rolls to see if the officer takes drugs from you
|
||||
if random.randint(0, 100) > 65: # confiscation chance is 35%
|
||||
j, k = 0, 0
|
||||
for i in range(0, len(my_drugs)):
|
||||
j = amount[i]
|
||||
amount[i] = 0
|
||||
k += j
|
||||
# set the cash_taken to conf for confiscation not of cash
|
||||
cash_taken = 'conf'
|
||||
# Update the inventory_db
|
||||
inventory -= k
|
||||
for i in range(0, len(dwInventoryDb)):
|
||||
if dwInventoryDb[i].get('userID') == nodeID:
|
||||
dwInventoryDb[i]['inventory'] = inventory
|
||||
amount = dwInventoryDb[i].get('amount')
|
||||
return cash_taken
|
||||
# rolls to see how much cash the officer takes
|
||||
cash_taken = random.randint(1, cash-1)
|
||||
cash -= cash_taken
|
||||
# Update the cash_db and inventory_db
|
||||
for i in range(0, len(dwCashDb)):
|
||||
if dwCashDb[i].get('userID') == nodeID:
|
||||
dwCashDb[i]['cash'] = cash
|
||||
return cash_taken
|
||||
|
||||
def get_found_items(nodeID):
|
||||
global dwInventoryDb, dwCashDb
|
||||
msg = ''
|
||||
# get the inventory for the user
|
||||
for i in range(0, len(dwInventoryDb)):
|
||||
if dwInventoryDb[i].get('userID') == nodeID:
|
||||
inventory = dwInventoryDb[i].get('inventory')
|
||||
amount = check_inv(nodeID)
|
||||
|
||||
# get the cash for the user
|
||||
for i in range(0, len(dwCashDb)):
|
||||
if dwCashDb[i].get('userID') == nodeID:
|
||||
cash = dwCashDb[i].get('cash')
|
||||
|
||||
if random.randint(0, 100) > 50: # 50% chance to find cash or drugs
|
||||
if random.randint(0, 100) > 30: # 30% chance to find drugs
|
||||
found = random.choice(range(len(my_drugs)))
|
||||
# rolls to see how much of the drug the user finds
|
||||
qty =random.randint(1, 80 - inventory)
|
||||
amount[found] += qty
|
||||
inventory += qty
|
||||
for i in range(0, len(dwInventoryDb)):
|
||||
if dwInventoryDb[i].get('userID') == nodeID:
|
||||
dwInventoryDb[i]['inventory'] = inventory
|
||||
dwInventoryDb[i]['amount'] = amount
|
||||
msg = f"💊You found {qty} {my_drugs[found].name}"
|
||||
else:
|
||||
# rolls to see how much cash the user finds
|
||||
cash_found = random.randint(1, 977)
|
||||
cash += cash_found
|
||||
# Update the cash_db
|
||||
for i in range(0, len(dwCashDb)):
|
||||
if dwCashDb[i].get('userID') == nodeID:
|
||||
dwCashDb[i]['cash'] = cash
|
||||
msg = "You found $" + str(cash_found) + "💸"
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def price_change(event_number):
|
||||
price_list = []
|
||||
for i in range(0, len(my_drugs)):
|
||||
j = my_drugs[i]
|
||||
k = j.price_check()
|
||||
price_list.append(k)
|
||||
|
||||
# check if IndexError will be thrown and find a new event_number with generate_event
|
||||
while event_number > len(price_list)-1:
|
||||
event_number = generate_event()
|
||||
|
||||
if event_number != -1:
|
||||
price_list[event_number] = event_list[event_number].price_mod()
|
||||
|
||||
return price_list
|
||||
|
||||
def check_inv(nodeID):
|
||||
global dwInventoryDb
|
||||
|
||||
# get the inventory ammount for the user
|
||||
for i in range(0, len(dwInventoryDb)):
|
||||
if dwInventoryDb[i].get('userID') == nodeID:
|
||||
amount = dwInventoryDb[i].get('amount')
|
||||
|
||||
# if ammount is empty list initialize it
|
||||
if not amount:
|
||||
amount = []
|
||||
for i in range(0, len(my_drugs)):
|
||||
amount.append(0,)
|
||||
|
||||
# save the amount to the inventory_db
|
||||
for i in range(0, len(dwInventoryDb)):
|
||||
if dwInventoryDb[i].get('userID') == nodeID:
|
||||
dwInventoryDb[i]['amount'] = amount
|
||||
|
||||
return amount
|
||||
|
||||
def buy_func(nodeID, price_list, choice=0, value='0'):
|
||||
global dwCashDb, dwInventoryDb, dwPlayerTracker
|
||||
msg = ''
|
||||
|
||||
# get the inventory for the user
|
||||
for i in range(0, len(dwInventoryDb)):
|
||||
if dwInventoryDb[i].get('userID') == nodeID:
|
||||
inventory = dwInventoryDb[i].get('inventory')
|
||||
amount = check_inv(nodeID)
|
||||
|
||||
# get the cash for the user
|
||||
for i in range(0, len(dwCashDb)):
|
||||
if dwCashDb[i].get('userID') == nodeID:
|
||||
cash = dwCashDb[i].get('cash')
|
||||
|
||||
drug_choice = choice
|
||||
if choice == 0:
|
||||
msg = f"Didnt see a drug chouce. ex: s,1,10 sells 10 of drug 1{my_drugs[1].name}, or p for price list"
|
||||
return msg
|
||||
else:
|
||||
if drug_choice in range(1, len(my_drugs) + 1):
|
||||
drug_choice = drug_choice - 1
|
||||
cost = price_list[drug_choice]
|
||||
msg = my_drugs[drug_choice].name + ": you have🎒 " + str(amount[drug_choice]) + " "
|
||||
msg += " The going price is: $" + "{:,}".format(cost) + " "
|
||||
|
||||
buy_amount = value
|
||||
if buy_amount == 'm':
|
||||
buy_amount = cash // price_list[drug_choice]
|
||||
if buy_amount > 100 - inventory:
|
||||
buy_amount = 100 - inventory
|
||||
if buy_amount == 0:
|
||||
return "You don\'t have any empty inventory slots.🎒"
|
||||
# set the buy amount to the max if the user enters m
|
||||
buy_amount = int(buy_amount)
|
||||
|
||||
if buy_amount == 0:
|
||||
msg = f"Didnt see a qty. ex: b,1,10 buys 10 of {my_drugs[1].name}, can also use m for max"
|
||||
return msg
|
||||
elif buy_amount not in range(1, 101):
|
||||
msg = "Enter qty or m for max"
|
||||
return msg
|
||||
elif buy_amount > 100 - inventory:
|
||||
msg = "You don\'t have enough space for all that.🎒"
|
||||
return msg
|
||||
elif buy_amount * price_list[drug_choice] <= cash:
|
||||
amount[drug_choice] += buy_amount
|
||||
cash -= buy_amount * price_list[drug_choice]
|
||||
inventory += buy_amount
|
||||
msg += "You bought " + str(buy_amount) + " " + my_drugs[drug_choice].name + '. Remaining cash: $' + str(cash)
|
||||
msg += f"\nBuy💸, Sell💰, Fly🛫?"
|
||||
else:
|
||||
msg = "You don't have enough cash!😭"
|
||||
return msg
|
||||
|
||||
# update the cash_db and inventory_db values
|
||||
for i in range(0, len(dwCashDb)):
|
||||
if dwCashDb[i].get('userID') == nodeID:
|
||||
dwCashDb[i]['cash'] = cash
|
||||
for i in range(0, len(dwInventoryDb)):
|
||||
if dwInventoryDb[i].get('userID') == nodeID:
|
||||
dwInventoryDb[i]['inventory'] = inventory
|
||||
# save the last command as ask_bsf
|
||||
for i in range(0, len(dwPlayerTracker)):
|
||||
if dwPlayerTracker[i].get('userID') == nodeID:
|
||||
dwPlayerTracker[i]['cmd'] = 'ask_bsf'
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def sell_func(nodeID, price_list, choice=0, value='0'):
|
||||
global dwCashDb, dwInventoryDb, dwPlayerTracker
|
||||
msg = ''
|
||||
|
||||
# get the inventory for the user
|
||||
for i in range(0, len(dwInventoryDb)):
|
||||
if dwInventoryDb[i].get('userID') == nodeID:
|
||||
inventory = dwInventoryDb[i].get('inventory')
|
||||
amount = check_inv(nodeID)
|
||||
|
||||
# get the cash for the user
|
||||
for i in range(0, len(dwCashDb)):
|
||||
if dwCashDb[i].get('userID') == nodeID:
|
||||
cash = dwCashDb[i].get('cash')
|
||||
|
||||
# get the drug choice and amount to sell
|
||||
drug_choice = choice
|
||||
sell_amount = value
|
||||
|
||||
try:
|
||||
if sell_amount == 'm':
|
||||
sell_amount = amount[drug_choice - 1]
|
||||
else:
|
||||
sell_amount = int(sell_amount)
|
||||
if sell_amount not in range(1, 101):
|
||||
msg = "You can only sell between 1 and 100"
|
||||
return msg
|
||||
except ValueError:
|
||||
msg = "Enter qty or m for max"
|
||||
return msg
|
||||
|
||||
# check if the user has any of the drug they are trying to sell
|
||||
if choice == 0:
|
||||
msg = "Enter b or s and the drug number and qty you want to buy or sell. ex: b,1,10 buys 10 of drug 1"
|
||||
return msg
|
||||
else:
|
||||
if drug_choice in range(1, len(my_drugs) + 1) and amount[drug_choice - 1] > 0:
|
||||
drug_choice = drug_choice - 1
|
||||
cost = price_list[drug_choice]
|
||||
msg = my_drugs[drug_choice].name + ": you have " + str(amount[drug_choice]) +\
|
||||
" The going price is: $" + str("{:,}".format(cost))
|
||||
# check if the user has enough of the drug to sell
|
||||
if sell_amount <= amount[drug_choice]:
|
||||
amount[drug_choice] -= sell_amount
|
||||
cash += sell_amount * price_list[drug_choice]
|
||||
inventory -= sell_amount
|
||||
profit = sell_amount * price_list[drug_choice]
|
||||
msg += " You sold " + str(sell_amount) + " " + my_drugs[drug_choice].name +\
|
||||
' for $' + "{:,}".format(profit) + '. Total cash: $' + "{:,}".format(cash)
|
||||
else:
|
||||
msg = "You don't have that much"
|
||||
return msg
|
||||
else:
|
||||
msg = "You don't have any " + my_drugs[drug_choice - 1].name
|
||||
return msg
|
||||
|
||||
# update the cash_db and inventory_db values
|
||||
for i in range(0, len(dwCashDb)):
|
||||
if dwCashDb[i].get('userID') == nodeID:
|
||||
dwCashDb[i]['cash'] = cash
|
||||
for i in range(0, len(dwInventoryDb)):
|
||||
if dwInventoryDb[i].get('userID') == nodeID:
|
||||
dwInventoryDb[i]['inventory'] = inventory
|
||||
dwInventoryDb[i]['amount'] = amount
|
||||
# save the last command as ask_bsf
|
||||
for i in range(0, len(dwPlayerTracker)):
|
||||
if dwPlayerTracker[i].get('userID') == nodeID:
|
||||
dwPlayerTracker[i]['cmd'] = 'ask_bsf'
|
||||
|
||||
return msg
|
||||
|
||||
def get_location_table(nodeID, choice=0):
|
||||
global dwLocationDb
|
||||
# get the location for the user
|
||||
for i in range(0, len(dwLocationDb)):
|
||||
if dwLocationDb[i].get('userID') == nodeID:
|
||||
loc = dwLocationDb[i].get('location')
|
||||
|
||||
# list the lcaitons and their index in two columns
|
||||
loc_table_string = ''
|
||||
for i in range(len(loc)):
|
||||
loc_table_string += str(i+1) + '. ' + loc[i] + ' '
|
||||
loc_table_string += ' Where do you want to 🛫?#'
|
||||
return loc_table_string
|
||||
|
||||
def endGameDw(nodeID):
|
||||
global dwCashDb, dwInventoryDb, dwLocationDb, dwGameDayDb, dwHighScore, dwPlayerTracker
|
||||
cash = 0
|
||||
msg = ''
|
||||
dwHighScore = getHighScoreDw()
|
||||
# Confirm the cash for the user
|
||||
for i in range(0, len(dwCashDb)):
|
||||
if dwCashDb[i].get('userID') == nodeID:
|
||||
cash = dwCashDb[i].get('cash')
|
||||
logger.debug("System: DopeWars: Game Over for user: " + str(nodeID) + " with cash: " + str(cash))
|
||||
|
||||
# checks if the player's score is higher than the high score and writes a new high score if it is
|
||||
if cash > dwHighScore.get('cash'):
|
||||
dwHighScore = ({'userID': nodeID, 'cash': round(cash, 2)})
|
||||
with open('data/dopewar_hs.pkl', 'wb') as file:
|
||||
pickle.dump(dwHighScore, file)
|
||||
msg = "You finished with $" + "{:,}".format(cash) + " and beat the high score!🎉💰"
|
||||
elif cash > starting_cash:
|
||||
msg = 'You made money! 💵 Up ' + str((cash/starting_cash).__round__()) + 'x! Well done.'
|
||||
elif cash == starting_cash:
|
||||
msg = 'You broke even... hope you at least had fun 💉💊'
|
||||
else:
|
||||
msg = "You lost money, better go get a real job.💸"
|
||||
|
||||
# remove player from all trackers and databases
|
||||
dwPlayerTracker[:] = [p for p in dwPlayerTracker if p.get('userID') != nodeID]
|
||||
dwCashDb[:] = [p for p in dwCashDb if p.get('userID') != nodeID]
|
||||
dwInventoryDb[:] = [p for p in dwInventoryDb if p.get('userID') != nodeID]
|
||||
dwLocationDb[:] = [p for p in dwLocationDb if p.get('userID') != nodeID]
|
||||
dwGameDayDb[:] = [p for p in dwGameDayDb if p.get('userID') != nodeID]
|
||||
|
||||
return msg
|
||||
|
||||
def getHighScoreDw():
|
||||
global dwHighScore
|
||||
# Load high score table
|
||||
try:
|
||||
with open('data/dopewar_hs.pkl', 'rb') as file:
|
||||
dwHighScore = pickle.load(file)
|
||||
except FileNotFoundError:
|
||||
logger.debug("System: DopeWars: No high score table found")
|
||||
# high score pickle file is a touple of the nodeID and the high score
|
||||
dwHighScore = ({"userID": 4258675309, "cash": 100})
|
||||
# write a new high score file if one is not found
|
||||
with open('data/dopewar_hs.pkl', 'wb') as file:
|
||||
pickle.dump(dwHighScore, file)
|
||||
return dwHighScore
|
||||
|
||||
def render_game_screen(userID, day_play, total_day, loc_choice, event_number, price_list, cash_stolen, found_items):
|
||||
global dwCashDb, dwInventoryDb, dwLocationDb
|
||||
msg = ''
|
||||
# get the location for the user
|
||||
for i in range(0, len(dwLocationDb)):
|
||||
if dwLocationDb[i].get('userID') == userID:
|
||||
loc = dwLocationDb[i].get('location')
|
||||
|
||||
if event_number != -1:
|
||||
msg += event_list[event_number].text + f"\n"
|
||||
elif event_number == -1 and cash_stolen != 0 and cash_stolen != 'conf':
|
||||
msg += random.choice([f"You got high and spent ${str(cash_stolen)}💊💸\n",
|
||||
f"You got mugged and lost ${str(cash_stolen)}💸🔫\n",
|
||||
f"You got a new tattoo and spent ${str(cash_stolen)}💉💸\n",])
|
||||
elif event_number == -1 and cash_stolen == 'conf':
|
||||
msg += f"🚔Officer Bob stopped you and took all of your drugs.🚭\n"
|
||||
elif event_number == -1 and found_items != 'nothing':
|
||||
msg += found_items + f"\n"
|
||||
|
||||
# get the inventory for the user
|
||||
for i in range(0, len(dwInventoryDb)):
|
||||
if dwInventoryDb[i].get('userID') == userID:
|
||||
inventory = dwInventoryDb[i].get('inventory')
|
||||
|
||||
amount = check_inv(userID)
|
||||
# get the cash for the user
|
||||
for i in range(0, len(dwCashDb)):
|
||||
if dwCashDb[i].get('userID') == userID:
|
||||
cash = dwCashDb[i].get('cash')
|
||||
|
||||
msg += "🗺️" + loc[int(loc_choice) - 1] + " 📆" + str(day_play) + '/' + str(total_day) + " 🎒" + str(inventory) + "/100" + " 💵" + "{:,}".format(cash) + f"\n"
|
||||
|
||||
for i, drug in enumerate(my_drugs, 1):
|
||||
qty = amount[i-1]
|
||||
msg += f'#{str(i)}.{drug.name}${"{:,}".format(price_list[i-1])}({qty}) '
|
||||
|
||||
return msg
|
||||
|
||||
def dopeWarGameDay(nodeID, day_play, total_day):
|
||||
global dwCashDb, dwLocationDb, dwInventoryDb
|
||||
cash_stolen = 0
|
||||
found_items = 'nothing'
|
||||
|
||||
# roll for the event of the day
|
||||
event_number = generate_event()
|
||||
|
||||
# get the location for the user
|
||||
for i in range(0, len(dwLocationDb)):
|
||||
if dwLocationDb[i].get('userID') == nodeID:
|
||||
loc = dwLocationDb[i].get('location')
|
||||
loc_choice = dwLocationDb[i].get('loc_choice')
|
||||
|
||||
# rolls to see if event happens
|
||||
if event_number == -1 and random.randint(0, 100) > 80: # 20% chance to have an event
|
||||
if random.randint(0, 100) > 50: # 50% chance to have an officer encounter
|
||||
cash_stolen = officer(nodeID)
|
||||
else:
|
||||
# find items
|
||||
found_items = get_found_items(nodeID)
|
||||
|
||||
|
||||
price_list = price_change(event_number)
|
||||
|
||||
# set the price_list for the user
|
||||
for i in range(0, len(dwInventoryDb)):
|
||||
if dwInventoryDb[i].get('userID') == nodeID:
|
||||
dwInventoryDb[i]['priceList'] = price_list
|
||||
|
||||
check_inv(nodeID)
|
||||
|
||||
# main game display print
|
||||
msg = render_game_screen(nodeID, day_play, total_day, loc_choice, event_number, price_list, cash_stolen, found_items)
|
||||
|
||||
return msg
|
||||
|
||||
def playDopeWars(nodeID, cmd):
|
||||
global dwGameDayDb, dwPlayerTracker, dwCashDb, dwInventoryDb, dwLocationDb, dwHighScore
|
||||
|
||||
inGame = False
|
||||
msg = ''
|
||||
|
||||
# check if the player is currently playing the game
|
||||
for i in range(0, len(dwGameDayDb)):
|
||||
if dwGameDayDb[i].get('userID') == nodeID:
|
||||
inGame = True
|
||||
|
||||
# Allow ending the game from any state while a session is active.
|
||||
cmd_normalized = str(cmd).strip().lower()
|
||||
if inGame and cmd_normalized in ['e', 'end', 'quit', 'exit']:
|
||||
return endGameDw(nodeID)
|
||||
|
||||
if not inGame:
|
||||
# initalize player in the database
|
||||
loc = generatelocations()
|
||||
dwInventoryDb.append({'userID': nodeID, 'inventory': 0, 'priceList': []})
|
||||
dwCashDb.append({'userID': nodeID, 'cash': starting_cash})
|
||||
dwLocationDb.append({'userID': nodeID, 'location': loc, 'loc_choice': 0})
|
||||
dwGameDayDb.append({'userID': nodeID, 'day': 0})
|
||||
dwPlayerTracker.append({'userID': nodeID, 'last_played': time.time(), 'cmd': 'start'})
|
||||
logger.debug("System: DopeWars: New player: " + str(nodeID))
|
||||
|
||||
# get the day for the user
|
||||
for i in range(0, len(dwGameDayDb)):
|
||||
if dwGameDayDb[i].get('userID') == nodeID:
|
||||
game_day = dwGameDayDb[i].get('day')
|
||||
|
||||
# get the player's last command
|
||||
for i in range(0, len(dwPlayerTracker)):
|
||||
if dwPlayerTracker[i].get('userID') == nodeID:
|
||||
last_cmd = dwPlayerTracker[i].get('cmd')
|
||||
|
||||
# get the price_list for the user
|
||||
for i in range(0, len(dwInventoryDb)):
|
||||
if dwInventoryDb[i].get('userID') == nodeID:
|
||||
price_list = dwInventoryDb[i].get('priceList')
|
||||
|
||||
# get the location for the user
|
||||
for i in range(0, len(dwLocationDb)):
|
||||
if dwLocationDb[i].get('userID') == nodeID:
|
||||
loc_choice = dwLocationDb[i].get('loc_choice')
|
||||
|
||||
# Pick Starting City
|
||||
if last_cmd == 'start':
|
||||
# print the location table
|
||||
msg = get_location_table(nodeID)
|
||||
|
||||
# set the player's last command to location to start the game
|
||||
for i in range(0, len(dwPlayerTracker)):
|
||||
if dwPlayerTracker[i].get('userID') == nodeID:
|
||||
dwPlayerTracker[i]['cmd'] = 'location'
|
||||
|
||||
if last_cmd == 'ask_bsf':
|
||||
msg = f'example buy:\nb,drug#,qty# or Sell: s,1,10 qty can be (m)ax\n f,p or end'
|
||||
menu_choice = cmd.lower()
|
||||
if ',' in menu_choice or '.' in menu_choice:
|
||||
#split the choice into a letter and a number for the buy/sell functions
|
||||
try:
|
||||
if '.' in menu_choice:
|
||||
menu_choice = menu_choice.split('.')
|
||||
if ',' in menu_choice:
|
||||
menu_choice = menu_choice.split(',')
|
||||
|
||||
if int(menu_choice[1]) not in range(1, 8):
|
||||
raise ValueError
|
||||
else:
|
||||
menu_choice[1] = int(menu_choice[1])
|
||||
if menu_choice[0] not in ['b', 's']:
|
||||
raise ValueError
|
||||
if menu_choice[2] != 'm':
|
||||
if int(menu_choice[2]) not in range(1, 101):
|
||||
raise ValueError
|
||||
else:
|
||||
menu_choice[2] = int(menu_choice[2])
|
||||
|
||||
except ValueError:
|
||||
msg = f'a value was bad, example dopeware Buy or Sell\n b,1,10 or s,1,m'
|
||||
return msg
|
||||
|
||||
if menu_choice[0] == 'b':
|
||||
# set last command to ask_bsf and buy
|
||||
for i in range(0, len(dwPlayerTracker)):
|
||||
if dwPlayerTracker[i].get('userID') == nodeID:
|
||||
dwPlayerTracker[i]['cmd'] = 'ask_bsf'
|
||||
msg = buy_func(nodeID, price_list, menu_choice[1], menu_choice[2])
|
||||
return msg
|
||||
|
||||
if menu_choice[0] == 's':
|
||||
# set last command to ask_bsf and sell
|
||||
for i in range(0, len(dwPlayerTracker)):
|
||||
if dwPlayerTracker[i].get('userID') == nodeID:
|
||||
dwPlayerTracker[i]['cmd'] = 'ask_bsf'
|
||||
msg = sell_func(nodeID, price_list, menu_choice[1], menu_choice[2])
|
||||
return msg
|
||||
elif 's' in menu_choice:
|
||||
msg = ''
|
||||
# sell everything we have in backpack
|
||||
for i in range(0, len(dwInventoryDb)):
|
||||
if dwInventoryDb[i].get('userID') == nodeID:
|
||||
inventory = dwInventoryDb[i].get('inventory')
|
||||
if inventory == 0:
|
||||
msg = "You don't have anything to sell🚭"
|
||||
else:
|
||||
for i in range(1, (len(my_drugs) +1)):
|
||||
sell = sell_func(nodeID, price_list, i, 'm')
|
||||
# ignore starts with "You don't have any"
|
||||
if not sell.startswith("You don't have any"):
|
||||
msg += sell + '\n'
|
||||
# trim the last newline
|
||||
msg = msg[:-1]
|
||||
return msg
|
||||
elif 'f' in menu_choice:
|
||||
# set last command to location
|
||||
for i in range(0, len(dwPlayerTracker)):
|
||||
if dwPlayerTracker[i].get('userID') == nodeID:
|
||||
dwPlayerTracker[i]['cmd'] = 'location'
|
||||
last_cmd = 'location'
|
||||
|
||||
elif 'p' in menu_choice:
|
||||
# render_game_screen
|
||||
msg = render_game_screen(nodeID, game_day, total_days, loc_choice, -1, price_list, 0, 'nothing')
|
||||
return msg
|
||||
else:
|
||||
msg = f'example buy:\nb,drug#,qty# or Sell: s,1,10 qty can be (m)ax\n f,p or end'
|
||||
return msg
|
||||
|
||||
# Buy
|
||||
if last_cmd == 'buy':
|
||||
# ned to collect which drug # and qty to buy
|
||||
msg = buy_func(nodeID, price_list)
|
||||
return msg
|
||||
|
||||
# Sell
|
||||
if last_cmd == 'sell':
|
||||
msg = sell_func(nodeID, price_list)
|
||||
return msg
|
||||
|
||||
# Pick Location, and display main game screen
|
||||
if last_cmd == 'location':
|
||||
# validate the location choice
|
||||
try:
|
||||
loc_choice = int(cmd)
|
||||
if loc_choice not in range(1, 6):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
loc_choice = random.randint(1, 6)
|
||||
|
||||
# set the player's location choice
|
||||
for i in range(0, len(dwLocationDb)):
|
||||
if dwLocationDb[i].get('userID') == nodeID:
|
||||
dwLocationDb[i]['loc_choice'] = loc_choice
|
||||
|
||||
# set the player's last command
|
||||
for i in range(0, len(dwPlayerTracker)):
|
||||
if dwPlayerTracker[i].get('userID') == nodeID:
|
||||
dwPlayerTracker[i]['cmd'] = 'display_main'
|
||||
|
||||
# increment the game_day
|
||||
game_day += 1
|
||||
for i in range(0, len(dwGameDayDb)):
|
||||
if dwGameDayDb[i].get('userID') == nodeID:
|
||||
dwGameDayDb[i]['day'] = game_day
|
||||
|
||||
# update the player's last played time
|
||||
for i in range(0, len(dwPlayerTracker)):
|
||||
if dwPlayerTracker[i].get('userID') == nodeID:
|
||||
dwPlayerTracker[i]['last_played'] = time.time()
|
||||
|
||||
last_cmd = 'display_main'
|
||||
|
||||
# Display Main Game Screen and ask for buy, sell, or fly
|
||||
if last_cmd == 'display_main':
|
||||
msg = dopeWarGameDay(nodeID, game_day, total_days)
|
||||
msg += f"\nBuy💸, Sell💰, (F)ly🛫? (P)riceList?"
|
||||
# set the player's last command
|
||||
for i in range(0, len(dwPlayerTracker)):
|
||||
if dwPlayerTracker[i].get('userID') == nodeID:
|
||||
dwPlayerTracker[i]['cmd'] = 'ask_bsf'
|
||||
dwPlayerTracker[i]['last_played'] = time.time()
|
||||
|
||||
# Game end
|
||||
if game_day == total_days + 1:
|
||||
msg = endGameDw(nodeID)
|
||||
|
||||
return msg
|
||||
416
modules/games/golfsim.py
Normal file
416
modules/games/golfsim.py
Normal file
@@ -0,0 +1,416 @@
|
||||
# https://github.com/danfriedman30/pythongame
|
||||
# Adapted for Meshtastic mesh-bot by K7MHI Kelly Keeton 2024
|
||||
|
||||
import random
|
||||
import time
|
||||
import pickle
|
||||
from modules.log import logger
|
||||
from modules.settings import golfTracker
|
||||
|
||||
# Clubs setup
|
||||
driver_distances = list(range(230, 280, 5))
|
||||
low_distances = list(range(185, 215, 5))
|
||||
mid_distances = list(range(130, 185, 5))
|
||||
high_distances = list(range(90, 135, 5))
|
||||
gap_wedge_distances = list(range(50, 85, 5))
|
||||
lob_wedge_distances = list(range(10, 50, 5))
|
||||
putt_outcomes = [1, 2, 3]
|
||||
|
||||
# Hole/Course Setup
|
||||
full_hole_range = list(range(130, 520, 5))
|
||||
par3_range = list(range(130, 255, 5))
|
||||
par4_range = list(range(255, 445, 5))
|
||||
par5_range = list(range(445, 520, 5))
|
||||
par3_4_range = par3_range + par4_range
|
||||
par3_5_range = par3_range + par5_range
|
||||
par4_5_range = par4_range + par5_range
|
||||
|
||||
# Player setup
|
||||
playingHole = False
|
||||
from modules.settings import golfTracker
|
||||
|
||||
# Club functions
|
||||
def hit_driver():
|
||||
club_distance = random.choice(driver_distances)
|
||||
return club_distance
|
||||
|
||||
def hit_low_iron():
|
||||
club_distance = random.choice(low_distances)
|
||||
return club_distance
|
||||
|
||||
def hit_mid_iron():
|
||||
club_distance = random.choice(mid_distances)
|
||||
return club_distance
|
||||
|
||||
def hit_high_iron():
|
||||
club_distance = random.choice(high_distances)
|
||||
return club_distance
|
||||
|
||||
def hit_gap_wedge():
|
||||
club_distance = random.choice(gap_wedge_distances)
|
||||
return club_distance
|
||||
|
||||
def hit_lob_wedge():
|
||||
club_distance = random.choice(lob_wedge_distances)
|
||||
return club_distance
|
||||
|
||||
def finish_hole():
|
||||
finish = random.choice(putt_outcomes)
|
||||
return finish
|
||||
|
||||
def endGameGolf(nodeID):
|
||||
# pop player from tracker
|
||||
for i in range(len(golfTracker)):
|
||||
if golfTracker[i]['nodeID'] == nodeID:
|
||||
golfTracker.pop(i)
|
||||
logger.debug("System: GolfSim: Player " + str(nodeID) + " has ended their round.")
|
||||
|
||||
def getScorecardGolf(scorecard):
|
||||
# Scorecard messages, convert score to message comment
|
||||
msg = ""
|
||||
if scorecard == 8:
|
||||
# Quadruple bogey
|
||||
msg += " +Quad Bogey☃️ "
|
||||
elif scorecard == 7:
|
||||
# Triple bogey
|
||||
msg += " +Triple Bogey "
|
||||
elif scorecard == 6:
|
||||
# Double bogey
|
||||
msg += " +Double Bogey "
|
||||
elif scorecard == 5:
|
||||
# Bogey
|
||||
msg += " +Bogey "
|
||||
elif scorecard > 0:
|
||||
# Over par
|
||||
msg += f" +Par {str(scorecard)} "
|
||||
elif scorecard == 0:
|
||||
# Even par
|
||||
msg += " Even Par💪 "
|
||||
elif scorecard == -1:
|
||||
# Birdie
|
||||
msg += " -Birdie🐦 "
|
||||
elif scorecard == -2:
|
||||
# Eagle
|
||||
msg += " -Eagle🦅 "
|
||||
elif scorecard == -3:
|
||||
# Albatross
|
||||
msg += " -Albatross🦅🦅 "
|
||||
else:
|
||||
# Under par
|
||||
msg += f" -Par {str(abs(scorecard))} "
|
||||
return msg
|
||||
|
||||
def getHighScoreGolf(nodeID, strokes, par):
|
||||
# check if player is in high score list
|
||||
try:
|
||||
with open('data/golfsim_hs.pkl', 'rb') as f:
|
||||
golfHighScore = pickle.load(f)
|
||||
except:
|
||||
logger.debug("System: GolfSim: High Score file not found.")
|
||||
golfHighScore = [{'nodeID': nodeID, 'strokes': strokes, 'par': par}]
|
||||
with open('data/golfsim_hs.pkl', 'wb') as f:
|
||||
pickle.dump(golfHighScore, f)
|
||||
|
||||
if strokes < golfHighScore[0]['strokes']:
|
||||
# player got new low score which is high score
|
||||
golfHighScore[0]['nodeID'] = nodeID
|
||||
golfHighScore[0]['strokes'] = strokes
|
||||
golfHighScore[0]['par'] = par
|
||||
with open('data/golfsim_hs.pkl', 'wb') as f:
|
||||
pickle.dump(golfHighScore, f)
|
||||
return golfHighScore
|
||||
|
||||
return 0
|
||||
|
||||
# Main game loop
|
||||
def playGolf(nodeID, message, finishedHole=False, last_cmd=''):
|
||||
msg = ''
|
||||
# Course setup
|
||||
par3_count = 0
|
||||
par4_count = 0
|
||||
par5_count = 0
|
||||
# Scorecard setup
|
||||
total_strokes = 0
|
||||
total_to_par = 0
|
||||
par = 0
|
||||
hole = 1
|
||||
|
||||
# get player's last command from tracker if not new player
|
||||
last_cmd = ""
|
||||
for i in range(len(golfTracker)):
|
||||
if golfTracker[i]['nodeID'] == nodeID:
|
||||
last_cmd = golfTracker[i]['cmd']
|
||||
hole = golfTracker[i]['hole']
|
||||
distance_remaining = golfTracker[i]['distance_remaining']
|
||||
hole_shots = golfTracker[i]['hole_shots']
|
||||
par = golfTracker[i]['par']
|
||||
total_strokes = golfTracker[i]['total_strokes']
|
||||
total_to_par = golfTracker[i]['total_to_par']
|
||||
#update last played time
|
||||
for i in range(len(golfTracker)):
|
||||
if golfTracker[i]['nodeID'] == nodeID:
|
||||
golfTracker[i]['last_played'] = time.time()
|
||||
|
||||
if last_cmd == "new":
|
||||
# Start a new hole
|
||||
if hole <= 9:
|
||||
# Set up hole count restrictions on par
|
||||
if par3_count < 2 and par4_count < 5 and par5_count < 2:
|
||||
hole_length = random.choice(full_hole_range)
|
||||
if par3_count >= 2 and par4_count < 5 and par5_count < 2:
|
||||
hole_length = random.choice(par4_5_range)
|
||||
if par3_count >= 2 and par4_count < 5 and par5_count >= 2:
|
||||
hole_length = random.choice(par4_range)
|
||||
if par3_count < 2 and par4_count < 5 and par5_count >= 2:
|
||||
hole_length = random.choice(par3_4_range)
|
||||
if par3_count < 2 and par4_count >= 5 and par5_count >= 2:
|
||||
hole_length = random.choice(par3_range)
|
||||
if par3_count >= 2 and par4_count >= 5 and par5_count < 2:
|
||||
hole_length = random.choice(par5_range)
|
||||
if par3_count < 2 and par4_count >= 5 and par5_count < 2:
|
||||
hole_length = random.choice(par3_5_range)
|
||||
|
||||
# Set up par for the hole
|
||||
if hole_length <= 250:
|
||||
par = 3
|
||||
par3_count += 1
|
||||
elif hole_length > 250 and hole_length <= 440:
|
||||
par = 4
|
||||
par4_count += 1
|
||||
elif hole_length > 440:
|
||||
par = 5
|
||||
par5_count += 1
|
||||
|
||||
# roll for chance of hazard
|
||||
hazard_chance = random.randint(1, 100)
|
||||
weather_chance = random.randint(1, 100)
|
||||
# have low chances of hazards and weather
|
||||
hasHazard = False
|
||||
hazard = ""
|
||||
if hazard_chance < 25:
|
||||
# Further reduce chance of hazards with weather
|
||||
if weather_chance < 15:
|
||||
# randomly calculate a hazard for the hole sand, 🌊, 🌲, 🏘️, etc
|
||||
hazard = random.choice(["🏖️", "🌊", "🌲", "🏘️"])
|
||||
hasHazard = True
|
||||
|
||||
|
||||
# Set initial parameters before starting a hole
|
||||
distance_remaining = hole_length
|
||||
hole_shots = 0
|
||||
last_cmd = 'stroking'
|
||||
|
||||
# save player's current game state
|
||||
for i in range(len(golfTracker)):
|
||||
if golfTracker[i]['nodeID'] == nodeID:
|
||||
golfTracker[i]['cmd'] = last_cmd
|
||||
golfTracker[i]['hole'] = hole
|
||||
golfTracker[i]['distance_remaining'] = distance_remaining
|
||||
golfTracker[i]['cmd'] = 'stroking'
|
||||
golfTracker[i]['par'] = par
|
||||
golfTracker[i]['total_strokes'] = total_strokes
|
||||
golfTracker[i]['total_to_par'] = total_to_par
|
||||
golfTracker[i]['hazard'] = hazard
|
||||
golfTracker[i]['last_played'] = time.time()
|
||||
golfTracker[i]['hole_shots'] = hole_shots
|
||||
|
||||
# Show player the hole information
|
||||
msg += "⛳️#" + str(hole) + " is a " + str(hole_length) + "-yard Par " + str(par) + "."
|
||||
if hasHazard:
|
||||
msg += "⚠️" + hazard + "."
|
||||
else:
|
||||
# add weather conditions with random choice from list, this is fluff
|
||||
msg += random.choice(["☀️", "💨", "☀️", "☀️", "⛅️", "☁️", "☀️"])
|
||||
|
||||
if not finishedHole:
|
||||
msg += f"\nChoose your club."
|
||||
|
||||
return msg
|
||||
|
||||
if last_cmd == 'stroking':
|
||||
|
||||
# Get player's current game state
|
||||
for i in range(len(golfTracker)):
|
||||
if golfTracker[i]['nodeID'] == nodeID:
|
||||
distance_remaining = golfTracker[i]['distance_remaining']
|
||||
hole = golfTracker[i]['hole']
|
||||
hole_shots = golfTracker[i]['hole_shots']
|
||||
par = golfTracker[i]['par']
|
||||
total_strokes = golfTracker[i]['total_strokes']
|
||||
total_to_par = golfTracker[i]['total_to_par']
|
||||
hazard = golfTracker[i]['hazard']
|
||||
|
||||
# Start loop to be able to choose clubs while at least 20 yards away
|
||||
if distance_remaining >= 20:
|
||||
msg = ""
|
||||
club = message.lower()
|
||||
shot_distance = 0
|
||||
|
||||
pin_distance = distance_remaining
|
||||
|
||||
if club == "driver" or club.startswith("d"):
|
||||
shot_distance = hit_driver()
|
||||
msg += "🏌️Hit D " + str(shot_distance) + "yd. "
|
||||
distance_remaining = abs(distance_remaining - shot_distance)
|
||||
hole_shots += 1
|
||||
elif "low" in club or club.startswith("l"):
|
||||
shot_distance = hit_low_iron()
|
||||
msg += "🏌️Hit L Iron " + str(shot_distance) + "yd. "
|
||||
distance_remaining = abs(distance_remaining - shot_distance)
|
||||
hole_shots += 1
|
||||
elif "mid" in club or club.startswith("m"):
|
||||
shot_distance = hit_mid_iron()
|
||||
msg += "🏌️Hit M Iron " + str(shot_distance) + "yd. "
|
||||
distance_remaining = abs(distance_remaining - shot_distance)
|
||||
hole_shots += 1
|
||||
elif "high" in club or club.startswith("h"):
|
||||
shot_distance = hit_high_iron()
|
||||
msg += "🏌️Hit H Iron " + str(shot_distance) + "yd. "
|
||||
distance_remaining = abs(distance_remaining - shot_distance)
|
||||
hole_shots += 1
|
||||
elif "gap" in club or club.startswith("g"):
|
||||
shot_distance = hit_gap_wedge()
|
||||
msg += "🏌️Hit G Wedge " + str(shot_distance) + "yd ."
|
||||
distance_remaining = abs(distance_remaining - shot_distance)
|
||||
hole_shots += 1
|
||||
elif "wedge" in club or club.startswith("w"):
|
||||
shot_distance = hit_lob_wedge()
|
||||
msg += "🏌️Hit L Wedge " + str(shot_distance) + "yd. "
|
||||
distance_remaining = abs(distance_remaining - shot_distance)
|
||||
hole_shots += 1
|
||||
elif club == "caddy" or club.startswith("c"):
|
||||
# Show player the club distances
|
||||
msg += f"Caddy Guess:\nD:{hit_driver()} L:{hit_low_iron()} M:{hit_mid_iron()} H:{hit_high_iron()} G:{hit_gap_wedge()} W:{hit_lob_wedge()}"
|
||||
else:
|
||||
msg += f"Didnt get your club 🥪♣️🪩 choice, you have {distance_remaining}yds. to ⛳️"
|
||||
return msg
|
||||
|
||||
if distance_remaining - pin_distance > pin_distance or shot_distance > pin_distance:
|
||||
# Check for over-shooting the hole
|
||||
if distance_remaining > 20:
|
||||
# did it go off the "green"?
|
||||
msg += "Overshot the green!🚀"
|
||||
if distance_remaining == 0:
|
||||
msg += "🎯Perfect shot! "
|
||||
last_cmd = 'putt'
|
||||
elif distance_remaining < 20:
|
||||
# Roll Dice
|
||||
hole_in_one_chance = random.randint(1, 100)
|
||||
wind_factor = random.randint(1, 10)
|
||||
skill_factor = random.randint(1, 10)
|
||||
critter_factor = random.randint(1, 50)
|
||||
|
||||
# Check for hole in one
|
||||
if hole_in_one_chance <= 5 and wind_factor > 7 and skill_factor > 8:
|
||||
distance_remaining = 0
|
||||
# Check for critters
|
||||
if skill_factor > 8 and critter_factor < 40 and wind_factor > 2 and hole_in_one_chance > 5:
|
||||
msg += random.choice(["A 🐿️ steals your ball!😡 ","You Hit a 🦅 soring past ", "🐊 need we say more? ", "hit a 🪟 of a 🏡 "])
|
||||
distance_remaining = -1
|
||||
# Handle hazard
|
||||
if hazard == "🌊" and skill_factor < 7:
|
||||
msg += "In the water!🌊"
|
||||
distance_remaining = -1
|
||||
if hazard == "🏖️" and skill_factor < 5:
|
||||
msg += "In the sand!🏖️"
|
||||
distance_remaining = random.randint(5, 10)
|
||||
if hazard == "🌲" and skill_factor < 3:
|
||||
msg += "In the trees!🌲"
|
||||
distance_remaining += random.randint(5, 20)
|
||||
if hazard == "🏘️" and skill_factor < 2:
|
||||
msg += "In the parking lot!🚗"
|
||||
distance_remaining += random.randint(10, 30)
|
||||
|
||||
# Check we didnt go off the green or into a hazard
|
||||
if distance_remaining < 20:
|
||||
last_cmd = 'putt'
|
||||
else:
|
||||
last_cmd = 'stroking'
|
||||
else:
|
||||
msg += f"\nYou have " + str(distance_remaining) + "yd. ⛳️"
|
||||
msg += f"\nClub?[D, L, M, H, G, W]🏌️"
|
||||
|
||||
|
||||
# save player's current game state, keep stroking
|
||||
for i in range(len(golfTracker)):
|
||||
if golfTracker[i]['nodeID'] == nodeID:
|
||||
golfTracker[i]['distance_remaining'] = distance_remaining
|
||||
golfTracker[i]['hole_shots'] = hole_shots
|
||||
golfTracker[i]['total_strokes'] = total_strokes
|
||||
golfTracker[i]['cmd'] = 'stroking'
|
||||
|
||||
return msg
|
||||
|
||||
if last_cmd == 'putt':
|
||||
# Finish the hole by putting
|
||||
critter = False
|
||||
if distance_remaining < 20:
|
||||
if distance_remaining == 0:
|
||||
putts = 0
|
||||
elif distance_remaining == -1:
|
||||
putts = 0
|
||||
critter = True
|
||||
else:
|
||||
putts = finish_hole()
|
||||
|
||||
# Calculate hole and round scores
|
||||
hole_strokes = hole_shots + putts
|
||||
hole_to_par = hole_strokes - par
|
||||
total_strokes += hole_strokes
|
||||
total_to_par += hole_to_par
|
||||
|
||||
|
||||
if not critter:
|
||||
# Show player hole/round scoring info
|
||||
if putts == 0 and hole_strokes == 1:
|
||||
msg += "🎯Hole in one!⛳️"
|
||||
elif putts == 0:
|
||||
msg += "You're in the hole at " + str(hole_strokes) + " strokes!"
|
||||
else:
|
||||
msg += "You're on the green! After " + str(putts) + " putt(s), you're in for " + str(hole_strokes) + " strokes."
|
||||
msg += getScorecardGolf(hole_to_par)
|
||||
|
||||
if hole not in [1, 10]:
|
||||
# Show player total scoring info for the round, except hole 1 and 10
|
||||
msg += f"\nYou've hit a total of " + str(total_strokes) + " strokes today, for"
|
||||
msg += getScorecardGolf(total_to_par)
|
||||
|
||||
# Move to next hole
|
||||
hole += 1
|
||||
else:
|
||||
msg += f"Got a new ball at Pro-Shop, marshal put you @" # flow into same hole haha
|
||||
|
||||
# Scorecard reset
|
||||
hole_to_par = 0
|
||||
hole_strokes = 0
|
||||
hole_shots = 0
|
||||
|
||||
# Save player's current game state
|
||||
for i in range(len(golfTracker)):
|
||||
if golfTracker[i]['nodeID'] == nodeID:
|
||||
golfTracker[i]['hole_strokes'] = hole_strokes
|
||||
golfTracker[i]['hole_to_par'] = hole_to_par
|
||||
golfTracker[i]['total_strokes'] = total_strokes
|
||||
golfTracker[i]['total_to_par'] = total_to_par
|
||||
golfTracker[i]['hole'] = hole
|
||||
golfTracker[i]['cmd'] = 'new'
|
||||
golfTracker[i]['last_played'] = time.time()
|
||||
|
||||
if hole >= 9:
|
||||
# Final score messages & exit prompt
|
||||
msg += f"🎉Finished 9-hole round⛳️"
|
||||
#HighScore Display
|
||||
highscore = getHighScoreGolf(nodeID, total_strokes, total_to_par)
|
||||
if highscore != 0:
|
||||
msg += " 🏆New Club Record🏆"
|
||||
# pop player from tracker
|
||||
for i in range(len(golfTracker)):
|
||||
if golfTracker[i]['nodeID'] == nodeID:
|
||||
golfTracker.pop(i)
|
||||
logger.debug("System: GolfSim: Player " + str(nodeID) + " has finished their round.")
|
||||
else:
|
||||
# Show player the next hole
|
||||
msg += playGolf(nodeID, '', True, last_cmd='new')
|
||||
msg += f"\n🏌️[D, L, M, H, G, W, End]🏌️"
|
||||
|
||||
return msg
|
||||
151
modules/games/hamtest.py
Normal file
151
modules/games/hamtest.py
Normal file
@@ -0,0 +1,151 @@
|
||||
# hamradio test module for meshbot DE K7MHI 2025
|
||||
# depends on the JSON question data files from https://github.com/russolsen/ham_radio_question_pool
|
||||
|
||||
# data files which are expected to be in ../../data/hamradio/ similar to the following:
|
||||
# https://raw.githubusercontent.com/russolsen/ham_radio_question_pool/refs/heads/master/technician-2022-2026/technician.json
|
||||
# https://raw.githubusercontent.com/russolsen/ham_radio_question_pool/refs/heads/master/general-2023-2027/general.json
|
||||
# https://raw.githubusercontent.com/russolsen/ham_radio_question_pool/refs/heads/master/extra-2024-2028/extra.json
|
||||
|
||||
import json
|
||||
import random
|
||||
import os
|
||||
from modules.log import logger
|
||||
from modules.settings import hamtestTracker
|
||||
|
||||
class HamTest:
|
||||
def __init__(self):
|
||||
self.questions = {}
|
||||
self.load_questions()
|
||||
self.game = {}
|
||||
|
||||
def load_questions(self):
|
||||
for level in ['technician', 'general', 'extra']:
|
||||
try:
|
||||
with open(f'{os.path.dirname(__file__)}/../../data/hamradio/{level}.json', encoding='utf-8') as f:
|
||||
self.questions[level] = json.load(f)
|
||||
except FileNotFoundError:
|
||||
logger.error(f"File not found: ../../data/hamradio/{level}.json")
|
||||
self.questions[level] = []
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Error decoding JSON from file: ../../data/hamradio/{level}.json")
|
||||
self.questions[level] = []
|
||||
|
||||
def newGame(self, id, level='technician'):
|
||||
msg = f"📻New {level} quiz started, 'end' to exit."
|
||||
if id in self.game:
|
||||
level = self.game[id]['level']
|
||||
self.game[id] = {
|
||||
'level': level,
|
||||
'score': 0,
|
||||
'total': 0,
|
||||
'errors': [],
|
||||
'qId': None,
|
||||
'question': None,
|
||||
'answers': None,
|
||||
'correct': None
|
||||
}
|
||||
# set the pool needed for the game
|
||||
if self.game[id]['level'] == 'extra':
|
||||
self.game[id]['total'] = 50
|
||||
else:
|
||||
self.game[id]['total'] = 35
|
||||
|
||||
# randomize the questions
|
||||
random.shuffle(self.questions[level])
|
||||
|
||||
msg += f"\n{self.nextQuestion(id)}"
|
||||
return msg
|
||||
|
||||
def nextQuestion(self, id):
|
||||
level = self.game[id]['level']
|
||||
# if question has the word figure in it, skip it
|
||||
question = random.choice(self.questions[level])
|
||||
while 'figure' in question['question'].lower():
|
||||
question = random.choice(self.questions[level])
|
||||
|
||||
self.game[id]['question'] = question['question']
|
||||
self.game[id]['answers'] = question['answers']
|
||||
self.game[id]['correct'] = question['correct']
|
||||
self.game[id]['qId'] = question['id']
|
||||
self.game[id]['total'] -= 1
|
||||
|
||||
if self.game[id]['total'] == 0:
|
||||
return self.endGame(id)
|
||||
|
||||
# ask the question and return answers in A, B, C, D format
|
||||
msg = f"{self.game[id]['question']}\n"
|
||||
for i, answer in enumerate(self.game[id]['answers']):
|
||||
msg += f"{chr(65+i)}. {answer}\n"
|
||||
return msg
|
||||
|
||||
def answer(self, id, answer):
|
||||
if id not in self.game:
|
||||
return "No game in progress"
|
||||
if self.game[id]['correct'] == ord(answer.upper()) - 65:
|
||||
self.game[id]['score'] += 1
|
||||
return f"Correct👍\n" + self.nextQuestion(id)
|
||||
else:
|
||||
# record the section of the question for study aid
|
||||
section = self.game[id]['qId'][:3]
|
||||
self.game[id]['errors'].append(section)
|
||||
# provide the correct answer
|
||||
answer = [self.game[id]['correct']]
|
||||
return f"Wrong.⛔️ Correct is {chr(65+self.game[id]['correct'])}\n" + self.nextQuestion(id)
|
||||
|
||||
def getScore(self, id):
|
||||
if id not in self.game:
|
||||
return "No game in progress"
|
||||
score = self.game[id]['score']
|
||||
total = self.game[id]['total']
|
||||
level = self.game[id]['level']
|
||||
if self.game[id]['errors']:
|
||||
areaofstudy = max(set(self.game[id]['errors']), key = self.game[id]['errors'].count)
|
||||
else:
|
||||
areaofstudy = "None"
|
||||
|
||||
if level == 'extra':
|
||||
pool = 50
|
||||
else:
|
||||
pool = 35
|
||||
|
||||
return f"Score: {score}/{pool}\nQuestions left: {total}\nArea of study: {areaofstudy}"
|
||||
|
||||
def endGame(self, id):
|
||||
if id not in self.game:
|
||||
return "No game in progress"
|
||||
|
||||
score = self.game[id]['score']
|
||||
level = self.game[id]['level']
|
||||
|
||||
if level == 'extra':
|
||||
# passing score for extra is 37 out of 50
|
||||
passing = 37
|
||||
else:
|
||||
# passing score for technician and general is 26 out of 35
|
||||
passing = 26
|
||||
|
||||
if score >= passing:
|
||||
msg = f"Game over. Score: {score} 73! 🎉You passed the {level} exam."
|
||||
else:
|
||||
# find the most common section of the questions missed
|
||||
if self.game[id]['errors']:
|
||||
areaofstudy = max(set(self.game[id]['errors']), key = self.game[id]['errors'].count)
|
||||
else:
|
||||
areaofstudy = "None"
|
||||
msg = f"Game over. Score: {score} 73! 😿You did not pass the {level} exam. \nYou may want to study {areaofstudy}."
|
||||
|
||||
# remove the game[id] from the list
|
||||
del self.game[id]
|
||||
# hamtestTracker stores dicts like {"nodeID": nodeID, ...}
|
||||
for i in range(len(hamtestTracker)):
|
||||
try:
|
||||
if hamtestTracker[i].get('nodeID') == id:
|
||||
hamtestTracker.pop(i)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return msg
|
||||
|
||||
hamtest = HamTest()
|
||||
|
||||
226
modules/games/hangman.py
Normal file
226
modules/games/hangman.py
Normal file
@@ -0,0 +1,226 @@
|
||||
# Written for Meshtastic mesh-bot by ZR1RF Johannes le Roux 2025
|
||||
from modules.log import logger, getPrettyTime
|
||||
import os
|
||||
import json
|
||||
import random
|
||||
from modules.settings import hangmanTracker
|
||||
|
||||
class Hangman:
|
||||
WORDS = [
|
||||
"ability","able","about","above","accept","according","account","across",
|
||||
"act","action","activity","actually","add","address","administration","admit",
|
||||
"adult","affect","after","again","against","age","agency","agent","ago",
|
||||
"agree","agreement","ahead","air","all","allow","almost","alone","along",
|
||||
"already","also","although","always","American","among","amount","analysis",
|
||||
"and","animal","another","answer","any","anyone","anything","appear","apply",
|
||||
"approach","area","argue","arm","around","arrive","art","article","artist",
|
||||
"as","ask","assume","at","attack","attention","attorney","audience","author",
|
||||
"authority","available","avoid","away","baby","back","bad","bag","ball",
|
||||
"bank","bar","base","be","beat","beautiful","because","become","bed","before",
|
||||
"begin","behavior","behind","believe","benefit","best","better","between",
|
||||
"beyond","big","bill","billion","bit","black","blood","blue","board","body",
|
||||
"book","born","both","box","boy","break","bring","brother","budget","build",
|
||||
"building","business","but","buy","by","call","camera","campaign","can",
|
||||
"cancer","candidate","capital","car","card","care","career","carry","case",
|
||||
"catch","cause","cell","center","central","century","certain","certainly",
|
||||
"chair","challenge","chance","change","character","charge","check","child",
|
||||
"choice","choose","church","citizen","city","civil","claim","class","clear",
|
||||
"clearly","close","coach","cold","collection","college","color","come",
|
||||
"commercial","common","community","company","compare","computer","concern",
|
||||
"condition","conference","Congress","consider","consumer","contain","continue",
|
||||
"control","cost","could","country","couple","course","court","cover","create",
|
||||
"crime","cultural","culture","cup","current","customer","cut","dark","data",
|
||||
"daughter","day","dead","deal","death","debate","decade","decide","decision",
|
||||
"deep","defense","degree","democrat","democratic","describe","design",
|
||||
"despite","detail","determine","develop","development","die","difference",
|
||||
"different","difficult","dinner","direction","director","discover","discuss",
|
||||
"discussion","disease","do","doctor","dog","door","down","draw","dream","drive",
|
||||
"drop","drug","during","each","early","east","easy","eat","economic","economy",
|
||||
"edge","education","effect","effort","eight","either","election","else",
|
||||
"employee","end","energy","enjoy","enough","enter","entire","environment",
|
||||
"environmental","especially","establish","even","evening","event","ever",
|
||||
"every","everybody","everyone","everything","evidence","exactly","example",
|
||||
"executive","exist","expect","experience","expert","explain","eye","face",
|
||||
"fact","factor","fail","fall","family","far","fast","father","fear","federal",
|
||||
"feel","feeling","few","field","fight","figure","fill","film","final","finally",
|
||||
"financial","find","fine","finger","finish","fire","firm","first","fish","five",
|
||||
"floor","fly","focus","follow","food","foot","for","force","foreign","forget",
|
||||
"form","former","forward","four","free","friend","from","front","full","fund",
|
||||
"future","game","garden","gas","general","generation","get","girl","give",
|
||||
"glass","go","goal","good","government","great","green","ground","group","grow",
|
||||
"growth","guess","gun","guy","hair","half","hand","hang","happen","happy",
|
||||
"hard","have","he","head","health","hear","heart","heat","heavy","help","her",
|
||||
"here","herself","high","him","himself","his","history","hit","hold","home",
|
||||
"hope","hospital","hot","hotel","hour","house","how","however","huge","human",
|
||||
"hundred","husband","I","idea","identify","if","image","imagine","impact",
|
||||
"important","improve","in","include","including","increase","indeed","indicate",
|
||||
"individual","industry","information","inside","instead","institution","interest",
|
||||
"interesting","international","interview","into","investment","involve","issue",
|
||||
"it","item","its","itself","job","join","just","keep","key","kid","kill","kind",
|
||||
"kitchen","know","knowledge","land","language","large","last","late","later",
|
||||
"laugh","law","lawyer","lay","lead","leader","learn","least","leave","left",
|
||||
"leg","legal","less","let","letter","level","lie","life","light","like","likely",
|
||||
"line","list","listen","little","live","local","long","look","lose","loss",
|
||||
"lot","love","low","machine","magazine","main","maintain","major","majority",
|
||||
"make","man","manage","management","manager","many","market","marriage",
|
||||
"material","matter","may","maybe","me","mean","measure","media","medical","meet",
|
||||
"meeting","member","memory","mention","message","method","middle","might",
|
||||
"military","million","mind","minute","miss","mission","model","modern","moment",
|
||||
"money","month","more","morning","most","mother","mouth","move","movement",
|
||||
"movie","Mr","Mrs","much","music","must","my","myself","name","nation",
|
||||
"national","natural","nature","near","nearly","necessary","need","network",
|
||||
"never","new","news","newspaper","next","nice","night","no","none","nor",
|
||||
"north","not","note","nothing","notice","now","number","occur","of","off",
|
||||
"offer","office","officer","official","often","oh","oil","ok","old","on",
|
||||
"once","one","only","onto","open","operation","opportunity","option","or",
|
||||
"order","organization","other","others","our","out","outside","over","own",
|
||||
"owner","page","pain","painting","paper","parent","part","participant",
|
||||
"particular","particularly","partner","party","pass","past","patient","pattern",
|
||||
"pay","peace","people","per","perform","performance","perhaps","period",
|
||||
"person","personal","phone","physical","pick","picture","piece","place","plan",
|
||||
"plant","play","player","point","police","policy","political","politics",
|
||||
"poor","popular","population","position","positive","possible","power",
|
||||
"practice","prepare","present","president","pressure","pretty","prevent","price",
|
||||
"private","probably","problem","process","produce","product","production",
|
||||
"professional","professor","program","project","property","protect","prove",
|
||||
"provide","public","pull","purpose","push","put","quality","question","quickly",
|
||||
"quite","race","radio","raise","range","rate","rather","reach","read","ready",
|
||||
"real","reality","realize","really","reason","receive","recent","recently",
|
||||
"recognize","record","red","reduce","reflect","region","relate","relationship",
|
||||
"religious","remain","remember","remove","report","represent","republican",
|
||||
"require","research","resource","respond","response","responsibility","rest",
|
||||
"result","return","reveal","rich","right","rise","risk","road","rock","role",
|
||||
"room","rule","run","safe","same","save","say","scene","school","science",
|
||||
"scientist","score","sea","season","seat","second","section","security","see",
|
||||
"seek","seem","sell","send","senior","sense","series","serious","serve",
|
||||
"service","set","seven","several","shake","share","she","shoot","short","shot",
|
||||
"should","shoulder","show","side","sign","significant","similar","simple",
|
||||
"simply","since","sing","single","sister","sit","site","situation","six","size",
|
||||
"skill","skin","small","smile","so","social","society","soldier","some",
|
||||
"somebody","someone","something","sometimes","son","song","soon","sort","sound",
|
||||
"source","south","southern","space","speak","special","specific","speech",
|
||||
"spend","sport","spring","staff","stage","stand","standard","star","start",
|
||||
"state","statement","station","stay","step","still","stock","stop","store",
|
||||
"story","strategy","street","strong","structure","student","study","stuff",
|
||||
"style","subject","success","successful","such","suddenly","suffer","suggest",
|
||||
"summer","support","sure","surface","system","table","take","talk","task","tax",
|
||||
"teach","teacher","team","technology","television","tell","ten","tend","term",
|
||||
"test","than","thank","that","the","their","them","themselves","then","theory",
|
||||
"there","these","they","thing","think","third","this","those","though","thought",
|
||||
"thousand","threat","three","through","throughout","throw","thus","time","to",
|
||||
"today","together","tonight","too","top","total","tough","toward","town","trade",
|
||||
"traditional","training","travel","treat","treatment","tree","trial","trip",
|
||||
"trouble","true","truth","try","turn","TV","two","type","under","understand",
|
||||
"unit","until","up","upon","us","use","usually","value","various","very",
|
||||
"victim","view","violence","visit","voice","vote","wait","walk","wall","want",
|
||||
"war","watch","water","way","we","weapon","wear","week","weight","well","west",
|
||||
"western","what","whatever","when","where","whether","which","while","white",
|
||||
"who","whole","whom","whose","why","wide","wife","will","win","wind","window",
|
||||
"wish","with","within","without","woman","wonder","word","work","worker","world",
|
||||
"worry","would","write","writer","wrong","yard","yeah","year","yes","yet","you",
|
||||
"young","your","yourself","meshtastic","node","lora","mesh"]
|
||||
|
||||
def __init__(self):
|
||||
self.game = {}
|
||||
self.DEFAULT_WORDS = self.WORDS
|
||||
|
||||
|
||||
# Try to load hangman.json if it exists
|
||||
hangman_json_path = os.path.join('data', 'hangman.json')
|
||||
if os.path.exists(hangman_json_path):
|
||||
try:
|
||||
with open(hangman_json_path, 'r') as f:
|
||||
words = json.load(f)
|
||||
# Ensure it's a list of strings
|
||||
if isinstance(words, list) and all(isinstance(w, str) for w in words):
|
||||
self.WORDS = words
|
||||
else:
|
||||
self.WORDS = self.DEFAULT_WORDS
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
logger.warning("Failed to load hangman.json, using default words. example JSON: [\"apple\",\"banana\",\"cherry\"]")
|
||||
self.WORDS = self.DEFAULT_WORDS
|
||||
else:
|
||||
self.WORDS = self.DEFAULT_WORDS
|
||||
|
||||
def new_game(self, id):
|
||||
games = won = 0
|
||||
ret = ""
|
||||
if id in self.game:
|
||||
games = self.game[id]["games"]
|
||||
won = self.game[id]["won"]
|
||||
ret += f"Total Games: {games}, Won: {won}\n"
|
||||
|
||||
self.game[id] = {
|
||||
"word": self.random_word(),
|
||||
"guesses": [],
|
||||
"games": games+1,
|
||||
"won": won
|
||||
}
|
||||
ret += self.game_continue(id)
|
||||
return ret
|
||||
|
||||
def guess(self, id, input):
|
||||
g = self.game[id]
|
||||
if not input:
|
||||
return
|
||||
letter = input[0].lower()
|
||||
if letter.isalpha() and letter not in g["guesses"]:
|
||||
g["guesses"].append(letter)
|
||||
|
||||
def wrong_guesses(self, id):
|
||||
g = self.game[id]
|
||||
wrong = 0
|
||||
for letter in g["guesses"]:
|
||||
if letter not in g["word"]:
|
||||
wrong += 1
|
||||
return wrong
|
||||
|
||||
def won(self, id):
|
||||
g = self.game[id]
|
||||
for letter in g["word"]:
|
||||
if letter not in g["guesses"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
def mask(self, id):
|
||||
g = self.game[id]
|
||||
return " ".join([a if a in g["guesses"] else "_" for a in g["word"]])
|
||||
|
||||
def game_board(self, id):
|
||||
g = self.game[id]
|
||||
emotions = "😀🙂😐😑😕😔💀"
|
||||
wrong = self.wrong_guesses(id)
|
||||
ret = ""
|
||||
if self.won(id):
|
||||
ret += "🥳" + "\n"
|
||||
g["won"] += 1
|
||||
else:
|
||||
ret += emotions[wrong] + "\n"
|
||||
ret += hangman.mask(id) + "\n"
|
||||
if g["guesses"]:
|
||||
ret += ",".join(g["guesses"]) + "\n"
|
||||
return ret
|
||||
|
||||
def game_continue(self, id):
|
||||
return self.game_board(id) + "Guess a letter"
|
||||
|
||||
def game_over(self, id):
|
||||
return self.game_board(id) + "Game over, the word was " + self.game[id]["word"]
|
||||
|
||||
def play(self, id, input):
|
||||
if id not in self.game:
|
||||
return self.new_game(id)
|
||||
self.guess(id, input)
|
||||
wrong = self.wrong_guesses(id)
|
||||
if wrong >= 6 or self.won(id):
|
||||
return self.game_over(id) + "\n" + self.new_game(id)
|
||||
return self.game_continue(id)
|
||||
|
||||
def end(self, id):
|
||||
del self.game[id]
|
||||
|
||||
def random_word(self):
|
||||
return random.choice(self.WORDS)
|
||||
|
||||
hangmanTracker = []
|
||||
hangman = Hangman()
|
||||
192
modules/games/joke.py
Normal file
192
modules/games/joke.py
Normal file
@@ -0,0 +1,192 @@
|
||||
# This module is used to tell jokes to the user
|
||||
# The emoji table of contents is used to replace words in the joke with emojis
|
||||
# As a Ham, is this obsecuring the meaning of the joke? Or is it enhancing it?
|
||||
from dadjokes import Dadjoke # pip install dadjokes
|
||||
import random
|
||||
from modules.log import logger, getPrettyTime
|
||||
from modules.settings import dad_jokes_emojiJokes, dad_jokes_enabled
|
||||
|
||||
lameJokes = [
|
||||
"Why don't scientists trust atoms? Because they make up everything!",
|
||||
"Why did the scarecrow win an award? Because he was outstanding in his field!",
|
||||
"Why don't skeletons fight each other? They don't have the guts.",
|
||||
"What do you call fake spaghetti? An impasta!",
|
||||
"Why did the bicycle fall over? Because it was two-tired!",
|
||||
"Why did the math book look sad? Because it had too many problems.",
|
||||
"Why did the golfer bring two pairs of pants? In case he got a hole in one.",
|
||||
"Why did the coffee file a police report? It got mugged.",
|
||||
"Why did the tomato turn red? Because it saw the salad dressing!",
|
||||
"Why did the cookie go to the doctor? Because it felt crummy.",
|
||||
"Why did the computer go to the doctor? Because it had a virus!",
|
||||
"Why did the chicken join a band? Because it had the drumsticks!",
|
||||
"Why did the banana go to the doctor? Because it wasn't peeling well.",
|
||||
"Why did the cow go to space? To see the moooon!",
|
||||
"Why did the fish blush? Because it saw the ocean's bottom!",
|
||||
"Why did the elephant bring a suitcase to the zoo? Because it wanted to pack its trunk!",
|
||||
"Why did the meshtastic node go to therapy? It had too many connections to handle!",
|
||||
"Why did the meshtastic user bring a ladder to the meeting? To reach new heights in communication!",
|
||||
"Why did the meshtastic device break up with Wi-Fi? It found a better connection!",
|
||||
"Why did the meshtastic network throw a party? Because it wanted to mesh well with everyone!",
|
||||
"Why did the meshtastic node get promoted? Because it was outstanding in its field!",
|
||||
"Why did the meshtastic user bring a map? To navigate the mesh of possibilities!",
|
||||
"Why did the meshtastic device go to school? To improve its signal strength!",
|
||||
"How did the meshtastic node become a comedian? It uses mesh-bots to deliver punchlines!",
|
||||
"Chuck Norris doesn't read books. He stares them down until he gets the information he wants.",
|
||||
"When Chuck Norris enters a room, he doesn't turn the lights on. He turns the dark off.",
|
||||
"Chuck Norris can divide by zero.",
|
||||
"Chuck Norris counted to infinity. Twice.",
|
||||
"Chuck Norris can slam a revolving door.",
|
||||
"When Chuck Norris does a push-up, he isn't lifting himself up; he's pushing the Earth down.",
|
||||
"Chuck Norris can hear sign language.",
|
||||
"Death once had a near-Chuck Norris experience.",
|
||||
"Chuck Norris can unscramble an egg.",
|
||||
"Chuck Norris can win a game of Connect Four in only three moves.",
|
||||
"Chuck Norris can make a snowman out of rain.",
|
||||
"Chuck Norris can strangle you with a cordless phone.",
|
||||
"Chuck Norris can do a wheelie on a unicycle.",
|
||||
"Chuck Norris can kill two stones with one bird.",
|
||||
"Chuck Norris can speak braille.",
|
||||
"Chuck Norris can build a snowman out of rain.",
|
||||
"This is a test. A test of the Joke Brodcast System. If this had been an actual joke, you would have been amused.",
|
||||
"Chuck Norris doesn't join mesh networks. Mesh networks join Chuck's topology.",
|
||||
"Every time Chuck Norris sends a packet, it arrives before he hits 'send'",
|
||||
"Chuck Norris doesn't need LoRa. His roundhouse kick has a 15km range with zero latency.",
|
||||
"When Chuck Norris uses a node, the bandwidth doubles out of fear.",
|
||||
"Chuck Norris once pinged a device. It replied with an apology and a firmware update.",
|
||||
"Chuck Norris doesn't use AES encryption. His packets are so secure, they punch hackers in the bits.",
|
||||
"The Meshtastic protocol has a hidden mode: “Chuck Norris mode.” It only activates when he blinks.",
|
||||
"Chuck Norris doesn't need a GPS fix. Satellites triangulate themselves around him.",
|
||||
"Chuck Norris's mesh node doesn't sleep. It meditates while transmitting at full power.",
|
||||
"Chuck Norris doesn't broadcast. He declares.",
|
||||
"Chuck Norris once bridged two mesh networks using a shoelace.",
|
||||
"Chuck Norris's packets don't hop. They teleport out of respect.",
|
||||
"Chuck Norris doesn't need a repeater. Client_Mute is set to 'Always'.",
|
||||
"Chuck Norris's mesh messages are entangled. When he sends one, it's already received.",
|
||||
"Chuck Norris doesn't mesh with others. Others mesh with Chuck.",
|
||||
"Chuck Norris's node doesn't need a case. The PCB is armored with his beard hair.",
|
||||
"Chuck Norris once typed “Hello World” and the world replied 'Hello Chuck.'",
|
||||
]
|
||||
|
||||
# pylint: disable=C0103, W0612
|
||||
imtellingyourightnowiAmTellingYouRightNowThatMotherfErBackThereIsNotReal = ["🐦", "🦅", "🦆", "🦉", "🦜", "🐤", "🐥", "🐣", "🐔", "🐧", "🦚", "🦢", "🦩", "🦤", "🦃", "🐓"]
|
||||
|
||||
def tableOfContents():
|
||||
wordToEmojiMap = {
|
||||
'love': '❤️', 'heart': '❤️', 'happy': '😊', 'smile': '😊', 'sad': '😢', 'angry': '😠', 'mad': '😠', 'cry': '😢', 'laugh': '😂', 'funny': '😂', 'cool': '😎',
|
||||
'joy': '😂', 'kiss': '😘', 'hug': '🤗', 'wink': '😉', 'grin': '😁', 'bored': '😐', 'tired': '😴', 'sleepy': '😴', 'shocked': '😲', 'surprised': '😲',
|
||||
'confused': '😕', 'thinking': '🤔', 'sick': '🤢', 'party': '🎉', 'celebrate': '🎉', 'clap': '👏', 'thumbs up': '👍', 'thumbs down': '👎',
|
||||
'ok': '👌', 'wave': '👋', 'pray': '🙏', 'muscle': '💪', 'fire': '🔥', 'star': '⭐', 'sun': '☀️', 'moon': '🌙', 'rain': '🌧️', 'snow': '❄️', 'cloud': '☁️',
|
||||
'dog': '🐶', 'cat': '🐱', 'mouse': '🐭', 'rabbit': '🐰', 'fox': '🦊', 'bear': '🐻', 'panda': '🐼', 'koala': '🐨', 'tiger': '🐯', 'lion': '🦁', 'cow': '🐮',
|
||||
'pig': '🐷', 'frog': '🐸', 'monkey': '🐵', 'chicken': '🐔', 'penguin': '🐧', 'bird': '🐦', 'fish': '🐟', 'whale': '🐋', 'dolphin': '🐬', 'octopus': '🐙',
|
||||
'apple': '🍎', 'orange': '🍊', 'banana': '🍌', 'watermelon': '🍉', 'grape': '🍇', 'strawberry': '🍓', 'cherry': '🍒', 'peach': '🍑', 'pineapple': '🍍', 'mango': '🥭', 'coconut': '🥥',
|
||||
'tomato': '🍅', 'eggplant': '🍆', 'avocado': '🥑', 'broccoli': '🥦', 'cucumber': '🥒', 'corn': '🌽', 'carrot': '🥕', 'potato': '🥔', 'sweet potato': '🍠', 'chili': '🌶️', 'garlic': '🧄',
|
||||
'pizza': '🍕', 'burger': '🍔', 'fries': '🍟', 'hotdog': '🌭', 'popcorn': '🍿', 'donut': '🍩', 'cookie': '🍪', 'cake': '🎂', 'pie': '🍰', 'cupcake': '🧁', 'chocolate': '🍫',
|
||||
'candy': '🍬', 'lollipop': '🍭', 'pudding': '🍮', 'honey': '🍯', 'milk': '🍼', 'coffee': '☕', 'tea': '🍵', 'sake': '🍶', 'beer': '🍺', 'cheers': '🍻', 'champagne': '🥂',
|
||||
'wine': '🍷', 'whiskey': '🥃', 'cocktail': '🍸', 'tropical drink': '🍹', 'bottle': '🍾', 'soda': '🥤', 'chopsticks': '🥢', 'fork': '🍴', 'knife': '🔪', 'spoon': '🥄', 'kitchen knife': '🔪',
|
||||
'house': '🏠', 'home': '🏡', 'office': '🏢', 'post office': '🏣', 'hospital': '🏥', 'bank': '🏦', 'hotel': '🏨', 'love hotel': '🏩', 'convenience store': '🏪', 'school': '🏫', 'department store': '🏬',
|
||||
'factory': '🏭', 'castle': '🏯', 'palace': '🏰', 'church': '💒', 'tower': '🗼', 'statue of liberty': '🗽', 'mosque': '🕌', 'synagogue': '🕍', 'hindu temple': '🛕', 'kaaba': '🕋', 'shinto shrine': '⛩️',
|
||||
'railway': '🛤️', 'highway': '🛣️', 'map': '🗾', 'carousel': '🎠', 'ferris wheel': '🎡', 'roller coaster': '🎢', 'circus': '🎪', 'theater': '🎭', 'art': '🎨', 'slot machine': '🎰', 'dice': '🎲',
|
||||
'bowling': '🎳', 'video game': '🎮', 'dart': '🎯', 'billiard': '🎱', 'medal': '🎖️', 'trophy': '🏆', 'gold medal': '🥇', 'silver medal': '🥈', 'bronze medal': '🥉', 'soccer': '⚽', 'baseball': '⚾',
|
||||
'basketball': '🏀', 'volleyball': '🏐', 'football': '🏈', 'rugby': '🏉', 'tennis': '🎾', 'frisbee': '🥏', 'ping pong': '🏓', 'badminton': '🏸', 'boxing': '🥊', 'martial arts': '🥋',
|
||||
'goal': '🥅', 'golf': '⛳', 'skating': '⛸️', 'fishing': '🎣', 'diving': '🤿', 'running': '🎽', 'skiing': '🎿', 'sledding': '🛷', 'curling': '🥌', 'climbing': '🧗', 'yoga': '🧘',
|
||||
'surfing': '🏄', 'swimming': '🏊', 'water polo': '🤽', 'cycling': '🚴', 'mountain biking': '🚵', 'horse riding': '🏇', 'kneeling': '🧎', 'weightlifting': '🏋️', 'gymnastics': '🤸', 'wrestling': '🤼', 'handball': '🤾',
|
||||
'juggling': '🤹', 'meditation': '🧘', 'sauna': '🧖', 'rock climbing': '🧗', 'stop': '🛑', 'computer': '💻', 'phone': '📱', 'email': '📧', 'camera': '📷', 'video': '📹', 'music': '🎵',
|
||||
'guitar': '🎸', 'piano': '🎹', 'drum': '🥁', 'microphone': '🎤', 'headphone': '🎧', 'book': '📚', 'newspaper': '📰', 'magazine': '📖', 'pen': '🖊️', 'pencil': '✏️', 'paintbrush': '🖌️',
|
||||
'scissors': '✂️', 'ruler': '📏', 'globe': '🌍', 'earth': '🌎', 'star': '🌟', 'comet': '☄️', 'rocket': '🚀', 'airplane': '✈️', 'car': '🚗', 'bus': '🚌', 'train': '🚆',
|
||||
'bicycle': '🚲', 'motorcycle': '🏍️', 'boat': '🚤', 'ship': '🚢', 'helicopter': '🚁', 'tractor': '🚜', 'ambulance': '🚑', 'fire truck': '🚒', 'police car': '🚓', 'taxi': '🚕', 'truck': '🚚',
|
||||
'construction': '🚧', 'traffic light': '🚦', 'stop sign': '🛑', 'fuel': '⛽', 'battery': '🔋', 'light bulb': '💡', 'flashlight': '🔦', 'candle': '🕯️', 'lamp': '🛋️',
|
||||
'bed': '🛏️', 'sofa': '🛋️', 'chair': '🪑', 'table': '🛋️', 'toilet': '🚽', 'shower': '🚿', 'bathtub': '🛁', 'sink': '🚰', 'mirror': '🪞', 'door': '🚪', 'window': '🪟',
|
||||
'key': '🔑', 'lock': '🔒', 'hammer': '🔨', 'wrench': '🔧', 'screwdriver': '🪛', 'saw': '🪚', 'drill': '🛠️', 'toolbox': '🧰', 'paint roller': '🖌️', 'brush': '🖌️', 'broom': '🧹',
|
||||
'mop': '🧽', 'bucket': '🪣', 'vacuum': '🧹', 'washing machine': '🧺', 'dryer': '🧺', 'iron': '🧺', 'hanger': '🧺', 'laundry': '🧺', 'basket': '🧺', 'trash': '🗑️', 'recycle': '♻️',
|
||||
'plant': '🌱', 'tree': '🌳', 'flower': '🌸', 'leaf': '🍃', 'cactus': '🌵', 'mushroom': '🍄', 'herb': '🌿', 'bamboo': '🎍', 'rose': '🌹', 'tulip': '🌷', 'sunflower': '🌻',
|
||||
'hibiscus': '🌺', 'cherry blossom': '🌸', 'bouquet': '💐', 'seedling': '🌱', 'palm tree': '🌴', 'evergreen tree': '🌲', 'deciduous tree': '🌳', 'fallen leaf': '🍂', 'maple leaf': '🍁',
|
||||
'ear of rice': '🌾', 'shamrock': '☘️', 'four leaf clover': '🍀', 'grapes': '🍇', 'melon': '🍈', 'watermelon': '🍉', 'tangerine': '🍊', 'lemon': '🍋', 'banana': '🍌', 'pineapple': '🍍',
|
||||
'green apple': '🍏', 'pear': '🍐', 'peach': '🍑', 'cherries': '🍒', 'strawberry': '🍓', 'kiwi': '🥝', 'tomato': '🍅', 'coconut': '🥥', 'avocado': '🥑',
|
||||
'hot pepper': '🌶️', 'cucumber': '🥒', 'leafy green': '🥬', 'broccoli': '🥦', 'garlic': '🧄', 'onion': '🧅',
|
||||
'peanuts': '🥜', 'chestnut': '🌰', 'bread': '🍞', 'croissant': '🥐', 'baguette': '🥖', 'flatbread': '🥙', 'pretzel': '🥨', 'bagel': '🥯', 'pancakes': '🥞', 'waffle': '🧇', 'cheese': '🧀',
|
||||
'meat': '🍖', 'poultry': '🍗', 'bacon': '🥓', 'hamburger': '🍔', 'fries': '🍟', 'pizza': '🍕', 'hot dog': '🌭', 'sandwich': '🥪', 'taco': '🌮', 'burrito': '🌯', 'tamale': '🫔',
|
||||
'stuffed flatbread': '🥙', 'falafel': '🧆', 'egg': '🥚', 'fried egg': '🍳', 'shallow pan of food': '🥘', 'pot of food': '🍲', 'fondue': '🫕', 'bowl with spoon': '🥣', 'green salad': '🥗',
|
||||
'popcorn': '🍿', 'butter': '🧈', 'salt': '🧂', 'canned food': '🥫', 'bento box': '🍱', 'rice cracker': '🍘', 'rice ball': '🍙', 'cooked rice': '🍚', 'curry rice': '🍛', 'steaming bowl': '🍜',
|
||||
'spaghetti': '🍝', 'roasted sweet potato': '🍠', 'oden': '🍢', 'sushi': '🍣', 'fried shrimp': '🍤', 'fish cake': '🍥', 'moon cake': '🥮', 'dango': '🍡', 'dumpling': '🥟', 'fortune cookie': '🥠',
|
||||
'takeout box': '🥡', 'crab': '🦀', 'lobster': '🦞', 'shrimp': '🦐', 'squid': '🦑', 'oyster': '🦪', 'ice cream': '🍨', 'shaved ice': '🍧', 'ice cream cone': '🍦', 'doughnut': '🍩', 'cookie': '🍪',
|
||||
'birthday cake': '🎂', 'shortcake': '🍰', 'cupcake': '🧁', 'pie': '🥧', 'chocolate bar': '🍫', 'candy': '🍬', 'lollipop': '🍭', 'custard': '🍮', 'honey pot': '🍯', 'baby bottle': '🍼',
|
||||
'glass of milk': '🥛', 'hot beverage': '☕', 'teapot': '🫖', 'teacup without handle': '🍵', 'sake': '🍶', 'bottle with popping cork': '🍾', 'wine glass': '🍷', 'cocktail glass': '🍸', 'tropical drink': '🍹',
|
||||
'beer mug': '🍺', 'clinking beer mugs': '🍻', 'clinking glasses': '🥂', 'tumbler glass': '🥃', 'cup with straw': '🥤', 'bubble tea': '🧋', 'beverage box': '🧃', 'mate': '🧉', 'ice': '🧊',
|
||||
'chopsticks': '🥢', 'fork and knife': '🍴', 'spoon': '🥄', 'kitchen knife': '🔪', 'amphora': '🏺', 'globe showing Europe-Africa': '🌍', 'globe showing Americas': '🌎', 'globe showing Asia-Australia': '🌏',
|
||||
'globe with meridians': '🌐', 'world map': '🗺️', 'mountain': '⛰️', 'volcano': '🌋', 'mount fuji': '🗻', 'camping': '🏕️', 'beach with umbrella': '🏖️', 'desert': '🏜️', 'desert island': '🏝️',
|
||||
'national park': '🏞️', 'stadium': '🏟️', 'classical building': '🏛️', 'building construction': '🏗️', 'brick': '🧱', 'rock': '🪨', 'wood': '🪵', 'hut': '🛖', 'houses': '🏘️', 'derelict house': '🏚️',
|
||||
'house with garden': '🏡', 'office building': '🏢', 'japanese post office': '🏣', 'post office': '🏤', 'hospital': '🏥', 'bank': '🏦', 'hotel': '🏨', 'love hotel': '🏩', 'convenience store': '🏪',
|
||||
'castle': '🏰', 'wedding': '💒', 'tokyo tower': '🗼', 'statue of liberty': '🗽', 'church': '⛪', 'mosque': '🕌',
|
||||
'fountain': '⛲', 'tent': '⛺', 'foggy': '🌁', 'night with stars': '🌃', 'sunrise over mountains': '🌄', 'sunrise': '🌅',
|
||||
'cityscape at dusk': '🌆', 'sunset': '🌇', 'cityscape': '🏙️', 'bridge at night': '🌉', 'hot springs': '♨️', 'carousel horse': '🎠', 'barber pole': '💈',
|
||||
'robot': '🤖', 'alien': '👽', 'ghost': '👻', 'skull': '💀', 'pumpkin': '🎃', 'clown': '🤡', 'wizard': '🧙', 'elf': '🧝', 'fairy': '🧚', 'mermaid': '🧜', 'vampire': '🧛',
|
||||
'zombie': '🧟', 'genie': '🧞', 'superhero': '🦸', 'supervillain': '🦹', 'mage': '🧙', 'knight': '🛡️', 'ninja': '🥷', 'pirate': '🏴☠️', 'angel': '👼', 'devil': '😈', 'dragon': '🐉',
|
||||
'unicorn': '🦄', 'phoenix': '🦅', 'griffin': '🦅', 'centaur': '🐎', 'minotaur': '🐂', 'cyclops': '👁️', 'medusa': '🐍', 'sphinx': '🦁', 'kraken': '🦑', 'yeti': '❄️', 'sasquatch': '🦧',
|
||||
'loch ness monster': '🦕', 'chupacabra': '🐐', 'banshee': '👻', 'golem': '🗿', 'djinn': '🧞', 'basilisk': '🐍', 'hydra': '🐉', 'cerberus': '🐶', 'chimera': '🐐', 'manticore': '🦁', 'wyvern': '🐉',
|
||||
'pegasus': '🦄', 'hippogriff': '🦅', 'kelpie': '🐎', 'selkie': '🦭', 'kitsune': '🦊', 'tanuki': '🦝', 'tengu': '🦅', 'oni': '👹', 'yokai': '👻', 'kappa': '🐢', 'yurei': '👻',
|
||||
'kami': '👼', 'shinigami': '💀', 'bakemono': '👹', 'tsukumogami': '🧸', 'noppera-bo': '👤', 'rokurokubi': '🧛', 'yuki-onna': '❄️', 'jorogumo': '🕷️', 'nue': '🐍', 'ubume': '👼',
|
||||
'atom': '⚛️', 'dna': '🧬', 'microscope': '🔬', 'telescope': '🔭', 'satellite': '🛰️', 'spaceship': '🛸', 'planet': '🪐', 'black hole': '🕳️', 'galaxy': '🌌',
|
||||
'constellation': '🌠', 'lightning': '⚡', 'magnet': '🧲', 'computer': '💻', 'keyboard': '⌨️', 'mouse': '🖱️', 'printer': '🖨️', 'floppy disk': '💾',
|
||||
'cd': '💿', 'dvd': '📀', 'smartphone': '📱', 'tablet': '📲', 'watch': '⌚', 'camera': '📷', 'video camera': '📹', 'projector': '📽️', 'radio': '📻', 'television': '📺',
|
||||
'satellite dish': '📡', 'game controller': '🎮', 'joystick': '🕹️', 'vr headset': '🕶️', 'headphones': '🎧', 'speaker': '🔊', 'circuit': '🔌', 'chip': '💻',
|
||||
'server': '🖥️', 'database': '💾', 'cloud': '☁️', 'network': '🌐', 'code': '💻', 'bug': '🐛', 'virus': '🦠', 'bacteria': '🦠', 'lab coat': '🥼', 'safety goggles': '🥽',
|
||||
'test tube': '🧪', 'petri dish': '🧫', 'beaker': '🧪', 'bunsen burner': '🔥', 'graduated cylinder': '🧪', 'pipette': '🧪', 'scalpel': '🔪', 'syringe': '💉', 'pill': '💊',
|
||||
'stethoscope': '🩺', 'thermometer': '🌡️', 'x-ray': '🩻', 'brain': '🧠', 'heart': '❤️', 'lung': '🫁', 'bone': '🦴', 'muscle': '💪', 'robot arm': '🦾', 'robot leg': '🦿',
|
||||
'prosthetic arm': '🦾', 'prosthetic leg': '🦿', 'wheelchair': '🦽', 'crutch': '🦯', 'hearing aid': '🦻', 'glasses': '👓', 'magnifying glass': '🔍', 'circus tent': '🎪',
|
||||
'duck': '🦆', 'eagle': '🦅', 'owl': '🦉', 'bat': '🦇', 'shark': '🦈', 'butterfly': '🦋', 'snail': '🐌', 'bee': '🐝', 'beetle': '🐞', 'ant': '🐜', 'cricket': '🦗',
|
||||
'spider': '🕷️', 'scorpion': '🦂', 'turkey': '🦃', 'peacock': '🦚', 'parrot': '🦜', 'swan': '🦢', 'flamingo': '🦩', 'dodo': '🦤', 'sloth': '🦥', 'otter': '🦦',
|
||||
'skunk': '🦨', 'kangaroo': '🦘', 'badger': '🦡', 'beaver': '🦫', 'bison': '🦬', 'mammoth': '🦣', 'raccoon': '🦝', 'hedgehog': '🦔', 'squirrel': '🐿️', 'chipmunk': '🐿️',
|
||||
'porcupine': '🦔', 'llama': '🦙', 'giraffe': '🦒', 'zebra': '🦓', 'hippopotamus': '🦛', 'rhinoceros': '🦏', 'gorilla': '🦍', 'orangutan': '🦧', 'elephant': '🐘', 'camel': '🐫',
|
||||
'alpaca': '🦙', 'buffalo': '🐃', 'ox': '🐂', 'deer': '🦌', 'moose': '🦌', 'reindeer': '🦌', 'goat': '🐐', 'sheep': '🐑', 'ram': '🐏', 'lamb': '🐑', 'horse': '🐴',
|
||||
'rat': '🐀', 'hedgehog': '🦔', 'chicken': '🐔', 'rooster': '🐓', 'crocodile': '🐊', 'turtle': '🐢', 'lizard': '🦎', 'dragon': '🐉', 'sauropod': '🦕', 't-rex': '🦖', 'butterfly': '🦋',
|
||||
'mosquito': '🦟', 'microbe': '🦠', 'locomotive': '🚂', 'arm': '💪', 'leg': '🦵', 'sponge': '🧽',
|
||||
'toothbrush': '🪥', 'roll of paper': '🧻', 'soap': '🧼', 'toilet paper': '🧻', 'shower': '🚿', 'bathtub': '🛁', 'razor': '🪒', 'lotion': '🧴',
|
||||
'letter': '✉️', 'envelope': '✉️', 'mail': '📬', 'post': '📮', 'golf': '⛳️', 'golfing': '⛳️', 'meeting': '📅', 'presentation': '📊', 'report': '📄', 'document': '📄',
|
||||
'file': '📁', 'folder': '📂', 'sports': '🏅', 'athlete': '🏃', 'competition': '🏆', 'race': '🏁', 'tournament': '🏆', 'champion': '🏆', 'medal': '🏅', 'victory': '🏆', 'win': '🏆', 'lose': '😞',
|
||||
'draw': '🤝', 'team': '👥', 'player': '👤', 'coach': '👨🏫', 'referee': '🧑⚖️', 'stadium': '🏟️', 'arena': '🏟️', 'field': '🏟️', 'court': '🏟️', 'track': '🏟️', 'gym': '🏋️', 'fitness': '🏋️', 'exercise': '🏋️',
|
||||
'workout': '🏋️', 'training': '🏋️', 'practice': '🏋️', 'game': '🎮', 'match': '🎮', 'score': '🏅', 'goal': '🥅', 'point': '🏅', 'basket': '🏀', 'home run': '⚾️', 'strike': '🎳', 'spare': '🎳', 'frame': '🎳',
|
||||
'inning': '⚾️', 'shower': '🚿', 'uniform': '👕', 'jersey': '👕', 'cleats': '👟', 'helmet': '⛑️', 'pads': '🛡️', 'gloves': '🧤', 'bat': '⚾️', 'ball': '⚽️', 'puck': '🏒', 'stick': '🏒', 'net': '🥅', 'goalpost': '🥅',
|
||||
'scoreboard': '📊', 'fans': '👥', 'crowd': '👥', 'cheer': '📣', 'boo': '😠', 'applause': '👏', 'celebration': '🎉', 'parade': '🎉', 'trophy': '🏆', 'medal': '🏅', 'ribbon': '🎀',
|
||||
'third place': '🥉', 'snowman': '☃️', 'snowmen': '⛄️'
|
||||
}
|
||||
|
||||
return wordToEmojiMap
|
||||
|
||||
def sendWithEmoji(message):
|
||||
# this will take a string of text and replace any word or phrase that is in the word list with the corresponding emoji
|
||||
wordToEmojiMap = tableOfContents()
|
||||
# type format to clean it up
|
||||
words = message.split()
|
||||
i = 0
|
||||
while i < len(words):
|
||||
for phrase in sorted(wordToEmojiMap.keys(), key=len, reverse=True):
|
||||
phrase_words = phrase.split()
|
||||
# Strip punctuation from the words
|
||||
stripped_words = [word.lower().strip('.,!?') for word in words[i:i+len(phrase_words)]]
|
||||
if stripped_words == phrase_words:
|
||||
logger.debug(f"System: Replaced the phrase '{phrase}' with '{wordToEmojiMap[phrase]}'")
|
||||
words[i:i+len(phrase_words)] = [wordToEmojiMap[phrase]]
|
||||
i += len(phrase_words) - 1
|
||||
break
|
||||
# Check for plural forms
|
||||
elif stripped_words == [word + 's' for word in phrase_words]:
|
||||
logger.debug(f"System: Replaced the plural phrase '{' '.join([word + 's' for word in phrase_words])}' with '{wordToEmojiMap[phrase]}'")
|
||||
words[i:i+len(phrase_words)] = [wordToEmojiMap[phrase]]
|
||||
i += len(phrase_words) - 1
|
||||
break
|
||||
i += 1
|
||||
return ' '.join(words)
|
||||
|
||||
def tell_joke(nodeID=0, vox=False, test=False):
|
||||
dadjoke = Dadjoke()
|
||||
if test:
|
||||
return sendWithEmoji(dadjoke.joke)
|
||||
try:
|
||||
if dad_jokes_emojiJokes:
|
||||
renderedLaugh = sendWithEmoji(dadjoke.joke)
|
||||
else:
|
||||
renderedLaugh = dadjoke.joke
|
||||
return renderedLaugh
|
||||
except Exception as e:
|
||||
return random.choice(lameJokes)
|
||||
|
||||
566
modules/games/lemonade.py
Normal file
566
modules/games/lemonade.py
Normal file
@@ -0,0 +1,566 @@
|
||||
# Port of https://github.com/tigerpointe/Lemonade-Stand/blob/main/lemonade.py MIT License Copyright (c) 2023 TigerPointe Software, LLC
|
||||
# Adapted for Meshtastic mesh-bot by K7MHI Kelly Keeton 2024
|
||||
|
||||
from collections import OrderedDict # ordered dictionaries
|
||||
from random import randrange, uniform # random numbers
|
||||
from types import SimpleNamespace # namespaces support
|
||||
import pickle # pickle file support
|
||||
import time # time functions
|
||||
from modules.log import logger # mesh-bot logging
|
||||
from modules.system import lemonadeTracker # player tracking
|
||||
import locale # culture specific locale
|
||||
import math # math functions
|
||||
import re # regular expressions
|
||||
|
||||
# Set all of the locale category elements as default
|
||||
# ex. print(locale.currency(12345.67, grouping=True))
|
||||
locale.setlocale(locale.LC_ALL, '')
|
||||
lemon_starting_cash = 30.00
|
||||
lemon_total_weeks = 7
|
||||
|
||||
lemonadeCups = [{'nodeID': 0, 'cost': 2.50, 'count': 25, 'min': 0.99, 'unit': 0.00}]
|
||||
lemonadeLemons = [{'nodeID': 0, 'cost': 4.00, 'count': 8, 'min': 2.00, 'unit': 0.00}]
|
||||
lemonadeSugar = [{'nodeID': 0, 'cost': 3.00, 'count': 15, 'min': 1.50, 'unit': 0.00}]
|
||||
lemonadeWeeks = [{'nodeID': 0, 'current': 1, 'total': lemon_total_weeks, 'sales': 99, 'potential': 0, 'unit': 0.00, 'price': 0.00, 'total_sales': 0}]
|
||||
lemonadeScore = [{'nodeID': 0, 'value': 0.00, 'total': 0.00}]
|
||||
|
||||
def get_sales_amount(potential, unit, price):
|
||||
"""Gets the sales amount.
|
||||
Multiply the potential sales by a ratio of unit cost to actual price; the
|
||||
exponent results in the values falling along a curve, rather than along a
|
||||
straight line, resulting in more realistic sales values at each price.
|
||||
Parameters
|
||||
potential : Potential sales
|
||||
unit : Unit cost
|
||||
price : Actual price
|
||||
"""
|
||||
return math.floor(potential * (unit / (price ** 1.5)))
|
||||
|
||||
def getHighScoreLemon():
|
||||
high_score = {"userID": 0, "cash": 0, "success": 0}
|
||||
# Load high score table
|
||||
try:
|
||||
with open('data/lemonstand.pkl', 'rb') as file:
|
||||
high_score = pickle.load(file)
|
||||
except FileNotFoundError:
|
||||
logger.debug("System: Lemonade: No high score table found")
|
||||
# write a new high score file if one is not found
|
||||
with open('data/lemonstand.pkl', 'wb') as file:
|
||||
pickle.dump(high_score, file)
|
||||
return high_score
|
||||
|
||||
def playLemonstand(nodeID, message, celsius=False, newgame=False):
|
||||
global lemonadeTracker, lemonadeCups, lemonadeLemons, lemonadeSugar, lemonadeWeeks, lemonadeScore
|
||||
msg = ""
|
||||
potential = 0
|
||||
unit = 0.0
|
||||
price = 0.0
|
||||
total_sales = 0
|
||||
lemonsLastCmd = ''
|
||||
|
||||
high_score = getHighScoreLemon()
|
||||
|
||||
def saveValues(nodeID, inventory, cups, lemons, sugar, weeks, score):
|
||||
# save playerDB values
|
||||
for i in range(len(lemonadeTracker)):
|
||||
if lemonadeTracker[i]['nodeID'] == nodeID:
|
||||
lemonadeTracker[i]['cups'] = inventory.cups
|
||||
lemonadeTracker[i]['lemons'] = inventory.lemons
|
||||
lemonadeTracker[i]['sugar'] = inventory.sugar
|
||||
lemonadeTracker[i]['cash'] = inventory.cash
|
||||
lemonadeTracker[i]['start'] = inventory.start
|
||||
for i in range(len(lemonadeCups)):
|
||||
if lemonadeCups[i]['nodeID'] == nodeID:
|
||||
lemonadeCups[i]['cost'] = cups.cost
|
||||
lemonadeCups[i]['unit'] = cups.unit
|
||||
for i in range(len(lemonadeLemons)):
|
||||
if lemonadeLemons[i]['nodeID'] == nodeID:
|
||||
lemonadeLemons[i]['cost'] = lemons.cost
|
||||
lemonadeLemons[i]['unit'] = lemons.unit
|
||||
for i in range(len(lemonadeSugar)):
|
||||
if lemonadeSugar[i]['nodeID'] == nodeID:
|
||||
lemonadeSugar[i]['cost'] = sugar.cost
|
||||
lemonadeSugar[i]['unit'] = sugar.unit
|
||||
for i in range(len(lemonadeWeeks)):
|
||||
if lemonadeWeeks[i]['nodeID'] == nodeID:
|
||||
lemonadeWeeks[i]['current'] = weeks.current
|
||||
lemonadeWeeks[i]['total'] = weeks.total
|
||||
lemonadeWeeks[i]['sales'] = weeks.sales
|
||||
lemonadeWeeks[i]['potential'] = potential
|
||||
lemonadeWeeks[i]['unit'] = unit
|
||||
lemonadeWeeks[i]['price'] = price
|
||||
lemonadeWeeks[i]['total_sales'] = weeks.total_sales
|
||||
for i in range(len(lemonadeScore)):
|
||||
if lemonadeScore[i]['nodeID'] == nodeID:
|
||||
lemonadeScore[i]['value'] = score.value
|
||||
lemonadeScore[i]['total'] = score.total
|
||||
|
||||
title="LemonStand🍋"
|
||||
# Define the temperature unit symbols
|
||||
fahrenheit_unit = "ºF"
|
||||
celsius_unit = "ºC"
|
||||
|
||||
# Inventory data (contains the item levels)
|
||||
inventoryd = {
|
||||
'cups' : 0,
|
||||
'lemons' : 0,
|
||||
'sugar' : 0,
|
||||
'cash' : lemon_starting_cash,
|
||||
'start' : lemon_starting_cash
|
||||
}
|
||||
inventory = SimpleNamespace(**inventoryd)
|
||||
|
||||
# Cups data (includes a calculated cost per unit)
|
||||
cupsd = {
|
||||
'cost' : 2.50, # current price
|
||||
'count' : 25, # servings per box
|
||||
'min' : 0.99, # minimum price
|
||||
'unit' : 0.00 # unit price
|
||||
}
|
||||
cups = SimpleNamespace(**cupsd)
|
||||
cups.unit = round(cups.cost / cups.count, 2)
|
||||
|
||||
# Lemons data (includes a calculated cost per unit)
|
||||
lemonsd = {
|
||||
'cost' : 4.00, # current price
|
||||
'count' : 8, # servings per bag
|
||||
'min' : 2.00, # minimum price
|
||||
'unit' : 0.00 # unit price
|
||||
}
|
||||
lemons = SimpleNamespace(**lemonsd)
|
||||
lemons.unit = round(lemons.cost / lemons.count, 2)
|
||||
|
||||
# Sugar data (includes a calculated cost per unit)
|
||||
sugard = {
|
||||
'cost' : 3.00, # current price
|
||||
'count' : 15, # servings per bag
|
||||
'min' : 1.50, # minimum price
|
||||
'unit' : 0.00 # unit price
|
||||
}
|
||||
sugar = SimpleNamespace(**sugard)
|
||||
sugar.unit = round(sugar.cost / sugar.count, 2)
|
||||
|
||||
# Weeks data (measures the session duration)
|
||||
weeksd = {
|
||||
'current' : 1, # start with the 1st week
|
||||
'total' : 12, # span the 12 weeks of Summer
|
||||
'sales' : 99, # 99 maximum sales per week
|
||||
'total_sales' : 0, # total sales
|
||||
'summary' : [] # empty array
|
||||
}
|
||||
weeks = SimpleNamespace(**weeksd)
|
||||
|
||||
# Forecast data (includes percentage values, UTF8 glyphs and display names)
|
||||
forecastd = OrderedDict()
|
||||
forecastd['sunny'] = [1.00, 0x2600, "Sunny"]
|
||||
forecastd['partly'] = [0.90, 0x26C5, "Partly Sunny"]
|
||||
forecastd['cloudy'] = [0.70, 0x2601, "Mostly Cloudy"]
|
||||
forecastd['rainy'] = [0.40, 0x2602, "Rainy"]
|
||||
forecastd['stormy'] = [0.10, 0x26C8, "Stormy"]
|
||||
|
||||
# Temperature data (uses Fahrenheit as the percentage values)
|
||||
temperatured = {
|
||||
'min' : 69,
|
||||
'max' : 100,
|
||||
'units' : fahrenheit_unit,
|
||||
'forecast' : None,
|
||||
'value' : None
|
||||
}
|
||||
temperature = SimpleNamespace(**temperatured)
|
||||
|
||||
# Score data (based on actual vs. maximum net sales)
|
||||
scored = {
|
||||
'value' : 0.00,
|
||||
'total' : 0.00
|
||||
}
|
||||
score = SimpleNamespace(**scored)
|
||||
|
||||
# Check for Celsius
|
||||
if (celsius):
|
||||
temperature.units = celsius_unit
|
||||
|
||||
# load playerDB values
|
||||
for i in range(len(lemonadeTracker)):
|
||||
if lemonadeTracker[i]['nodeID'] == nodeID:
|
||||
inventory.cups = lemonadeTracker[i]['cups']
|
||||
inventory.lemons = lemonadeTracker[i]['lemons']
|
||||
inventory.sugar = lemonadeTracker[i]['sugar']
|
||||
inventory.cash = lemonadeTracker[i]['cash']
|
||||
inventory.start = lemonadeTracker[i]['start']
|
||||
lemonsLastCmd = lemonadeTracker[i]['cmd']
|
||||
for i in range(len(lemonadeCups)):
|
||||
if lemonadeCups[i]['nodeID'] == nodeID:
|
||||
cups.cost = lemonadeCups[i]['cost']
|
||||
cups.unit = lemonadeCups[i]['unit']
|
||||
for i in range(len(lemonadeLemons)):
|
||||
if lemonadeLemons[i]['nodeID'] == nodeID:
|
||||
lemons.cost = lemonadeLemons[i]['cost']
|
||||
lemons.unit = lemonadeLemons[i]['unit']
|
||||
for i in range(len(lemonadeSugar)):
|
||||
if lemonadeSugar[i]['nodeID'] == nodeID:
|
||||
sugar.cost = lemonadeSugar[i]['cost']
|
||||
sugar.unit = lemonadeSugar[i]['unit']
|
||||
for i in range(len(lemonadeWeeks)):
|
||||
if lemonadeWeeks[i]['nodeID'] == nodeID:
|
||||
weeks.current = lemonadeWeeks[i]['current']
|
||||
weeks.total = lemonadeWeeks[i]['total']
|
||||
weeks.sales = lemonadeWeeks[i]['sales']
|
||||
potential = lemonadeWeeks[i]['potential']
|
||||
unit = lemonadeWeeks[i]['unit']
|
||||
price = lemonadeWeeks[i]['price']
|
||||
weeks.total_sales = lemonadeWeeks[i]['total_sales']
|
||||
for i in range(len(lemonadeScore)):
|
||||
if lemonadeScore[i]['nodeID'] == nodeID:
|
||||
score.value = lemonadeScore[i]['value']
|
||||
score.total = lemonadeScore[i]['total']
|
||||
if (newgame):
|
||||
# reset the game values
|
||||
inventory.cups = 0
|
||||
inventory.lemons = 0
|
||||
inventory.sugar = 0
|
||||
inventory.cash = lemon_starting_cash
|
||||
inventory.start = lemon_starting_cash
|
||||
cups.cost = 2.50
|
||||
cups.unit = round(cups.cost / cups.count, 2)
|
||||
lemons.cost = 4.00
|
||||
lemons.unit = round(lemons.cost / lemons.count, 2)
|
||||
sugar.cost = 3.00
|
||||
sugar.unit = round(sugar.cost / sugar.count, 2)
|
||||
weeks.current = 1
|
||||
weeks.total_sales = 0
|
||||
weeks.summary = []
|
||||
score.value = 0.00
|
||||
score.total = 0.00
|
||||
lemonsLastCmd = "cups"
|
||||
# set the last command to new in the inventory db
|
||||
for i in range(len(lemonadeTracker)):
|
||||
if lemonadeTracker[i]['nodeID'] == nodeID:
|
||||
lemonadeTracker[i]['cmd'] = "cups"
|
||||
lemonadeTracker[i]['last_played'] = time.time()
|
||||
saveValues(nodeID, inventory, cups, lemons, sugar, weeks, score)
|
||||
# Start the main loop
|
||||
if (weeks.current <= weeks.total):
|
||||
if newgame or "new" in lemonsLastCmd:
|
||||
logger.debug("System: Lemonade: New Game: " + str(nodeID))
|
||||
# Create a new display buffer for the text messages
|
||||
buffer= ""
|
||||
|
||||
# the current week number
|
||||
buffer += title + "Week #" + str(weeks.current) + "of" + str(weeks.total)
|
||||
|
||||
# Generate a random weather forecast and temperature and display
|
||||
temperature.forecast = randrange(0, len(forecastd))
|
||||
temperature.value = randrange(temperature.min, temperature.max)
|
||||
formatted = str(temperature.value)
|
||||
if (temperature.units == celsius_unit):
|
||||
formatted = str(round(((temperature.value - 32) * (5/9))))
|
||||
glyph = chr(forecastd[list(forecastd)[temperature.forecast]][1])
|
||||
buffer += ". " + \
|
||||
formatted + temperature.units + " " + \
|
||||
forecastd[list(forecastd)[temperature.forecast]][2] + \
|
||||
" " + glyph + f"\n"
|
||||
|
||||
# Calculate the potential sales as a percentage of the maximum value
|
||||
# (lower temperature = fewer sales, severe weather = fewer sales)
|
||||
forecast = forecastd[list(forecastd)[temperature.forecast]][0]
|
||||
potential = math.floor(weeks.sales * \
|
||||
(temperature.value / 100) * \
|
||||
forecast)
|
||||
|
||||
# Update the cups cost
|
||||
cups.cost = cups.cost + round(uniform(-1.50, 1.50), 2)
|
||||
if (cups.cost < cups.min):
|
||||
cups.cost = cups.min
|
||||
cups.unit = round(cups.cost / cups.count, 2)
|
||||
|
||||
# Update the lemons cost
|
||||
lemons.cost = lemons.cost + round(uniform(-1.50, 1.50), 2)
|
||||
if (lemons.cost < lemons.min):
|
||||
lemons.cost = lemons.min
|
||||
lemons.unit = round(lemons.cost / lemons.count, 2)
|
||||
|
||||
# Update the sugar cost
|
||||
sugar.cost = sugar.cost + round(uniform(-1.50, 1.50), 2)
|
||||
if (sugar.cost < sugar.min):
|
||||
sugar.cost = sugar.min
|
||||
sugar.unit = round(sugar.cost / sugar.count, 2)
|
||||
|
||||
# Calculate the unit cost and display the estimated sales from the forecast potential
|
||||
unit = max(0.01, min(cups.unit + lemons.unit + sugar.unit, 4.0)) # limit the unit cost between $0.01 and $4.00
|
||||
buffer += f"\nSupplyCost" + locale.currency(round(unit, 2), grouping=True) + " a cup."
|
||||
buffer += f"\nSales Potential:" + str(potential) + " cups."
|
||||
|
||||
# Display the current inventory
|
||||
buffer += f"\nInventory:"
|
||||
buffer += "🥤:" + str(inventory.cups)
|
||||
buffer += "🍋:" + str(inventory.lemons)
|
||||
buffer += "🍚:" + str(inventory.sugar)
|
||||
|
||||
# Display the updated item prices
|
||||
buffer += f"\nPrices:\n"
|
||||
buffer += f"\n🥤:" + locale.currency(round(cups.cost, 2), grouping=True) + " 📦 of " + str(cups.count) + "."
|
||||
buffer += f"\n🍋:" + locale.currency(round(lemons.cost, 2), grouping=True) + " 🧺 of " + str(lemons.count) + "."
|
||||
buffer += f"\n🍚:" + locale.currency(round(sugar.cost, 2), grouping=True) + " bag for " + str(sugar.count) + "🥤."
|
||||
# Display the current cash
|
||||
gainloss = inventory.cash - inventory.start
|
||||
buffer += f"\n💵:" + locale.currency(round(inventory.cash, 2), grouping=True)
|
||||
|
||||
|
||||
# if the player is in the red
|
||||
pnl = locale.currency(round(gainloss, 2), grouping=True)
|
||||
if "0.00" not in pnl:
|
||||
if pnl.startswith("-"):
|
||||
buffer += "📊P&L📉" + pnl
|
||||
else:
|
||||
buffer += "📊P&L📈" + pnl
|
||||
|
||||
buffer += f"\n🥤 to buy?\nHave {inventory.cups} Cost {locale.currency(cups.cost, grouping=True)} a 📦 of {str(cups.count)}"
|
||||
saveValues(nodeID, inventory, cups, lemons, sugar, weeks, score)
|
||||
return buffer
|
||||
|
||||
if "cups" in lemonsLastCmd and not newgame:
|
||||
# Read the number of cup boxes to purchase
|
||||
newcups = -1
|
||||
if "n" in message.lower():
|
||||
message = "0"
|
||||
try:
|
||||
newcups = int(message)
|
||||
if (newcups > 0):
|
||||
cost = round(newcups * cups.cost, 2)
|
||||
if (cost > inventory.cash):
|
||||
return "You do not have enough 💵."
|
||||
inventory.cups += (newcups * cups.count)
|
||||
inventory.cash -= cost
|
||||
msg = "Purchased " + str(newcups) + " 📦 "
|
||||
msg += str(inventory.cups) + " 🥤 in inventory. " + locale.currency(round(inventory.cash, 2), grouping=True) + f" remaining"
|
||||
else:
|
||||
msg = "No 🥤 were purchased"
|
||||
except Exception as e:
|
||||
return "invalid input, enter the number of 🥤 to purchase or (N)one"
|
||||
|
||||
msg += f"\n 🍋 to buy?\nHave {inventory.lemons}🥤 of 🍋 Cost {locale.currency(lemons.cost, grouping=True)} a 🧺 for {str(lemons.count)}🥤"
|
||||
# set the last command to lemons in the inventory db
|
||||
for i in range(len(lemonadeTracker)):
|
||||
if lemonadeTracker[i]['nodeID'] == nodeID:
|
||||
lemonadeTracker[i]['cmd'] = "lemons"
|
||||
saveValues(nodeID, inventory, cups, lemons, sugar, weeks, score)
|
||||
return msg
|
||||
|
||||
|
||||
if "lemons" in lemonsLastCmd and not newgame:
|
||||
# Read the number of lemon bags to purchase
|
||||
newlemons = -1
|
||||
if "n" in message.lower():
|
||||
message = "0"
|
||||
try:
|
||||
newlemons = int(message)
|
||||
if (newlemons > 0):
|
||||
cost = round(newlemons * lemons.cost, 2)
|
||||
if (cost > inventory.cash):
|
||||
return "You do not have enough cash."
|
||||
inventory.lemons += (newlemons * lemons.count)
|
||||
inventory.cash -= cost
|
||||
msg = "Purchased " + str(newlemons) + " 🧺 "
|
||||
msg += str(inventory.lemons) + " 🍋 in inventory. " + locale.currency(inventory.cash, grouping=True) + f" remaining"
|
||||
else:
|
||||
msg = "No 🍋 were purchased"
|
||||
except Exception as e:
|
||||
newlemons = -1
|
||||
return "⛔️invalid input, enter the number of 🍋 to purchase"
|
||||
|
||||
msg += f"\n 🍚 to buy?\nYou have {inventory.sugar}🥤 of 🍚, Cost {locale.currency(sugar.cost, grouping=True)} a bag for {str(sugar.count)}🥤"
|
||||
# set the last command to sugar in the inventory db
|
||||
for i in range(len(lemonadeTracker)):
|
||||
if lemonadeTracker[i]['nodeID'] == nodeID:
|
||||
lemonadeTracker[i]['cmd'] = "sugar"
|
||||
saveValues(nodeID, inventory, cups, lemons, sugar, weeks, score)
|
||||
return msg
|
||||
|
||||
if "sugar" in lemonsLastCmd and not newgame:
|
||||
# Read the number of sugar bags to purchase
|
||||
newsugar = -1
|
||||
if "n" in message.lower():
|
||||
message = "0"
|
||||
try:
|
||||
newsugar = int(message)
|
||||
if (newsugar > 0):
|
||||
cost = round(newsugar * sugar.cost, 2)
|
||||
if (cost > inventory.cash):
|
||||
return "You do not have enough cash."
|
||||
inventory.sugar += (newsugar * sugar.count)
|
||||
inventory.cash -= cost
|
||||
msg = " Purchased " + str(newsugar) + " bag(s) of 🍚 for " + locale.currency(cost, grouping=True)
|
||||
msg += ". " + str(inventory.sugar) + f"🥤🍚 in inventory."
|
||||
else:
|
||||
msg = "No additional 🍚 was purchased"
|
||||
except Exception as e:
|
||||
return "⛔️invalid input, enter the number of 🍚 bags to purchase"
|
||||
|
||||
msg += f"Cost of goods is {locale.currency(round(unit, 2), grouping=True)}"
|
||||
msg += f"per 🥤 {locale.currency(round(inventory.cash, 2), grouping=True)} 💵 remaining."
|
||||
msg += f"\nPrice to Sell? or (G)rocery to buy more 🥤🍋🍚"
|
||||
|
||||
# set the last command to price in the inventory db
|
||||
for i in range(len(lemonadeTracker)):
|
||||
if lemonadeTracker[i]['nodeID'] == nodeID:
|
||||
lemonadeTracker[i]['cmd'] = "price"
|
||||
saveValues(nodeID, inventory, cups, lemons, sugar, weeks, score)
|
||||
return msg
|
||||
|
||||
if "price" in lemonsLastCmd and not newgame:
|
||||
# set the last command to sales in the inventory db
|
||||
for i in range(len(lemonadeTracker)):
|
||||
if lemonadeTracker[i]['nodeID'] == nodeID:
|
||||
lemonadeTracker[i]['cmd'] = "sales"
|
||||
if "g" in message.lower():
|
||||
lemonadeTracker[i]['cmd'] = "cups"
|
||||
msg = f"#of🥤\nto buy? Have {inventory.cups} Cost {locale.currency(cups.cost, grouping=True)} a 📦 of {str(cups.count)}"
|
||||
return msg
|
||||
else:
|
||||
lemonsLastCmd = "sales"
|
||||
|
||||
# Read the actual price
|
||||
price = 0.00
|
||||
while (price <= 0.00):
|
||||
try:
|
||||
raw = message
|
||||
price = float(re.sub("[^0-9.-]", "", raw) or 0.00)
|
||||
if (price <= 0.00):
|
||||
return "The price must be greater than zero."
|
||||
except Exception as e:
|
||||
price = 0.00
|
||||
lemonsLastCmd = "price"
|
||||
return "⛔️Invalid input, enter the price of the lemonade per 🥤"
|
||||
|
||||
# this isnt sent to the user, not needed
|
||||
#msg = " Setting the price at " + locale.currency(price, grouping=True)
|
||||
saveValues(nodeID, inventory, cups, lemons, sugar, weeks, score)
|
||||
|
||||
|
||||
if "sales" in lemonsLastCmd and not newgame:
|
||||
# Calculate the weekly sales based on price and lowest inventory level
|
||||
# (higher markup price = fewer sales, limited by the inventory on-hand)
|
||||
sales = get_sales_amount(potential, unit, price)
|
||||
sales = min(potential, sales, \
|
||||
inventory.cups, inventory.lemons, \
|
||||
inventory.sugar) # "min" returns lowest value
|
||||
margin = price - unit
|
||||
gross = sales * price
|
||||
net = sales * margin
|
||||
|
||||
# Add a new row to the summary
|
||||
weeks.summary.append({ 'sales' : sales, 'price' : price })
|
||||
|
||||
# Update the inventory levels to reflect consumption
|
||||
inventory.cups = inventory.cups - sales
|
||||
inventory.lemons = inventory.lemons - sales
|
||||
inventory.sugar = inventory.sugar - sales
|
||||
inventory.cash = inventory.cash + gross
|
||||
gainloss= inventory.cash - inventory.start
|
||||
|
||||
# Display the calculated sales information
|
||||
msg = "Results Week📊#" + str(weeks.current) + "of" + str(weeks.total)
|
||||
msg += " Cost/Price:" + locale.currency(unit, grouping=True) + "/" + locale.currency(price, grouping=True)
|
||||
msg += " P.Margin:" + locale.currency(margin, grouping=True)
|
||||
msg += " T.Sales:" + str(sales) + "@" + locale.currency(price, grouping=True)
|
||||
msg += " G.Profit: " + locale.currency(gross, grouping=True)
|
||||
msg += " N.Profit:" + locale.currency(net, grouping=True)
|
||||
|
||||
# Display the updated inventory levels
|
||||
msg += f"\nRemaining"
|
||||
msg += " 🥤:" + str(inventory.cups)
|
||||
msg += " 🍋:" + str(inventory.lemons)
|
||||
msg += " 🍚:" + str(inventory.sugar)
|
||||
msg += " 💵:" + locale.currency(inventory.cash, grouping=True)
|
||||
# Display the gain/loss
|
||||
pnl = locale.currency(gainloss, grouping=True)
|
||||
if "0.00" not in pnl:
|
||||
if pnl.startswith("-"):
|
||||
msg += "📊P&L📉" + pnl
|
||||
else:
|
||||
msg += "📊P&L📈" + pnl
|
||||
|
||||
# Display the weekly sales summary
|
||||
pad_week = len(str(weeks.total))
|
||||
pad_sale = len(str(weeks.sales))
|
||||
total = 0
|
||||
msg += f"\nWeekly📊"
|
||||
for i in range(len(weeks.summary)):
|
||||
msg += "#" + str(weeks.current).rjust(pad_week) + ". " + str(weeks.summary[i]['sales']).rjust(pad_sale) + \
|
||||
" sold x " + locale.currency(weeks.summary[i]['price'], grouping=True) + "ea. "
|
||||
total = total + weeks.summary[i]['sales']
|
||||
|
||||
# Update the total sales for the game
|
||||
weeks.total_sales += total
|
||||
|
||||
# Loop through a range of prices to find the highest net profit
|
||||
maxsales = 0
|
||||
maxprice = 0.00
|
||||
maxgross = 0.00
|
||||
maxnet = 0.00
|
||||
minnet = net
|
||||
for i in range(25, 2500, 25):
|
||||
price = i / 100 # range uses integers, not currency (floats)
|
||||
sales = get_sales_amount(potential, unit, price)
|
||||
margin = price - unit
|
||||
gross = sales * price
|
||||
net = sales * margin
|
||||
if (sales > 0) and \
|
||||
(sales <= potential) and \
|
||||
(unit <= price):
|
||||
if (net > maxnet):
|
||||
maxsales = sales
|
||||
maxprice = price
|
||||
maxgross = gross
|
||||
maxnet = net
|
||||
if (maxnet > minnet):
|
||||
msg += "Sales could have been:"
|
||||
msg += " " + str(maxsales) + " sold x " + locale.currency(maxprice, grouping=True) + "ea. @" + \
|
||||
locale.currency(maxgross, grouping=True) + " for a net profit of " + locale.currency(maxnet, grouping=True)
|
||||
if (inventory.cups <= 0):
|
||||
msg += " You ran out of cups.🥤"
|
||||
if (inventory.lemons <= 0):
|
||||
msg += " You ran out of lemons.🍋"
|
||||
if (inventory.sugar <= 0):
|
||||
msg += " You ran out of sugar.🍚"
|
||||
else:
|
||||
msg += f"\nCongratulations 🍋🍋 your sales were perfect!🎉"
|
||||
|
||||
# Increment the score counters
|
||||
score.value = score.value + minnet
|
||||
score.total = score.total + maxnet
|
||||
|
||||
|
||||
# Increment the week number
|
||||
if (weeks.current == weeks.total):
|
||||
# end of the game
|
||||
success = round((score.value / score.total) * 100)
|
||||
msg += f"\nYou've made " + locale.currency(score.value, grouping=True) + " out of a possible " + \
|
||||
locale.currency(score.total, grouping=True) + " for a score of " + str(success) + "% "
|
||||
msg += f"\nYou've sold " + str(weeks.total_sales) + " total 🥤🍋"
|
||||
|
||||
# check for high score
|
||||
high_score = getHighScoreLemon()
|
||||
if (inventory.cash > int(high_score['cash'])):
|
||||
msg += f"\nCongratulations! You've set a new high score!🎉💰🍋"
|
||||
high_score['cash'] = inventory.cash
|
||||
high_score['success'] = success
|
||||
high_score['userID'] = nodeID
|
||||
with open('data/lemonstand.pkl', 'wb') as file:
|
||||
pickle.dump(high_score, file)
|
||||
|
||||
else:
|
||||
# keep playing
|
||||
|
||||
weeks.current = weeks.current + 1
|
||||
|
||||
msg += f"\nPlay another week🥤? or (E)nd Game"
|
||||
# set the last command to new in the inventory db
|
||||
for i in range(len(lemonadeTracker)):
|
||||
if lemonadeTracker[i]['nodeID'] == nodeID:
|
||||
lemonadeTracker[i]['cmd'] = "new"
|
||||
lemonadeTracker[i]['last_played'] = time.time()
|
||||
saveValues(nodeID, inventory, cups, lemons, sugar, weeks, score)
|
||||
return msg
|
||||
else:
|
||||
return "Game Over! Start a (N)ew Game or (E)xit"
|
||||
255
modules/games/meshtrekker.py
Normal file
255
modules/games/meshtrekker.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Mesh Trekker Game
|
||||
Game Rules:
|
||||
1. Players compete to cover the most distance over time using their Meshtastic devices.
|
||||
2. The game tracks players' movements via GPS coordinates sent by their devices.
|
||||
3. Total distance traveled is calculated and summed over time for each player.
|
||||
4. Leaderboards show top distances for daily, weekly, and all-time periods.
|
||||
5. Players can form teams, with team distances being the sum of all team members' distances.
|
||||
6. Special achievements are awarded for milestones (e.g., 10km, 50km, 100km total distance).
|
||||
7. The game runs continuously, allowing players to participate at their own pace.
|
||||
8. Players can use the 'whereami' command to check their current location and update their position in the game.
|
||||
"""
|
||||
|
||||
import pickle
|
||||
from modules.log import logger, getPrettyTime
|
||||
from datetime import datetime, timedelta
|
||||
from geopy.distance import geodesic
|
||||
|
||||
class MeshTrekkerError(Exception):
|
||||
"""Base class for exceptions in this module."""
|
||||
pass
|
||||
|
||||
class DataLoadError(MeshTrekkerError):
|
||||
"""Raised when there's an error loading data."""
|
||||
pass
|
||||
|
||||
class DataSaveError(MeshTrekkerError):
|
||||
"""Raised when there's an error saving data."""
|
||||
pass
|
||||
|
||||
class InvalidGPSDataError(MeshTrekkerError):
|
||||
"""Raised when invalid GPS data is provided."""
|
||||
pass
|
||||
|
||||
class MeshTrekker:
|
||||
def __init__(self, data_file='mesh_trekker_data.pkl'):
|
||||
self.data_file = data_file
|
||||
try:
|
||||
self.data = self.load_data()
|
||||
except DataLoadError as e:
|
||||
logger.error(f"Failed to load data: {e}")
|
||||
self.data = self.initialize_data()
|
||||
|
||||
def initialize_data(self):
|
||||
return {
|
||||
'gps_data': {},
|
||||
'user_distances': {},
|
||||
'teams': {},
|
||||
'achievements': {},
|
||||
}
|
||||
|
||||
def load_data(self):
|
||||
try:
|
||||
with open(self.data_file, 'rb') as f:
|
||||
return pickle.load(f)
|
||||
except (pickle.PickleError, EOFError, FileNotFoundError) as e:
|
||||
logger.info(f"Data file {self.data_file} not found. Initializing new data.")
|
||||
return self.initialize_data()
|
||||
|
||||
def save_data(self):
|
||||
try:
|
||||
with open(self.data_file, 'wb') as f:
|
||||
pickle.dump(self.data, f)
|
||||
except (pickle.PickleError, IOError) as e:
|
||||
raise DataSaveError(f"Error saving data: {e}")
|
||||
|
||||
def validate_gps_data(self, latitude, longitude, timestamp):
|
||||
try:
|
||||
lat = float(latitude)
|
||||
lon = float(longitude)
|
||||
if not -90 <= lat <= 90:
|
||||
raise InvalidGPSDataError(f"Invalid latitude: {latitude}")
|
||||
if not -180 <= lon <= 180:
|
||||
raise InvalidGPSDataError(f"Invalid longitude: {longitude}")
|
||||
if not isinstance(timestamp, datetime):
|
||||
raise InvalidGPSDataError(f"Invalid timestamp: {timestamp}")
|
||||
except ValueError:
|
||||
raise InvalidGPSDataError(f"Invalid GPS data: latitude={latitude}, longitude={longitude}")
|
||||
|
||||
def process_gps_data(self, user_id, latitude, longitude, timestamp):
|
||||
try:
|
||||
self.validate_gps_data(latitude, longitude, timestamp)
|
||||
|
||||
if user_id not in self.data['gps_data']:
|
||||
self.data['gps_data'][user_id] = []
|
||||
|
||||
self.data['gps_data'][user_id].append((float(latitude), float(longitude), timestamp))
|
||||
|
||||
if len(self.data['gps_data'][user_id]) > 1:
|
||||
last_lat, last_lon, last_time = self.data['gps_data'][user_id][-2]
|
||||
last_point = (last_lat, last_lon)
|
||||
new_point = (float(latitude), float(longitude))
|
||||
|
||||
distance = geodesic(last_point, new_point).kilometers
|
||||
|
||||
if user_id not in self.data['user_distances']:
|
||||
self.data['user_distances'][user_id] = (0, timestamp)
|
||||
|
||||
total_distance, _ = self.data['user_distances'][user_id]
|
||||
new_total_distance = total_distance + distance
|
||||
self.data['user_distances'][user_id] = (new_total_distance, timestamp)
|
||||
|
||||
self.check_achievements(user_id, new_total_distance)
|
||||
|
||||
self.save_data()
|
||||
return new_total_distance
|
||||
except InvalidGPSDataError as e:
|
||||
logger.error(f"Invalid GPS data for user {user_id}: {e}")
|
||||
except DataSaveError as e:
|
||||
logger.error(f"Failed to save data after processing GPS for user {user_id}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error processing GPS data for user {user_id}: {e}")
|
||||
return None
|
||||
|
||||
def get_leaderboard(self, timeframe='all'):
|
||||
try:
|
||||
now = datetime.now()
|
||||
if timeframe == 'daily':
|
||||
start_time = now - timedelta(days=1)
|
||||
elif timeframe == 'weekly':
|
||||
start_time = now - timedelta(weeks=1)
|
||||
else:
|
||||
start_time = datetime.min
|
||||
|
||||
leaderboard = []
|
||||
for user_id, (distance, last_updated) in self.data['user_distances'].items():
|
||||
if last_updated > start_time:
|
||||
leaderboard.append((user_id, distance))
|
||||
|
||||
return sorted(leaderboard, key=lambda x: x[1], reverse=True)[:10]
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating leaderboard: {e}")
|
||||
return []
|
||||
|
||||
def get_team_leaderboard(self):
|
||||
try:
|
||||
team_distances = {}
|
||||
for team_name, members in self.data['teams'].items():
|
||||
team_distance = sum(self.data['user_distances'].get(member, (0, None))[0] for member in members)
|
||||
team_distances[team_name] = team_distance
|
||||
|
||||
return sorted(team_distances.items(), key=lambda x: x[1], reverse=True)[:10]
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating team leaderboard: {e}")
|
||||
return []
|
||||
|
||||
def get_user_stats(self, user_id):
|
||||
try:
|
||||
distance, last_updated = self.data['user_distances'].get(user_id, (0, None))
|
||||
achievements = self.data['achievements'].get(user_id, [])
|
||||
return {
|
||||
'distance': distance,
|
||||
'last_updated': last_updated,
|
||||
'achievements': achievements
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving stats for user {user_id}: {e}")
|
||||
return None
|
||||
|
||||
def create_team(self, team_name, user_id):
|
||||
try:
|
||||
if team_name not in self.data['teams']:
|
||||
self.data['teams'][team_name] = [user_id]
|
||||
self.save_data()
|
||||
return True
|
||||
return False
|
||||
except DataSaveError as e:
|
||||
logger.error(f"Failed to save data after creating team {team_name}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating team {team_name}: {e}")
|
||||
return False
|
||||
|
||||
def join_team(self, team_name, user_id):
|
||||
try:
|
||||
if team_name in self.data['teams'] and user_id not in self.data['teams'][team_name]:
|
||||
self.data['teams'][team_name].append(user_id)
|
||||
self.save_data()
|
||||
return True
|
||||
return False
|
||||
except DataSaveError as e:
|
||||
logger.error(f"Failed to save data after user {user_id} joined team {team_name}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error joining team {team_name} for user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def check_achievements(self, user_id, total_distance):
|
||||
try:
|
||||
if user_id not in self.data['achievements']:
|
||||
self.data['achievements'][user_id] = []
|
||||
|
||||
milestones = [10, 50, 100, 500, 1000] # in km
|
||||
new_achievements = []
|
||||
for milestone in milestones:
|
||||
if total_distance >= milestone and milestone not in self.data['achievements'][user_id]:
|
||||
self.data['achievements'][user_id].append(milestone)
|
||||
new_achievements.append(milestone)
|
||||
logger.info(f"User {user_id} achieved {milestone}km milestone!")
|
||||
return new_achievements
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking achievements for user {user_id}: {e}")
|
||||
return []
|
||||
|
||||
def get_achievements(self, user_id):
|
||||
try:
|
||||
return self.data['achievements'].get(user_id, [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving achievements for user {user_id}: {e}")
|
||||
return []
|
||||
|
||||
# Initialize the game
|
||||
game = MeshTrekker()
|
||||
|
||||
def handle_meshtrekker(user_id, deviceID, channel_number, location_info=(0,0)):
|
||||
# Process GPS data from Meshtastic devices
|
||||
latitude, longitude = location_info.split(": ")[1].split(", ")
|
||||
|
||||
current_time = datetime.now()
|
||||
new_distance = game.process_gps_data(user_id, latitude, longitude, current_time)
|
||||
|
||||
|
||||
# # Create and join teams
|
||||
# game.create_team("Team A", "user1")
|
||||
# game.join_team("Team A", "user2")
|
||||
|
||||
# # Get individual leaderboard
|
||||
# print("\nAll-time individual leaderboard:")
|
||||
# for user, distance in game.get_leaderboard():
|
||||
# print(f"{user}: {distance:.2f} km")
|
||||
|
||||
# # Get team leaderboard
|
||||
# print("\nTeam leaderboard:")
|
||||
# for team, distance in game.get_team_leaderboard():
|
||||
# print(f"{team}: {distance:.2f} km")
|
||||
|
||||
# # Get user stats
|
||||
# user_stats = game.get_user_stats("user1")
|
||||
# print(f"\nUser1 stats: {user_stats}")
|
||||
|
||||
# # Get achievements
|
||||
# achievements = game.get_achievements("user1")
|
||||
# print(f"User1 achievements: {achievements}")
|
||||
|
||||
|
||||
if new_distance is not None:
|
||||
new_achievements = game.check_achievements(user_id, new_distance)
|
||||
response = f"{location_info}\nTotal distance: {new_distance:.2f} km"
|
||||
if new_achievements:
|
||||
response += f"\nNew achievements: {', '.join([f'{a}km' for a in new_achievements])}"
|
||||
else:
|
||||
response = f"{location_info}\nFailed to update distance. Please try again."
|
||||
|
||||
return response
|
||||
|
||||
310
modules/games/mmind.py
Normal file
310
modules/games/mmind.py
Normal file
@@ -0,0 +1,310 @@
|
||||
# https://github.com/pwdkramer/pythonMastermind/blob/main/main.py
|
||||
# Adapted for Meshtastic mesh-bot by K7MHI Kelly Keeton 2024
|
||||
|
||||
import random
|
||||
import time
|
||||
import pickle
|
||||
from modules.log import logger
|
||||
from modules.system import mindTracker
|
||||
|
||||
def chooseDifficultyMMind(message):
|
||||
usrInput = message.lower()
|
||||
msg = ''
|
||||
valid_colorsMMind = "RYGB"
|
||||
|
||||
if not usrInput.startswith("n") and not usrInput.startswith("h") and not usrInput.startswith("x"):
|
||||
# default to normal difficulty
|
||||
usrInput = "n"
|
||||
|
||||
if usrInput == "n":
|
||||
msg += f"The colors to choose from are:\nR🔴, Y🟡, G🟢, B🔵"
|
||||
elif usrInput == "h":
|
||||
valid_colorsMMind += "OP"
|
||||
msg += f"The colors to choose from are\nR🔴, Y🟡, G🟢, B🔵, O🟠, P🟣"
|
||||
elif usrInput == "x":
|
||||
valid_colorsMMind += "OPWK"
|
||||
msg += f"The colors to choose from are\nR🔴, Y🟡, G🟢, B🔵, O🟠, P🟣, W⚪, K⚫"
|
||||
return msg
|
||||
|
||||
|
||||
#possible colors on nomral: Red, Yellow, Green, Blue
|
||||
#added colors on hard: Orange, Purple
|
||||
def makeCodeMMind(diff):
|
||||
secret_code = ""
|
||||
for i in range(4):
|
||||
if diff == "n":
|
||||
roll = random.randrange(1, 5)
|
||||
elif diff == "h":
|
||||
roll = random.randrange(1,7)
|
||||
elif diff == "x":
|
||||
roll = random.randrange(1,9)
|
||||
else:
|
||||
print("Difficulty error in makeCode()")
|
||||
if roll == 1:
|
||||
secret_code += "R"
|
||||
elif roll == 2:
|
||||
secret_code += "Y"
|
||||
elif roll == 3:
|
||||
secret_code += "G"
|
||||
elif roll == 4:
|
||||
secret_code += "B"
|
||||
elif roll == 5:
|
||||
secret_code += "O"
|
||||
elif roll == 6:
|
||||
secret_code += "P"
|
||||
elif roll == 7:
|
||||
secret_code += "W"
|
||||
elif roll == 8:
|
||||
secret_code += "K"
|
||||
else:
|
||||
print("Error with range of roll in makeCode()")
|
||||
return secret_code
|
||||
|
||||
#get guess from user
|
||||
def getGuessMMind(diff, guess, nodeID):
|
||||
valid_colors = {
|
||||
"n": "RYGB",
|
||||
"h": "RYGBOP",
|
||||
"x": "RYGBOPWK"
|
||||
}
|
||||
user_guess = guess.strip().upper()
|
||||
if len(user_guess) != 4 or any(c not in valid_colors.get(diff, "RYGB") for c in user_guess):
|
||||
return "XXXX"
|
||||
|
||||
#increase the turn count and store in tracker
|
||||
for i in range(len(mindTracker)):
|
||||
if mindTracker[i]['nodeID'] == nodeID:
|
||||
mindTracker[i]['turns'] += 1
|
||||
mindTracker[i]['last_played'] = time.time()
|
||||
mindTracker[i]['diff'] = diff
|
||||
return user_guess
|
||||
|
||||
def getHighScoreMMind(nodeID, turns, diff):
|
||||
import os
|
||||
hs_file = 'data/mmind_hs.pkl'
|
||||
# Try to load existing high scores
|
||||
if os.path.exists(hs_file):
|
||||
try:
|
||||
with open(hs_file, 'rb') as f:
|
||||
mindHighScore = pickle.load(f)
|
||||
except Exception as e:
|
||||
logger.debug(f"System: MasterMind: Error loading high score file: {e}")
|
||||
mindHighScore = []
|
||||
else:
|
||||
mindHighScore = []
|
||||
|
||||
# If nodeID==0, just return 0
|
||||
if nodeID == 0:
|
||||
mindHighScore = [{'nodeID': 0, 'turns': 0, 'diff': 'n'}]
|
||||
return mindHighScore
|
||||
|
||||
# If no high score, add this one
|
||||
if not mindHighScore:
|
||||
mindHighScore = [{'nodeID': nodeID, 'turns': turns, 'diff': diff}]
|
||||
with open(hs_file, 'wb') as f:
|
||||
pickle.dump(mindHighScore, f)
|
||||
return mindHighScore
|
||||
|
||||
# If the diff matches, compare and update if better
|
||||
if mindHighScore[0]['diff'] == diff:
|
||||
if turns < mindHighScore[0]['turns']:
|
||||
mindHighScore[0] = {'nodeID': nodeID, 'turns': turns, 'diff': diff}
|
||||
with open(hs_file, 'wb') as f:
|
||||
pickle.dump(mindHighScore, f)
|
||||
return mindHighScore
|
||||
|
||||
# If the diff is different, replace with new high score for new diff
|
||||
mindHighScore[0] = {'nodeID': nodeID, 'turns': turns, 'diff': diff}
|
||||
with open(hs_file, 'wb') as f:
|
||||
pickle.dump(mindHighScore, f)
|
||||
return mindHighScore
|
||||
|
||||
|
||||
def getEmojiMMind(secret_code):
|
||||
# for each letter in the secret code, convert to emoji for display
|
||||
secret_code = secret_code.upper()
|
||||
secret_code_emoji = ""
|
||||
for i in range(len(secret_code)):
|
||||
if secret_code[i] == "R":
|
||||
secret_code_emoji += "🔴"
|
||||
elif secret_code[i] == "Y":
|
||||
secret_code_emoji += "🟡"
|
||||
elif secret_code[i] == "G":
|
||||
secret_code_emoji += "🟢"
|
||||
elif secret_code[i] == "B":
|
||||
secret_code_emoji += "🔵"
|
||||
elif secret_code[i] == "O":
|
||||
secret_code_emoji += "🟠"
|
||||
elif secret_code[i] == "P":
|
||||
secret_code_emoji += "🟣"
|
||||
elif secret_code[i] == "W":
|
||||
secret_code_emoji += "⚪"
|
||||
elif secret_code[i] == "K":
|
||||
secret_code_emoji += "⚫"
|
||||
elif secret_code[i] == "X":
|
||||
secret_code_emoji += "❌"
|
||||
return secret_code_emoji
|
||||
|
||||
#compare userGuess with secret code and provide feedback
|
||||
def compareCodeMMind(secret_code, user_guess, nodeID):
|
||||
game_won = False
|
||||
perfect_pins = 0
|
||||
wrong_position = 0
|
||||
msg = ''
|
||||
#logger.debug("System: MasterMind: secret_code: " + str(secret_code) + " user_guess: " + str(user_guess))
|
||||
if secret_code == user_guess: #correct guess, user wins
|
||||
perfect_pins = 4
|
||||
game_won = True
|
||||
else:
|
||||
# check for perfect pins and right color wrong position
|
||||
temp_code = []
|
||||
temp_guess = []
|
||||
# Check for perfect pins
|
||||
for i in range(len(user_guess)):
|
||||
if user_guess[i] == secret_code[i]:
|
||||
perfect_pins += 1
|
||||
else:
|
||||
temp_code.append(secret_code[i])
|
||||
temp_guess.append(user_guess[i])
|
||||
|
||||
# Check for right color wrong position
|
||||
for guess in temp_guess:
|
||||
if guess in temp_code:
|
||||
wrong_position += 1
|
||||
temp_code.remove(guess) # Remove the first occurrence of the matched color
|
||||
# display feedback
|
||||
if game_won:
|
||||
msg += f"\n🏆Correct{getEmojiMMind(user_guess)}\nYou are the master mind!🤯"
|
||||
# get turn count from tracker
|
||||
for i in range(len(mindTracker)):
|
||||
if mindTracker[i]['nodeID'] == nodeID:
|
||||
turns = mindTracker[i]['turns'] - 2 # subtract 2 to account for increment after last guess and starting at 1
|
||||
diff = mindTracker[i]['diff']
|
||||
# get high score
|
||||
high_score = getHighScoreMMind(nodeID, turns, diff)
|
||||
if high_score[0]['turns'] != 0:
|
||||
msg += f"\n🏆 High Score:{turns} turns, Difficulty:{diff}"
|
||||
# reset turn count in tracker
|
||||
msg += f"\nWould you like to play again? (N)ormal, (H)ard, or e(X)pert?"
|
||||
# reset turn count in tracker
|
||||
for i in range(len(mindTracker)):
|
||||
if mindTracker[i]['nodeID'] == nodeID:
|
||||
mindTracker[i]['turns'] = 0
|
||||
mindTracker[i]['secret_code'] = ''
|
||||
mindTracker[i]['cmd'] = 'new'
|
||||
else:
|
||||
msg += f"\nGuess{getEmojiMMind(user_guess)}\n"
|
||||
|
||||
if perfect_pins > 0 and game_won == False:
|
||||
msg += "✅ color ✅ position: {}".format(perfect_pins)
|
||||
|
||||
if wrong_position > 0:
|
||||
if "✅" in msg: msg += f"\n"
|
||||
msg += "✅ color 🚫 position: {}".format(wrong_position)
|
||||
|
||||
if "✅" not in msg and game_won == False:
|
||||
msg += "🚫No pins in your guess😿 are in the code!"
|
||||
|
||||
return msg
|
||||
|
||||
#game loop with turn counter
|
||||
def playGameMMind(diff, secret_code, turn_count, nodeID, message):
|
||||
msg = ''
|
||||
won = False
|
||||
if turn_count < 11:
|
||||
user_guess = getGuessMMind(diff, message, nodeID)
|
||||
if user_guess == "XXXX":
|
||||
msg += f"⛔️Invalid guess. Please enter 4 valid colors letters.\n🔴🟢🔵🔴 is RGBR"
|
||||
return msg
|
||||
check_guess = compareCodeMMind(secret_code, user_guess, nodeID)
|
||||
|
||||
# display turn count and feedback
|
||||
msg += "Turn {}:".format(turn_count)
|
||||
if check_guess.startswith("Correct"):
|
||||
won = True
|
||||
msg += check_guess
|
||||
|
||||
if won == True:
|
||||
msg += f"\n🎉🧠 you win 🥷🤯"
|
||||
else:
|
||||
# increment turn count and keep playing
|
||||
turn_count += 1
|
||||
# store turn count in tracker
|
||||
for i in range(len(mindTracker)):
|
||||
if mindTracker[i]['nodeID'] == nodeID:
|
||||
mindTracker[i]['turns'] = turn_count
|
||||
elif won == False:
|
||||
msg += f"🙉Game Over🙈\nThe code was: {getEmojiMMind(secret_code)}"
|
||||
msg += f"\nYou have run out of turns.😿"
|
||||
msg += f"\nWould you like to play again? (N)ormal, (H)ard, or e(X)pert?"
|
||||
# reset turn count in tracker
|
||||
for i in range(len(mindTracker)):
|
||||
if mindTracker[i]['nodeID'] == nodeID:
|
||||
mindTracker[i]['turns'] = 1
|
||||
mindTracker[i]['secret_code'] = ''
|
||||
mindTracker[i]['cmd'] = 'new'
|
||||
|
||||
return msg
|
||||
|
||||
def endGameMMind(nodeID):
|
||||
global mindTracker
|
||||
# remove player from tracker
|
||||
for i in range(len(mindTracker)):
|
||||
if mindTracker[i]['nodeID'] == nodeID:
|
||||
del mindTracker[i]
|
||||
logger.debug("System: MasterMind: Player removed: " + str(nodeID))
|
||||
break
|
||||
|
||||
#main game
|
||||
def start_mMind(nodeID, message):
|
||||
global mindTracker
|
||||
last_cmd = ""
|
||||
msg = ''
|
||||
|
||||
# get player's last command from tracker if not new player
|
||||
for i in range(len(mindTracker)):
|
||||
if mindTracker[i]['nodeID'] == nodeID:
|
||||
last_cmd = mindTracker[i]['cmd']
|
||||
|
||||
if last_cmd == "new":
|
||||
if message.lower().startswith("n") or message.lower().startswith("h") or message.lower().startswith("x"):
|
||||
diff = message.lower()[0]
|
||||
else:
|
||||
diff = "n"
|
||||
|
||||
# set player's last command to makeCode
|
||||
for i in range(len(mindTracker)):
|
||||
if mindTracker[i]['nodeID'] == nodeID:
|
||||
mindTracker[i]['cmd'] = 'makeCode'
|
||||
mindTracker[i]['diff'] = diff
|
||||
mindTracker[i]['turns'] = 1
|
||||
# Return color message to player
|
||||
msg += chooseDifficultyMMind(message.lower()[0])
|
||||
return msg
|
||||
|
||||
if last_cmd == "makeCode":
|
||||
# get difficulty from tracker
|
||||
for i in range(len(mindTracker)):
|
||||
if mindTracker[i]['nodeID'] == nodeID:
|
||||
diff = mindTracker[i]['diff']
|
||||
|
||||
secret_code = makeCodeMMind(diff)
|
||||
last_cmd = "playGame"
|
||||
# set player's last command to playGame
|
||||
for i in range(len(mindTracker)):
|
||||
if mindTracker[i]['nodeID'] == nodeID:
|
||||
mindTracker[i]['cmd'] = 'playGame'
|
||||
mindTracker[i]['secret_code'] = secret_code
|
||||
mindTracker[i]['last_played'] = time.time()
|
||||
|
||||
if last_cmd == "playGame":
|
||||
# get difficulty, secret code, and turn count from tracker
|
||||
for i in range(len(mindTracker)):
|
||||
if mindTracker[i]['nodeID'] == nodeID:
|
||||
diff = mindTracker[i]['diff']
|
||||
secret_code = mindTracker[i]['secret_code']
|
||||
turn_count = mindTracker[i]['turns']
|
||||
|
||||
msg += playGameMMind(diff, secret_code, turn_count, nodeID=nodeID, message=message)
|
||||
|
||||
return msg
|
||||
166
modules/games/quiz.py
Normal file
166
modules/games/quiz.py
Normal file
@@ -0,0 +1,166 @@
|
||||
# Quiz Module for meshbot 2025
|
||||
# Provides a quiz game function with multiple choice and free-text questions
|
||||
# Quizmaster can start/stop the quiz, players can join/leave, answer questions
|
||||
# Scores are tracked, first correct answer is noted, top 3 players announced at end
|
||||
# Questions are loaded from a JSON file in data/quiz_questions.json
|
||||
# Questions can be multiple choice (with answers array) or free-text (with answer string)
|
||||
# Players answer with "Q: <answer>" format, "Q: ?" for next question, locked to DM
|
||||
# unlike a normal game, players can join/leave anytime during the quiz but the QuizMaster needs to start or open game
|
||||
# Quizmaster can broadcast messages to all players
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
from modules.log import logger
|
||||
from modules.settings import bbs_admin_list
|
||||
|
||||
QUIZ_JSON = os.path.join(os.path.dirname(__file__), '../', '../', 'data', 'quiz_questions.json')
|
||||
QUIZMASTER_ID = bbs_admin_list
|
||||
|
||||
trap_list_quiz = ("quiz", "q:")
|
||||
help_text_quiz = "quiz",
|
||||
|
||||
class QuizGame:
|
||||
def __init__(self):
|
||||
self.quizmaster = QUIZMASTER_ID
|
||||
self.active = False
|
||||
self.players = {} # user_id: {'score': int, 'current_q': int, 'answered': set()}
|
||||
self.questions = [] # Loaded from JSON
|
||||
self.first_correct = {} # q_idx: user_id
|
||||
self.load_questions()
|
||||
|
||||
def start_game(self, quizmaster_id):
|
||||
if str(quizmaster_id) not in self.quizmaster:
|
||||
return "Only the quizmaster can start the quiz."
|
||||
if self.active:
|
||||
return "Quiz already running."
|
||||
self.active = True
|
||||
logger.debug(f"QuizMaster: {quizmaster_id} started a new quiz round.")
|
||||
self.players = {}
|
||||
self.first_correct = {} # Reset on new game
|
||||
self.load_questions()
|
||||
return "Quiz started! Players can now join."
|
||||
|
||||
def load_questions(self):
|
||||
try:
|
||||
with open(QUIZ_JSON, 'r') as f:
|
||||
self.questions = json.load(f)
|
||||
# Shuffle questions to ensure randomness each game
|
||||
#random.shuffle(self.questions)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load quiz questions: {e}")
|
||||
self.questions = []
|
||||
|
||||
def stop_game(self, quizmaster_id):
|
||||
if not self.active or str(quizmaster_id) not in self.quizmaster:
|
||||
return "Only the quizmaster can stop the quiz."
|
||||
return_msg = "Quiz stopped! Final scores:\n" + self.top_three()
|
||||
logger.debug(f"QuizMaster: {quizmaster_id} stopped the quiz.")
|
||||
self.active = False
|
||||
self.players = {}
|
||||
return return_msg
|
||||
|
||||
def join(self, user_id):
|
||||
if not self.active:
|
||||
return "No quiz running. Wait for the quizmaster to start."
|
||||
if user_id in self.players:
|
||||
return "You are already in the quiz."
|
||||
self.players[user_id] = {'score': 0, 'current_q': 0, 'answered': set()}
|
||||
reminder = f"Joined!\n'Q: <Answer>' 'Q: ?' for more.\n"
|
||||
logger.debug(f"QuizMaster: Player {user_id} joined the round.")
|
||||
return reminder + self.next_question(user_id)
|
||||
|
||||
def leave(self, user_id):
|
||||
if user_id in self.players:
|
||||
del self.players[user_id]
|
||||
logger.debug(f"QuizMaster: Player {user_id} left the round.")
|
||||
return "You left the quiz."
|
||||
return "You are not in the quiz."
|
||||
|
||||
def next_question(self, user_id):
|
||||
if user_id not in self.players:
|
||||
return "Join the quiz first."
|
||||
player = self.players[user_id]
|
||||
while player['current_q'] < len(self.questions) and player['current_q'] in player['answered']:
|
||||
player['current_q'] += 1
|
||||
if player['current_q'] >= len(self.questions):
|
||||
return f"No more questions. Your final score: {player['score']}."
|
||||
q = self.questions[player['current_q']]
|
||||
msg = f"Q{player['current_q']+1}: {q['question']}\n"
|
||||
if "answers" in q:
|
||||
for i, opt in enumerate(q['answers']):
|
||||
msg += f"{chr(65+i)}. {opt}\n"
|
||||
msg = msg.strip()
|
||||
return msg
|
||||
|
||||
def answer(self, user_id, answer):
|
||||
if user_id not in self.players:
|
||||
return "Join the quiz first."
|
||||
player = self.players[user_id]
|
||||
q_idx = player['current_q']
|
||||
if q_idx >= len(self.questions):
|
||||
return "No more questions."
|
||||
if q_idx in player['answered']:
|
||||
return "Already answered. Type 'next' for another question."
|
||||
q = self.questions[q_idx]
|
||||
# Check if it's multiple choice or free-text
|
||||
if "answers" in q and "correct" in q:
|
||||
try:
|
||||
ans_idx = ord(answer.upper()) - 65
|
||||
if ans_idx == q['correct']:
|
||||
player['score'] += 1
|
||||
# Track first correct answer
|
||||
if q_idx not in self.first_correct:
|
||||
self.first_correct[q_idx] = user_id
|
||||
logger.info(f"QuizMaster: Question {q_idx+1} first user with correct answer by {user_id}")
|
||||
result = "Correct! 🎉"
|
||||
else:
|
||||
result = f"Wrong. Correct answer: {chr(65+q['correct'])}"
|
||||
player['answered'].add(q_idx)
|
||||
player['current_q'] += 1
|
||||
return f"{result}\n" + self.next_question(user_id)
|
||||
except Exception:
|
||||
return "Invalid answer. Use A, B, C, etc."
|
||||
elif "answer" in q:
|
||||
user_ans = answer.strip().lower()
|
||||
correct_ans = str(q['answer']).strip().lower()
|
||||
if user_ans == correct_ans:
|
||||
player['score'] += 1
|
||||
if q_idx not in self.first_correct:
|
||||
self.first_correct[q_idx] = user_id
|
||||
logger.info(f"QuizMaster: Question {q_idx+1} first user with correct answer by {user_id}")
|
||||
result = "Correct! 🎉"
|
||||
else:
|
||||
result = f"Wrong. Correct answer: {q['answer']}"
|
||||
player['answered'].add(q_idx)
|
||||
player['current_q'] += 1
|
||||
return f"{result}\n" + self.next_question(user_id)
|
||||
else:
|
||||
return "Invalid question format."
|
||||
|
||||
def top_three(self):
|
||||
if not self.players:
|
||||
return "No players in the quiz."
|
||||
ranking = sorted(self.players.items(), key=lambda x: x[1]['score'], reverse=True)
|
||||
count = min(3, len(ranking))
|
||||
msg = f"🏆 Top {count} Player{'s' if count > 1 else ''}:\n"
|
||||
for idx, (uid, pdata) in enumerate(iterable=ranking[:count], start=1):
|
||||
msg += f"{idx}. {uid}: @{pdata['score']}\n"
|
||||
return msg
|
||||
|
||||
def broadcast(self, quizmaster_id, message):
|
||||
msgToAll = {}
|
||||
if quizmaster_id and str(quizmaster_id) not in self.quizmaster:
|
||||
return "Only the quizmaster can broadcast."
|
||||
if not self.players:
|
||||
return "No players to broadcast to."
|
||||
# set up message
|
||||
message_to_send = f"📢 From Quizmaster: {message}"
|
||||
msgToAll['message'] = message_to_send
|
||||
# setup players
|
||||
for uid in self.players.keys():
|
||||
msgToAll.setdefault('players', []).append(uid)
|
||||
return msgToAll
|
||||
|
||||
# Initialize the quiz game
|
||||
quizGamePlayer = QuizGame()
|
||||
301
modules/games/tictactoe.py
Normal file
301
modules/games/tictactoe.py
Normal file
@@ -0,0 +1,301 @@
|
||||
# Tic-Tac-Toe game for Meshtastic mesh-bot
|
||||
# Board positions chosen by numbers 1-9
|
||||
# 2025
|
||||
import random
|
||||
import time
|
||||
import modules.settings as my_settings
|
||||
from modules.settings import tictactoeTracker
|
||||
|
||||
useSynchCompression = True
|
||||
if useSynchCompression:
|
||||
import zlib
|
||||
|
||||
# to (max), molly and jake, I miss you both so much.
|
||||
|
||||
class TicTacToe:
|
||||
def __init__(self, display_module):
|
||||
if getattr(my_settings, "disable_emojis_in_games", False):
|
||||
self.X = "X"
|
||||
self.O = "O"
|
||||
self.digit_emojis = None
|
||||
else:
|
||||
self.X = "❌"
|
||||
self.O = "⭕️"
|
||||
# Unicode emoji digits 1️⃣-9️⃣
|
||||
self.digit_emojis = [
|
||||
"1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"
|
||||
]
|
||||
self.display_module = display_module
|
||||
self.game = {}
|
||||
self.win_lines_3d = self.generate_3d_win_lines()
|
||||
|
||||
def new_game(self, nodeID, mode="2D", channel=None, deviceID=None):
|
||||
board_size = 9 if mode == "2D" else 27
|
||||
self.game[nodeID] = {
|
||||
"board": [" "] * board_size,
|
||||
"mode": mode,
|
||||
"channel": channel,
|
||||
"nodeID": nodeID,
|
||||
"deviceID": deviceID,
|
||||
"player": self.X,
|
||||
"games": 1,
|
||||
"won": 0,
|
||||
"turn": "human"
|
||||
}
|
||||
self.update_display(nodeID, status="new")
|
||||
msg = f"{mode} game started!\n"
|
||||
if mode == "2D":
|
||||
msg += self.show_board(nodeID)
|
||||
msg += "Pick 1-9:"
|
||||
else:
|
||||
msg += "Play on the MeshBot Display!\n"
|
||||
msg += "Pick 1-27:"
|
||||
return msg
|
||||
|
||||
def update_display(self, nodeID, status=None):
|
||||
from modules.system import send_raw_bytes
|
||||
g = self.game[nodeID]
|
||||
mapping = {" ": "0", "X": "1", "O": "2", "❌": "1", "⭕️": "2"}
|
||||
board_str = "".join(mapping.get(cell, "0") for cell in g["board"])
|
||||
msg = f"MTTT:{board_str}|{g['nodeID']}|{g['channel']}|{g['deviceID']}"
|
||||
if status:
|
||||
msg += f"|status={status}"
|
||||
if useSynchCompression:
|
||||
payload = zlib.compress(msg.encode("utf-8"))
|
||||
else:
|
||||
payload = msg.encode("utf-8")
|
||||
send_raw_bytes(nodeID, payload, portnum=256)
|
||||
if self.display_module:
|
||||
self.display_module.update_board(
|
||||
g["board"], g["channel"], g["nodeID"], g["deviceID"]
|
||||
)
|
||||
|
||||
def show_board(self, nodeID):
|
||||
g = self.game[nodeID]
|
||||
if g["mode"] == "2D":
|
||||
b = g["board"]
|
||||
s = ""
|
||||
for i in range(3):
|
||||
row = []
|
||||
for j in range(3):
|
||||
cell = b[i*3+j]
|
||||
if cell != " ":
|
||||
row.append(cell)
|
||||
else:
|
||||
if self.digit_emojis:
|
||||
row.append(self.digit_emojis[i*3+j])
|
||||
else:
|
||||
row.append(str(i*3+j+1))
|
||||
s += " | ".join(row) + "\n"
|
||||
return s
|
||||
return ""
|
||||
|
||||
def make_move(self, nodeID, position):
|
||||
g = self.game[nodeID]
|
||||
board = g["board"]
|
||||
max_pos = 9 if g["mode"] == "2D" else 27
|
||||
if 1 <= position <= max_pos and board[position-1] == " ":
|
||||
board[position-1] = g["player"]
|
||||
return True
|
||||
return False
|
||||
|
||||
def bot_move(self, nodeID):
|
||||
g = self.game[nodeID]
|
||||
board = g["board"]
|
||||
max_pos = 9 if g["mode"] == "2D" else 27
|
||||
# Try to win or block
|
||||
for player in (self.O, self.X):
|
||||
move = self.find_winning_move(nodeID, player)
|
||||
if move != -1:
|
||||
board[move] = self.O
|
||||
return move+1
|
||||
# Otherwise random move
|
||||
empty = [i for i, cell in enumerate(board) if cell == " "]
|
||||
if empty:
|
||||
move = random.choice(empty)
|
||||
board[move] = self.O
|
||||
return move+1
|
||||
return -1
|
||||
|
||||
def find_winning_move(self, nodeID, player):
|
||||
g = self.game[nodeID]
|
||||
board = g["board"]
|
||||
lines = self.get_win_lines(g["mode"])
|
||||
for line in lines:
|
||||
cells = [board[i] for i in line]
|
||||
if cells.count(player) == 2 and cells.count(" ") == 1:
|
||||
return line[cells.index(" ")]
|
||||
return -1
|
||||
|
||||
def play(self, nodeID, input_msg):
|
||||
try:
|
||||
if nodeID not in self.game:
|
||||
return self.new_game(nodeID)
|
||||
g = self.game[nodeID]
|
||||
mode = g["mode"]
|
||||
max_pos = 9 if mode == "2D" else 27
|
||||
|
||||
input_str = input_msg.strip().lower()
|
||||
if input_str in ("end", "e", "quit", "q"):
|
||||
msg = "Game ended."
|
||||
self.update_display(nodeID)
|
||||
return msg
|
||||
|
||||
# Add refresh/draw command
|
||||
if input_str in ("refresh", "board", "b"):
|
||||
self.update_display(nodeID, status="refresh")
|
||||
if mode == "2D":
|
||||
return self.show_board(nodeID) + f"Pick 1-{max_pos}:"
|
||||
else:
|
||||
return "Display refreshed."
|
||||
|
||||
# Allow 'new', 'new 2d', 'new 3d'
|
||||
if input_str.startswith("new"):
|
||||
parts = input_str.split()
|
||||
if len(parts) > 1 and parts[1] in ("2d", "3d"):
|
||||
new_mode = "2D" if parts[1] == "2d" else "3D"
|
||||
else:
|
||||
new_mode = mode
|
||||
msg = self.new_game(nodeID, new_mode, g["channel"], g["deviceID"])
|
||||
return msg
|
||||
|
||||
# Accept emoji digits as input
|
||||
pos = None
|
||||
# Try to match emoji digits if enabled
|
||||
if self.digit_emojis:
|
||||
try:
|
||||
# Remove variation selectors for matching
|
||||
normalized_input = input_msg.replace("\ufe0f", "")
|
||||
for idx, emoji in enumerate(self.digit_emojis[:max_pos]):
|
||||
if normalized_input == emoji.replace("\ufe0f", ""):
|
||||
pos = idx + 1
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if pos is None:
|
||||
try:
|
||||
pos = int(input_msg)
|
||||
except Exception:
|
||||
return f"Enter a number or emoji between 1 and {max_pos}."
|
||||
|
||||
if not self.make_move(nodeID, pos):
|
||||
return f"Invalid move! Pick 1-{max_pos}:"
|
||||
|
||||
winner = self.check_winner(nodeID)
|
||||
if winner:
|
||||
# Add positive/sorry messages and stats
|
||||
positiveThoughts = [
|
||||
"🚀I need to call NATO",
|
||||
"🏅Going for the gold!",
|
||||
"Mastering ❌TTT⭕️",
|
||||
]
|
||||
sorryNotGoinWell = [
|
||||
"😭Not your day, huh?",
|
||||
"📉Results here dont define you.",
|
||||
"🤖WOPR would be proud."
|
||||
]
|
||||
games = won = 0
|
||||
ret = ""
|
||||
if nodeID in self.game:
|
||||
self.game[nodeID]["won"] += 1
|
||||
games = self.game[nodeID]["games"]
|
||||
won = self.game[nodeID]["won"]
|
||||
if games > 3:
|
||||
if won / games >= 3.14159265358979323846: # win rate > pi
|
||||
ret += random.choice(positiveThoughts) + "\n"
|
||||
else:
|
||||
ret += random.choice(sorryNotGoinWell) + "\n"
|
||||
# Retain stats
|
||||
ret += f"Games:{games} 🥇❌:{won}\n"
|
||||
msg = f"You ({g['player']}) win!\n" + ret
|
||||
msg += "Type 'new' to play again or 'end' to quit."
|
||||
self.update_display(nodeID, status="win")
|
||||
return msg
|
||||
|
||||
if " " not in g["board"]:
|
||||
msg = "Tie game!"
|
||||
msg += "\nType 'new' to play again or 'end' to quit."
|
||||
self.update_display(nodeID, status="tie")
|
||||
return msg
|
||||
|
||||
# Bot's turn
|
||||
g["player"] = self.O
|
||||
bot_pos = self.bot_move(nodeID)
|
||||
winner = self.check_winner(nodeID)
|
||||
if winner:
|
||||
self.update_display(nodeID, status="loss")
|
||||
msg = f"Bot ({g['player']}) wins!\n"
|
||||
msg += "Type 'new' to play again or 'end' to quit."
|
||||
return msg
|
||||
|
||||
if " " not in g["board"]:
|
||||
msg = "Tie game!"
|
||||
msg += "\nType 'new' to play again or 'end' to quit."
|
||||
self.update_display(nodeID, status="tie")
|
||||
return msg
|
||||
|
||||
g["player"] = self.X
|
||||
prompt = f"Pick 1-{max_pos}:"
|
||||
if mode == "2D":
|
||||
prompt = self.show_board(nodeID) + prompt
|
||||
self.update_display(nodeID)
|
||||
return prompt
|
||||
|
||||
except Exception as e:
|
||||
return f"An unexpected error occurred: {e}"
|
||||
|
||||
def check_winner(self, nodeID):
|
||||
g = self.game[nodeID]
|
||||
board = g["board"]
|
||||
lines = self.get_win_lines(g["mode"])
|
||||
for line in lines:
|
||||
vals = [board[i] for i in line]
|
||||
if vals[0] != " " and all(v == vals[0] for v in vals):
|
||||
return vals[0]
|
||||
return None
|
||||
|
||||
def get_win_lines(self, mode):
|
||||
if mode == "2D":
|
||||
return [
|
||||
[0,1,2],[3,4,5],[6,7,8], # rows
|
||||
[0,3,6],[1,4,7],[2,5,8], # columns
|
||||
[0,4,8],[2,4,6] # diagonals
|
||||
]
|
||||
return self.win_lines_3d
|
||||
|
||||
def generate_3d_win_lines(self):
|
||||
lines = []
|
||||
# Rows in each layer
|
||||
for z in range(3):
|
||||
for y in range(3):
|
||||
lines.append([z*9 + y*3 + x for x in range(3)])
|
||||
# Columns in each layer
|
||||
for z in range(3):
|
||||
for x in range(3):
|
||||
lines.append([z*9 + y*3 + x for y in range(3)])
|
||||
# Pillars (vertical columns through layers)
|
||||
for y in range(3):
|
||||
for x in range(3):
|
||||
lines.append([z*9 + y*3 + x for z in range(3)])
|
||||
# Diagonals in each layer
|
||||
for z in range(3):
|
||||
lines.append([z*9 + i*3 + i for i in range(3)]) # TL to BR
|
||||
lines.append([z*9 + i*3 + (2-i) for i in range(3)]) # TR to BL
|
||||
# Vertical diagonals in columns
|
||||
for x in range(3):
|
||||
lines.append([z*9 + z*3 + x for z in range(3)]) # (0,0,x)-(1,1,x)-(2,2,x)
|
||||
lines.append([z*9 + (2-z)*3 + x for z in range(3)]) # (0,2,x)-(1,1,x)-(2,0,x)
|
||||
for y in range(3):
|
||||
lines.append([z*9 + y*3 + z for z in range(3)]) # (z,y,z)
|
||||
lines.append([z*9 + y*3 + (2-z) for z in range(3)]) # (z,y,2-z)
|
||||
# Main space diagonals
|
||||
lines.append([0, 13, 26])
|
||||
lines.append([2, 13, 24])
|
||||
lines.append([6, 13, 20])
|
||||
lines.append([8, 13, 18])
|
||||
return lines
|
||||
|
||||
def end(self, nodeID):
|
||||
"""End and remove the game for the given nodeID."""
|
||||
if nodeID in self.game:
|
||||
del self.game[nodeID]
|
||||
199
modules/games/tictactoe_vid.py
Normal file
199
modules/games/tictactoe_vid.py
Normal file
@@ -0,0 +1,199 @@
|
||||
# Tic-Tac-Toe Video Display Module for Meshtastic mesh-bot
|
||||
# Uses Pygame to render the game board visually
|
||||
# 2025 K7MHI Kelly Keeton
|
||||
|
||||
try:
|
||||
import pygame
|
||||
except ImportError:
|
||||
print("Pygame is not installed. Please install it with 'pip install pygame-ce' to use the Tic-Tac-Toe display module.")
|
||||
exit(1)
|
||||
|
||||
latest_board = [" "] * 9 # or 27 for 3D
|
||||
latest_meta = {} # To store metadata like status
|
||||
|
||||
def handle_tictactoe_payload(payload, from_id=None):
|
||||
global latest_board, latest_meta
|
||||
#print("Received payload:", payload)
|
||||
board, meta = parse_tictactoe_message(payload)
|
||||
#print("Parsed board:", board)
|
||||
if board:
|
||||
latest_board = board
|
||||
latest_meta = meta if meta else {}
|
||||
|
||||
def parse_tictactoe_message(msg):
|
||||
# msg is already stripped of 'MTTT:' prefix
|
||||
parts = msg.split("|")
|
||||
if not parts or len(parts[0]) < 9:
|
||||
return None, None # Not enough data for a board
|
||||
board_str = parts[0]
|
||||
meta = {}
|
||||
if len(parts) > 1:
|
||||
meta["nodeID"] = parts[1] if len(parts) > 1 else ""
|
||||
meta["channel"] = parts[2] if len(parts) > 2 else ""
|
||||
meta["deviceID"] = parts[3] if len(parts) > 3 else ""
|
||||
# Look for status in any remaining parts
|
||||
for part in parts[4:]:
|
||||
if part.startswith("status="):
|
||||
meta["status"] = part.split("=", 1)[1]
|
||||
symbol_map = {"0": " ", "1": "❌", "2": "⭕️"}
|
||||
board = [symbol_map.get(c, " ") for c in board_str]
|
||||
return board, meta
|
||||
|
||||
def draw_board(screen, board, meta=None):
|
||||
screen.fill((30, 30, 30))
|
||||
width, height = screen.get_size()
|
||||
margin = int(min(width, height) * 0.05)
|
||||
font_size = int(height * 0.12)
|
||||
font = pygame.font.Font(None, font_size)
|
||||
|
||||
# Draw the title at the top center, scaled
|
||||
title_font = pygame.font.Font(None, int(height * 0.08))
|
||||
title_text = title_font.render("MeshBot Tic-Tac-Toe", True, (220, 220, 255))
|
||||
title_rect = title_text.get_rect(center=(width // 2, margin // 2 + 10))
|
||||
screen.blit(title_text, title_rect)
|
||||
|
||||
# Add a buffer below the title
|
||||
title_buffer = int(height * 0.06)
|
||||
|
||||
# --- Show win/draw message if present ---
|
||||
if meta and "status" in meta:
|
||||
status = meta["status"]
|
||||
msg_font = pygame.font.Font(None, int(height * 0.06)) # Smaller font
|
||||
msg_y = title_rect.bottom + int(height * 0.04) # Just under the title
|
||||
if status == "win":
|
||||
msg = "Game Won!"
|
||||
text = msg_font.render(msg, True, (100, 255, 100))
|
||||
text_rect = text.get_rect(center=(width // 2, msg_y))
|
||||
screen.blit(text, text_rect)
|
||||
elif status == "tie":
|
||||
msg = "Tie Game!"
|
||||
text = msg_font.render(msg, True, (255, 220, 120))
|
||||
text_rect = text.get_rect(center=(width // 2, msg_y))
|
||||
screen.blit(text, text_rect)
|
||||
elif status == "loss":
|
||||
msg = "You Lost!"
|
||||
text = msg_font.render(msg, True, (255, 100, 100))
|
||||
text_rect = text.get_rect(center=(width // 2, msg_y))
|
||||
screen.blit(text, text_rect)
|
||||
elif status == "new":
|
||||
msg = "Welcome! New Game"
|
||||
text = msg_font.render(msg, True, (200, 255, 200))
|
||||
text_rect = text.get_rect(center=(width // 2, msg_y))
|
||||
screen.blit(text, text_rect)
|
||||
# Do NOT return here—let the board draw as normal
|
||||
elif status != "refresh":
|
||||
msg = status.capitalize()
|
||||
text = msg_font.render(msg, True, (255, 220, 120))
|
||||
text_rect = text.get_rect(center=(width // 2, msg_y))
|
||||
screen.blit(text, text_rect)
|
||||
# Don't return here—let the board draw as normal
|
||||
|
||||
# Show waiting message if board is empty, unless status is "new"
|
||||
if all(cell.strip() == "" or cell.strip() == " " for cell in board):
|
||||
if not (meta and meta.get("status") == "new"):
|
||||
msg_font = pygame.font.Font(None, int(height * 0.09))
|
||||
msg = "Waiting for player..."
|
||||
text = msg_font.render(msg, True, (200, 200, 200))
|
||||
text_rect = text.get_rect(center=(width // 2, height // 2))
|
||||
screen.blit(text, text_rect)
|
||||
pygame.display.flip()
|
||||
return
|
||||
|
||||
def draw_x(rect):
|
||||
thickness = max(4, rect.width // 12)
|
||||
pygame.draw.line(screen, (255, 80, 80), rect.topleft, rect.bottomright, thickness)
|
||||
pygame.draw.line(screen, (255, 80, 80), rect.topright, rect.bottomleft, thickness)
|
||||
|
||||
def draw_o(rect):
|
||||
center = rect.center
|
||||
radius = rect.width // 2 - max(6, rect.width // 16)
|
||||
thickness = max(4, rect.width // 12)
|
||||
pygame.draw.circle(screen, (80, 180, 255), center, radius, thickness)
|
||||
|
||||
if len(board) == 9:
|
||||
# 2D: Center a single 3x3 grid, scale to fit
|
||||
size = min((width - 2*margin)//3, (height - 2*margin - title_buffer)//3)
|
||||
offset_x = (width - size*3) // 2
|
||||
offset_y = (height - size*3) // 2 + title_buffer // 2
|
||||
offset_y = max(offset_y, title_rect.bottom + title_buffer)
|
||||
# Index number font and buffer
|
||||
small_index_font = pygame.font.Font(None, int(size * 0.38))
|
||||
index_buffer_x = int(size * 0.16)
|
||||
index_buffer_y = int(size * 0.10)
|
||||
for i in range(3):
|
||||
for j in range(3):
|
||||
rect = pygame.Rect(offset_x + j*size, offset_y + i*size, size, size)
|
||||
pygame.draw.rect(screen, (200, 200, 200), rect, 2)
|
||||
idx = i*3 + j
|
||||
# Draw index number in top-left, start at 1
|
||||
idx_text = small_index_font.render(str(idx + 1), True, (120, 120, 160))
|
||||
idx_rect = idx_text.get_rect(topleft=(rect.x + index_buffer_x, rect.y + index_buffer_y))
|
||||
screen.blit(idx_text, idx_rect)
|
||||
val = board[idx].strip()
|
||||
if val == "❌" or val == "X":
|
||||
draw_x(rect)
|
||||
elif val == "⭕️" or val == "O":
|
||||
draw_o(rect)
|
||||
elif val:
|
||||
text = font.render(val, True, (255, 255, 255))
|
||||
text_rect = text.get_rect(center=rect.center)
|
||||
screen.blit(text, text_rect)
|
||||
elif len(board) == 27:
|
||||
# 3D: Stack three 3x3 grids vertically, with horizontal offsets for 3D effect, scale to fit
|
||||
size = min((width - 2*margin)//7, (height - 4*margin - title_buffer)//9)
|
||||
base_offset_x = (width - (size * 3)) // 2
|
||||
offset_y = (height - (size*9 + margin*2)) // 2 + title_buffer // 2
|
||||
offset_y = max(offset_y, title_rect.bottom + title_buffer)
|
||||
small_font = pygame.font.Font(None, int(height * 0.045))
|
||||
small_index_font = pygame.font.Font(None, int(size * 0.38))
|
||||
index_buffer_x = int(size * 0.16)
|
||||
index_buffer_y = int(size * 0.10)
|
||||
for display_idx, layer in enumerate(reversed(range(3))):
|
||||
layer_offset_x = base_offset_x + (layer - 1) * 2 * size
|
||||
layer_y = offset_y + display_idx * (size*3 + margin)
|
||||
label_text = f"Layer {layer+1}"
|
||||
label = small_font.render(label_text, True, (180, 180, 220))
|
||||
label_rect = label.get_rect(center=(layer_offset_x + size*3//2, layer_y + size*3 + int(size*0.2)))
|
||||
screen.blit(label, label_rect)
|
||||
for i in range(3):
|
||||
for j in range(3):
|
||||
rect = pygame.Rect(layer_offset_x + j*size, layer_y + i*size, size, size)
|
||||
pygame.draw.rect(screen, (200, 200, 200), rect, 2)
|
||||
idx = layer*9 + i*3 + j
|
||||
idx_text = small_index_font.render(str(idx + 1), True, (120, 120, 160))
|
||||
idx_rect = idx_text.get_rect(topleft=(rect.x + index_buffer_x, rect.y + index_buffer_y))
|
||||
screen.blit(idx_text, idx_rect)
|
||||
val = board[idx].strip()
|
||||
if val == "❌" or val == "X":
|
||||
draw_x(rect)
|
||||
elif val == "⭕️" or val == "O":
|
||||
draw_o(rect)
|
||||
elif val:
|
||||
text = font.render(val, True, (255, 255, 255))
|
||||
text_rect = text.get_rect(center=rect.center)
|
||||
screen.blit(text, text_rect)
|
||||
pygame.display.flip()
|
||||
|
||||
def ttt_main(fullscreen=True):
|
||||
global latest_board, latest_meta
|
||||
pygame.init()
|
||||
if fullscreen:
|
||||
screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN)
|
||||
else:
|
||||
# Use a reasonable windowed size if not fullscreen
|
||||
screen = pygame.display.set_mode((900, 700))
|
||||
pygame.display.set_caption("Tic-Tac-Toe 3D Display")
|
||||
info = pygame.display.Info()
|
||||
mode = "fullscreen" if fullscreen else "windowed"
|
||||
print(f"[MeshBot TTT Display] Pygame version: {pygame.version.ver}")
|
||||
print(f"[MeshBot TTT Display] Resolution: {info.current_w}x{info.current_h} ({mode})")
|
||||
print(f"[MeshBot TTT Display] Display driver: {pygame.display.get_driver()}")
|
||||
running = True
|
||||
while running:
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.QUIT or (event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE):
|
||||
running = False
|
||||
draw_board(screen, latest_board, latest_meta)
|
||||
pygame.display.flip()
|
||||
pygame.time.wait(75) # or 50-100 for lower CPU
|
||||
pygame.quit()
|
||||
457
modules/games/videopoker.py
Normal file
457
modules/games/videopoker.py
Normal file
@@ -0,0 +1,457 @@
|
||||
# Port of https://github.com/devtronvarma/Video-Poker-Terminal-Game
|
||||
# Adapted for Meshtastic mesh-bot by K7MHI Kelly Keeton 2024
|
||||
import random
|
||||
import time
|
||||
import pickle
|
||||
from modules.log import logger, getPrettyTime
|
||||
from modules.settings import vpTracker
|
||||
|
||||
vpStartingCash = 20
|
||||
# Define the Card class
|
||||
class CardVP:
|
||||
global vpTracker
|
||||
|
||||
card_values = { # value of the ace is high until it needs to be low
|
||||
2: 2,
|
||||
3: 3,
|
||||
4: 4,
|
||||
5: 5,
|
||||
6: 6,
|
||||
7: 7,
|
||||
8: 8,
|
||||
9: 9,
|
||||
10: 10,
|
||||
'Jack': 11,
|
||||
'Queen': 12,
|
||||
'King': 13,
|
||||
'Ace': 14
|
||||
}
|
||||
|
||||
def __init__(self, suit, rank):
|
||||
"""
|
||||
:param suit: The face of the card, e.g. Spade or Diamond
|
||||
:param rank: The value of the card, e.g 3 or King
|
||||
"""
|
||||
self.suit = suit.capitalize()
|
||||
self.rank = rank
|
||||
self.points = self.card_values[rank]
|
||||
|
||||
# Function to output ascii version of the cards in a hand in the terminal
|
||||
def drawCardsVp(*cards, return_string=True):
|
||||
"""
|
||||
Instead of a boring text version of the card we render an ASCII image of the card.
|
||||
:param cards: One or more card objects
|
||||
:param return_string: By default we return the string version of the card, but the dealer hide the 1st card and we
|
||||
keep it as a list so that the dealer can add a hidden card in front of the list
|
||||
"""
|
||||
# we will use this to prints the appropriate icons for each card
|
||||
suits_name = ['Spades', 'Diamonds', 'Hearts', 'Clubs']
|
||||
suits_symbols = ['♠️', '♦️', '♥️', '♣️']
|
||||
|
||||
# create an empty list of list, each sublist is a line 2 lines for the card
|
||||
lines = [[] for i in range(1)]
|
||||
|
||||
for index, card in enumerate(cards):
|
||||
# "King" should be "K" and "10" should still be "10"
|
||||
if card.rank == 10: # ten is the only one who's rank is 2 char long
|
||||
rank = str(card.rank)
|
||||
else:
|
||||
rank = str(card.rank)[0] # some have a rank of 'King' this changes that to a simple 'K' ("King" doesn't fit)
|
||||
# get the cards suit in two steps
|
||||
suit = suits_name.index(card.suit)
|
||||
suit = suits_symbols[suit]
|
||||
|
||||
# add the individual card on a line by line basis
|
||||
lines[0].append('{}{} '.format(rank, suit))
|
||||
|
||||
result = []
|
||||
#result.append('1 2 3 4 5') # add the index for the cards to top row
|
||||
for index, line in enumerate(lines):
|
||||
result.append(''.join(lines[index]))
|
||||
|
||||
# hidden cards do not use string
|
||||
if return_string:
|
||||
return '\n'.join(result)
|
||||
else:
|
||||
return result
|
||||
|
||||
# Define Deck class
|
||||
class DeckVP:
|
||||
|
||||
def __init__(self):
|
||||
self.cards = []
|
||||
self.build()
|
||||
|
||||
# method for building the deck
|
||||
def build(self):
|
||||
for s in ['Spades', 'Diamonds', 'Hearts', 'Clubs']:
|
||||
for v in range(2, 11):
|
||||
self.cards.append(CardVP(s,v))
|
||||
for c in ["Jack", "Queen", "King", "Ace"]:
|
||||
self.cards.append(CardVP(s,c))
|
||||
|
||||
# method to show cards in deck
|
||||
def display(self):
|
||||
for c in self.cards:
|
||||
print(drawCardsVp(c))
|
||||
|
||||
# method to shuffle cards in deck
|
||||
def shuffle(self):
|
||||
for i in range(len(self.cards) - 1, 0, -1):
|
||||
r = random.randint(0, i)
|
||||
self.cards[i], self.cards[r] = self.cards[r], self.cards[i]
|
||||
|
||||
# method to draw card from the deck
|
||||
def draw_card(self):
|
||||
return self.cards.pop()
|
||||
|
||||
# Define Player Class
|
||||
class PlayerVP:
|
||||
def __init__(self):
|
||||
self.hand = []
|
||||
self.bankroll = 20
|
||||
|
||||
# Method for initial five-card draw
|
||||
def draw_cards(self, deck):
|
||||
for i in range(5):
|
||||
self.hand.append(deck.draw_card())
|
||||
return self
|
||||
|
||||
# Method for displaying player's hand
|
||||
def show_hand(self):
|
||||
msg = (drawCardsVp(
|
||||
self.hand[0],
|
||||
self.hand[1],
|
||||
self.hand[2],
|
||||
self.hand[3],
|
||||
self.hand[4]))
|
||||
return msg
|
||||
|
||||
# Method for placing a bet
|
||||
def bet(self, ammount=0):
|
||||
bet = int(ammount)
|
||||
self.bet_size = bet
|
||||
self.bankroll -= self.bet_size
|
||||
|
||||
# Method for selecting cards to redraw
|
||||
def redraw(self, deck, message):
|
||||
# if message has single digit, then it is the card to redraw, else it is the list of cards to redraw with a comma
|
||||
if len(message) == 1:
|
||||
try:
|
||||
# if single digit is the letter a redraw all cards
|
||||
if message.lower() == "a":
|
||||
for i in range(5):
|
||||
self.hand[i] = deck.draw_card()
|
||||
else:
|
||||
# error trap for bad input
|
||||
redraw_index = int(message) - 1
|
||||
self.hand[redraw_index] = deck.draw_card()
|
||||
|
||||
return self.show_hand()
|
||||
except Exception as e:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
# error trap for bad input
|
||||
if "," in message:
|
||||
redraw_list = [int(x) - 1 for x in message.split(',')]
|
||||
if "." in message:
|
||||
redraw_list = [int(x) - 1 for x in message.split('.')]
|
||||
if " " in message:
|
||||
redraw_list = [int(x) - 1 for x in message.split(' ')]
|
||||
for i in redraw_list:
|
||||
self.hand[i] = deck.draw_card()
|
||||
return self.show_hand()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return "ex:1,3,4 deals them new, and keeps 2,5 or (N)o to keep current (H)and"
|
||||
|
||||
# Method for scoring hand, calculating winnings, and outputting message
|
||||
def score_hand(self, resetHand = True):
|
||||
points = sorted([self.hand[i].points for i in range(5)])
|
||||
suits = [self.hand[i].suit for i in range(5)]
|
||||
points_repeat = [points.count(i) for i in points]
|
||||
suits_repeat = [suits.count(i) for i in suits]
|
||||
diff = max(points) - min(points)
|
||||
hand_name = ""
|
||||
msg = ""
|
||||
payoff = {
|
||||
"👑Royal Flush🚽": 10,
|
||||
"🧻Straight Flush🚽": 9,
|
||||
"Flush🚽": 8,
|
||||
"Full House🏠": 7,
|
||||
"Four of a Kind👯👯": 6,
|
||||
"Three of a Kind☘️": 5,
|
||||
"Two Pair👯👯": 4,
|
||||
"Straight📏": 3,
|
||||
"Pair👯": 2,
|
||||
"Bad Hand 🙈": -1,
|
||||
}
|
||||
|
||||
if 5 in suits_repeat:
|
||||
if points == [10, 11, 12, 13, 14]: #find royal flush
|
||||
hand_name = "👑Royal Flush🚽"
|
||||
if resetHand:
|
||||
self.bankroll += self.bet_size * payoff[hand_name]
|
||||
elif diff == 4 and max(points_repeat) == 1: # find straight flush w/o ace low
|
||||
hand_name = "🧻Straight Flush🚽"
|
||||
if resetHand:
|
||||
self.bankroll += self.bet_size * payoff[hand_name]
|
||||
elif diff == 12 and points[4] == 14: # find straight flush w/ace low
|
||||
check = 0
|
||||
for i in range(1, 4):
|
||||
check += points[i] - points[i - 1]
|
||||
if check == 3:
|
||||
hand_name = "🧻Straight Flush🚽"
|
||||
if resetHand:
|
||||
self.bankroll += self.bet_size * payoff[hand_name]
|
||||
else:
|
||||
hand_name = "Flush🚽"
|
||||
if resetHand:
|
||||
self.bankroll += self.bet_size * payoff[hand_name]
|
||||
else:
|
||||
hand_name = "Flush🚽"
|
||||
if resetHand:
|
||||
self.bankroll += self.bet_size * payoff[hand_name]
|
||||
elif sorted(points_repeat) == [2,2,3,3,3]: # find full house
|
||||
hand_name = "Full House🏠"
|
||||
if resetHand:
|
||||
self.bankroll += self.bet_size * payoff[hand_name]
|
||||
elif 4 in points_repeat: # find four of a kind
|
||||
hand_name = "Four of a Kind👯👯"
|
||||
if resetHand:
|
||||
self.bankroll += self.bet_size * payoff[hand_name]
|
||||
elif 3 in points_repeat: # find three of a kind
|
||||
hand_name = "Three of a Kind☘️"
|
||||
if resetHand:
|
||||
self.bankroll += self.bet_size * payoff[hand_name]
|
||||
elif points_repeat.count(2) == 4: # find two-pair
|
||||
hand_name = "Two Pair👯👯"
|
||||
if resetHand:
|
||||
self.bankroll += self.bet_size * payoff[hand_name]
|
||||
elif 2 in points_repeat: # find pair
|
||||
hand_name = "Pair👯"
|
||||
if resetHand:
|
||||
self.bankroll += self.bet_size * payoff[hand_name]
|
||||
elif diff == 4 and max(points_repeat) == 1: # find straight w/o ace low
|
||||
hand_name = "Straight📏"
|
||||
if resetHand:
|
||||
self.bankroll += self.bet_size * payoff[hand_name]
|
||||
elif diff == 12 and points[4] == 14: # find straight w/ace low
|
||||
check = 0
|
||||
for i in range(1, 4):
|
||||
check += points[i] - points[i - 1]
|
||||
if check == 3:
|
||||
hand_name = "Straight📏"
|
||||
if resetHand:
|
||||
self.bankroll += self.bet_size * payoff[hand_name]
|
||||
else:
|
||||
hand_name = "Bad Hand 🙈"
|
||||
else: # for everything Hand
|
||||
hand_name = "Bad Hand 🙈"
|
||||
|
||||
if resetHand:
|
||||
self.hand = []
|
||||
msg = f"\nYour hand, {hand_name}. Your bankroll is now {self.bankroll} coins."
|
||||
else:
|
||||
if hand_name != "":
|
||||
msg = f"\nShowing:{hand_name}"
|
||||
return msg
|
||||
|
||||
|
||||
def getLastCmdVp(nodeID):
|
||||
global vpTracker
|
||||
last_cmd = ""
|
||||
for i in range(len(vpTracker)):
|
||||
if vpTracker[i]['nodeID'] == nodeID:
|
||||
last_cmd = vpTracker[i]['cmd']
|
||||
return last_cmd
|
||||
|
||||
def setLastCmdVp(nodeID, cmd):
|
||||
global vpTracker
|
||||
for i in range(len(vpTracker)):
|
||||
if vpTracker[i]['nodeID'] == nodeID:
|
||||
vpTracker[i]['cmd'] = cmd
|
||||
|
||||
def saveHSVp(nodeID, highScore):
|
||||
# Save the game high_score to pickle
|
||||
highScore = {'nodeID': nodeID, 'highScore': highScore}
|
||||
try:
|
||||
with open('data/videopoker_hs.pkl', 'wb') as file:
|
||||
pickle.dump(highScore, file)
|
||||
except FileNotFoundError:
|
||||
logger.debug("System: BlackJack: Creating new data/videopoker_hs.pkl file")
|
||||
with open('data/videopoker_hs.pkl', 'wb') as file:
|
||||
pickle.dump(highScore, file)
|
||||
|
||||
def loadHSVp():
|
||||
# Load the game high_score from pickle
|
||||
try:
|
||||
with open('data/videopoker_hs.pkl', 'rb') as file:
|
||||
highScore = pickle.load(file)
|
||||
return highScore
|
||||
except FileNotFoundError:
|
||||
logger.debug("System: VideoPoker: Creating new data/videopoker_hs.pkl file")
|
||||
highScore = {'nodeID': 0, 'highScore': 0}
|
||||
with open('data/videopoker_hs.pkl', 'wb') as file:
|
||||
pickle.dump(highScore, file)
|
||||
return 0
|
||||
|
||||
def playVideoPoker(nodeID, message):
|
||||
global vpTracker, vpStartingCash
|
||||
msg = ""
|
||||
try:
|
||||
# Initialize the player
|
||||
if getLastCmdVp(nodeID) is None or getLastCmdVp(nodeID) == "":
|
||||
# create new player if not in tracker
|
||||
logger.debug(f"System: VideoPoker: New Player {nodeID}")
|
||||
vpTracker.append({'nodeID': nodeID, 'cmd': 'new', 'time': time.time(), 'cash': vpStartingCash, 'player': None, 'deck': None, 'highScore': 0, 'drawCount': 0})
|
||||
return f"You have {vpStartingCash} coins, \nWhats your bet?"
|
||||
|
||||
# Gather the player's bet
|
||||
if getLastCmdVp(nodeID) == "new" or getLastCmdVp(nodeID) == "gameOver":
|
||||
# Initialize shuffled Deck and Player
|
||||
player = PlayerVP()
|
||||
deck = DeckVP()
|
||||
deck.shuffle()
|
||||
drawCount = 1
|
||||
bet = 0
|
||||
msg = ''
|
||||
|
||||
# load the player bankroll from tracker
|
||||
for i in range(len(vpTracker)):
|
||||
if vpTracker[i]['nodeID'] == nodeID:
|
||||
player.bankroll = vpTracker[i]['cash']
|
||||
vpTracker[i]['time'] = time.time()
|
||||
|
||||
# Detect if message is a bet
|
||||
try:
|
||||
bet = int(message)
|
||||
except ValueError:
|
||||
msg += f"Please enter a valid bet, 1 to 5 coins. you have {player.bankroll} coins."
|
||||
|
||||
# Check if bet is valid
|
||||
if bet > player.bankroll:
|
||||
msg += f"You can only bet the money you have. {player.bankroll} coins, No strip poker here..."
|
||||
elif bet < 1:
|
||||
msg += "You must bet at least 1 coin.🪙"
|
||||
elif bet > 5:
|
||||
msg += "The 🎰 coin slot only fits 5 coins max."
|
||||
|
||||
# if msg contains an error, return it
|
||||
if msg is not None and msg != '':
|
||||
return msg
|
||||
else:
|
||||
# Take the bet
|
||||
player.bet(str(message))
|
||||
# Bet placed, start the game
|
||||
setLastCmdVp(nodeID, "playing")
|
||||
|
||||
# save player and deck to tracker
|
||||
for i in range(len(vpTracker)):
|
||||
if vpTracker[i]['nodeID'] == nodeID:
|
||||
vpTracker[i]['player'] = player
|
||||
vpTracker[i]['deck'] = deck
|
||||
vpTracker[i]['cash'] = player.bankroll
|
||||
|
||||
# Play the game
|
||||
if getLastCmdVp(nodeID) == "playing":
|
||||
msg = ''
|
||||
|
||||
player.draw_cards(deck)
|
||||
msg += player.show_hand()
|
||||
# give hint to player
|
||||
msg += player.score_hand(resetHand=False)
|
||||
|
||||
# save player and deck to tracker
|
||||
for i in range(len(vpTracker)):
|
||||
if vpTracker[i]['nodeID'] == nodeID:
|
||||
vpTracker[i]['player'] = player
|
||||
vpTracker[i]['deck'] = deck
|
||||
vpTracker[i]['drawCount'] = drawCount
|
||||
|
||||
msg += f"\nDeal new card? \nex: 1,3,4 or (N)o,(A)ll (H)and"
|
||||
setLastCmdVp(nodeID, "redraw")
|
||||
return msg
|
||||
|
||||
if getLastCmdVp(nodeID) == "redraw":
|
||||
msg = ''
|
||||
# load the player and deck from tracker
|
||||
for i in range(len(vpTracker)):
|
||||
if vpTracker[i]['nodeID'] == nodeID:
|
||||
player = vpTracker[i]['player']
|
||||
deck = vpTracker[i]['deck']
|
||||
drawCount = vpTracker[i]['drawCount']
|
||||
|
||||
# if player wants to redraw cards, and not done already
|
||||
if message.lower().startswith("n"):
|
||||
setLastCmdVp(nodeID, "endGame")
|
||||
if message.lower().startswith("h"):
|
||||
msg = player.show_hand()
|
||||
return msg
|
||||
else:
|
||||
if drawCount <= 1:
|
||||
msg = player.redraw(deck, message)
|
||||
if msg.startswith("ex:"):
|
||||
# if returned error message, return it
|
||||
return msg
|
||||
drawCount += 1
|
||||
# save player and deck to tracker
|
||||
for i in range(len(vpTracker)):
|
||||
if vpTracker[i]['nodeID'] == nodeID:
|
||||
vpTracker[i]['player'] = player
|
||||
vpTracker[i]['deck'] = deck
|
||||
vpTracker[i]['drawCount'] = drawCount
|
||||
if drawCount == 2:
|
||||
# this is the last draw will carry on to endGame for scoring
|
||||
msg = player.redraw(deck, message) + f"\n"
|
||||
if msg.startswith("ex:"):
|
||||
# if returned error message, return it
|
||||
return msg
|
||||
# redraw done
|
||||
setLastCmdVp(nodeID, "endGame")
|
||||
else:
|
||||
# show redrawn hand
|
||||
return msg
|
||||
else:
|
||||
# redraw already done
|
||||
setLastCmdVp(nodeID, "endGame")
|
||||
|
||||
if getLastCmdVp(nodeID) == "endGame":
|
||||
# load the player and deck from tracker
|
||||
for i in range(len(vpTracker)):
|
||||
if vpTracker[i]['nodeID'] == nodeID:
|
||||
player = vpTracker[i]['player']
|
||||
deck = vpTracker[i]['deck']
|
||||
|
||||
msg += player.score_hand()
|
||||
|
||||
if player.bankroll < 1:
|
||||
player.bankroll = vpStartingCash
|
||||
msg += f"\nLooks 💸 like you're out of money. 💳 resetting ballance 🏧"
|
||||
elif player.bankroll > vpTracker[i]['highScore']:
|
||||
vpTracker[i]['highScore'] = player.bankroll
|
||||
msg += " 🎉HighScore!"
|
||||
# save high score
|
||||
saveHSVp(nodeID, vpTracker[i]['highScore'])
|
||||
|
||||
msg += f"\nPlace your Bet, or (L)eave Table."
|
||||
|
||||
setLastCmdVp(nodeID, "gameOver")
|
||||
# reset player and deck in tracker
|
||||
for i in range(len(vpTracker)):
|
||||
if vpTracker[i]['nodeID'] == nodeID:
|
||||
vpTracker[i]['player'] = None
|
||||
vpTracker[i]['deck'] = None
|
||||
vpTracker[i]['drawCount'] = 0
|
||||
# save bankroll
|
||||
vpTracker[i]['cash'] = player.bankroll
|
||||
|
||||
return msg
|
||||
# At the end of the try block, if nothing returned yet:
|
||||
return msg if msg else 'No action taken.'
|
||||
except Exception as e:
|
||||
logger.warning(f"System: VideoPoker: Error {e}")
|
||||
return 'No Game in progress'
|
||||
|
||||
313
modules/games/wodt.py
Normal file
313
modules/games/wodt.py
Normal file
@@ -0,0 +1,313 @@
|
||||
# python word of the day game module for meshing-around bot
|
||||
# 2025 K7MHI Kelly Keeton
|
||||
from modules.log import logger, getPrettyTime
|
||||
import random
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from itertools import product
|
||||
|
||||
class WordOfTheDayGame:
|
||||
def __init__(self):
|
||||
self.bingo_board_size = 3 # 3x3 bingo board good for small demos
|
||||
|
||||
default_word_list = [
|
||||
{'word': 'serendipity', 'meta': 'The occurrence of events by chance in a happy or beneficial way.'},
|
||||
{'word': 'ephemeral', 'meta': 'Lasting for a very short time.'},
|
||||
{'word': 'sonder', 'meta': 'The realization that each passerby has a life as vivid and complex as your own.'},
|
||||
{'word': 'petrichor', 'meta': 'A pleasant smell that frequently accompanies the first rain after a long period of warm, dry weather.'},
|
||||
]
|
||||
json_path = os.path.join('data', 'wotd.json')
|
||||
if os.path.exists(json_path):
|
||||
try:
|
||||
with open(json_path, 'r') as f:
|
||||
self.word_list = json.load(f)
|
||||
except FileNotFoundError:
|
||||
logger.debug("System: WoTd: Failed to load data/wotd.json, using default word list.")
|
||||
self.word_list = default_word_list
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("System: WoTd: JSON decode error in data/wotd.json, example format: [{\"word\": \"example\", \"definition\": \"An example definition.\"}]")
|
||||
self.word_list = default_word_list
|
||||
else:
|
||||
logger.debug("System: WoTd: data/wotd.json not found, using default word list.")
|
||||
self.word_list = default_word_list
|
||||
|
||||
# Load bingo card words from JSON if available
|
||||
default_bingo_card = [
|
||||
"dog", "cat", "fish", "bird", "hamster", "rabbit", "turtle", "lizard", "snake", "frog",
|
||||
"horse", "cow", "pig", "sheep", "goat", "chicken", "duck", "turkey", "peacock", "parrot",
|
||||
"elephant", "lion", "tiger", "bear", "wolf", "fox", "deer", "moose", "zebra", "giraffe",
|
||||
"monkey", "ape", "chimpanzee", "gorilla", "orangutan", "kangaroo", "koala", "panda",
|
||||
"whale", "dolphin", "shark", "octopus", "crab", "lobster", "jellyfish", "seahorse",
|
||||
"ant", "bee", "butterfly", "dragonfly", "spider", "ladybug"
|
||||
]
|
||||
bingo_json_path = os.path.join('data', 'bingo.json')
|
||||
if os.path.exists(bingo_json_path):
|
||||
try:
|
||||
with open(bingo_json_path, 'r') as f:
|
||||
bingoCard = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
logger.debug("System: WoTd: Failed to load data/bingo.json, using default bingo card. example format: [\"word1\", \"word2\", ...]")
|
||||
bingoCard = default_bingo_card
|
||||
else:
|
||||
logger.debug("System: WoTd: data/bingo.json not found, using default bingo card.")
|
||||
bingoCard = default_bingo_card
|
||||
|
||||
# Create a set for faster lookup
|
||||
self.bingoCardSet = set(bingoCard)
|
||||
|
||||
self.leet_dict = {
|
||||
'a': ['4', '@'],
|
||||
'b': ['8'],
|
||||
'e': ['3'],
|
||||
'i': ['1', '!', '|'],
|
||||
'l': ['1', '|', '7'],
|
||||
'o': ['0'],
|
||||
's': ['5', '$'],
|
||||
't': ['7', '+'],
|
||||
'g': ['9', '6'],
|
||||
}
|
||||
# Initialize the word of the day
|
||||
self.word_of_the_day_entry = random.choice(self.word_list)
|
||||
logger.debug(f"System: WoTd: Initialized with word of the day '{self.word_of_the_day_entry['word']}'.")
|
||||
# Initialize bingo card
|
||||
self.generate_bingo_card(self.bingo_board_size)
|
||||
logger.debug("System: BINGO: " + ". ".join(" | ".join(row) for row in self.bingo_card))
|
||||
|
||||
def get_emoji_type(self, emoji, randomReturn=False):
|
||||
smileys = "😀😁😂🤣😃😄😅😆😉😊😋😎😍😘🥰😗😙😚🙂🤗🤩🤔🤨😐😑😶🙄😏😣😥😮🤐😯😪😫😴😌😛😜😝🤤😒😓😔😕🙃🤑😲☹️🙁😖😞😟😤😢😭😦😧😨😩🤯😬😰😱🥵🥶😳🤪😵😡😠🤬😷🤒🤕🤢🤮🤧😇🥳🥺🤠"
|
||||
animals = "🐶🐱🐭🐹🐰🦊🐻🐼🐨🐯🦁🐮🐷🐽🐸🐵🙈🙉🙊🐒🐔🐧🐦🐤🐣🐥🦆🦅🦉🦇🐺🐗🐴🦄🐝🐛🦋🐌🐞🐜🦟🦗🕷️🕸️🐢🐍🦎🦂🦀🦞🦐🦑🐙🦑🐠🐟🐡🐬🦈🐳🐋🐊🐅🐆🦓🦍🦧🐘🦛🦏🐪🐫🦒🦘🐃🐂🐄🐎🐖🐏🐑🦙🐐🦌🐕🐩🦮🐕🦺🐈🐓🦃🦚🦜🦢🦩🕊️🐇🦝🦨🦡🦦🦥🐁🐀🐿️🦔"
|
||||
fruit = "🍎🍊🍌🍉🍇🍓🍒🍑🥭🍍🥥🥝🍅🥑🍆🥔🥕🌽🌶️🥒🥬🥦🧄🧅🍄🥜🌰"
|
||||
categories = {'smileys': smileys, 'animals': animals, 'fruit': fruit}
|
||||
if randomReturn:
|
||||
cat = random.choice(list(categories))
|
||||
return random.choice(categories[cat])
|
||||
for cat, chars in categories.items():
|
||||
if emoji in chars:
|
||||
return cat
|
||||
return False
|
||||
|
||||
def reset_word_of_the_day(self):
|
||||
logger.debug("System: WoTd: Resetting Word of the Day.")
|
||||
self.word_of_the_day_entry = random.choice(self.word_list)
|
||||
|
||||
def generate_leet_variants(self, word):
|
||||
chars = []
|
||||
for c in word.lower():
|
||||
if c in self.leet_dict:
|
||||
chars.append([c] + self.leet_dict[c])
|
||||
else:
|
||||
chars.append([c])
|
||||
variants = set()
|
||||
for combo in product(*chars):
|
||||
variant = ''.join(combo)
|
||||
variants.add(variant)
|
||||
if len(variants) > 128:
|
||||
break
|
||||
return variants
|
||||
|
||||
def did_it_happen(self, string_of_text=''):
|
||||
"""
|
||||
Check if the current word of the day (or its leet variants) appears in the text.
|
||||
Also check for a bingo win.
|
||||
Returns:
|
||||
(wotd_found, old_entry, new_entry, bingo_win, bingo_message)
|
||||
"""
|
||||
text = string_of_text.lower()
|
||||
words_in_text = set(text.split())
|
||||
word = self.word_of_the_day_entry['word'].lower()
|
||||
variants = self.generate_leet_variants(word)
|
||||
wotd_found = False
|
||||
old_entry = None
|
||||
new_entry = None
|
||||
|
||||
for variant in variants:
|
||||
if variant in words_in_text:
|
||||
old_entry = self.word_of_the_day_entry
|
||||
self.reset_word_of_the_day()
|
||||
new_entry = self.word_of_the_day_entry
|
||||
wotd_found = True
|
||||
break
|
||||
|
||||
bingo_win, bingo_message = self.b_i_n_g_o(string_of_text)
|
||||
return wotd_found, old_entry, new_entry, bingo_win, bingo_message
|
||||
|
||||
def generate_bingo_card(self, size=None):
|
||||
"""
|
||||
Generate a random bingo card of given size (size x size) from the bingoCardSet.
|
||||
Returns a 2D list representing the bingo card.
|
||||
"""
|
||||
if size is None:
|
||||
size = self.bingo_board_size
|
||||
words = random.sample(list(self.bingoCardSet), size * size)
|
||||
card = [words[i*size:(i+1)*size] for i in range(size)]
|
||||
self.bingo_card = card
|
||||
self.bingo_card_matches = [[False]*size for _ in range(size)]
|
||||
return card
|
||||
|
||||
def b_i_n_g_o(self, string_of_text=''):
|
||||
"""
|
||||
Check if any words in the text match the bingo card.
|
||||
If a row, column, or diagonal is fully matched, return True and the winning line.
|
||||
Otherwise, return False and None.
|
||||
"""
|
||||
if not hasattr(self, 'bingo_card'):
|
||||
logger.debug("System: WoTd: Generating new bingo card.")
|
||||
self.generate_bingo_card(self.bingo_board_size)
|
||||
|
||||
words_in_text = set(string_of_text.lower().split())
|
||||
size = len(self.bingo_card)
|
||||
# Mark matches
|
||||
for i in range(size):
|
||||
for j in range(size):
|
||||
if self.bingo_card[i][j].lower() in words_in_text:
|
||||
self.bingo_card_matches[i][j] = True
|
||||
|
||||
# Check rows
|
||||
for i in range(size):
|
||||
if all(self.bingo_card_matches[i]):
|
||||
winning_row = self.bingo_card[i]
|
||||
logger.debug("System: BINGO achieved, generating new bingo card.")
|
||||
self.generate_bingo_card(size) # Reset board after win
|
||||
return True, f"BINGO! Row {i+1}: {winning_row}"
|
||||
|
||||
# Check columns
|
||||
for j in range(size):
|
||||
if all(self.bingo_card_matches[i][j] for i in range(size)):
|
||||
col = [self.bingo_card[i][j] for i in range(size)]
|
||||
logger.debug("System: BINGO achieved, generating new bingo card.")
|
||||
self.generate_bingo_card(size) # Reset board after win
|
||||
return True, f"BINGO! Column {j+1}: {col}"
|
||||
|
||||
# Check diagonals
|
||||
if all(self.bingo_card_matches[i][i] for i in range(size)):
|
||||
diag = [self.bingo_card[i][i] for i in range(size)]
|
||||
logger.debug("System: BINGO achieved, generating new bingo card.")
|
||||
self.generate_bingo_card(size) # Reset board after win
|
||||
return True, f"BINGO! Diagonal: {diag}"
|
||||
if all(self.bingo_card_matches[i][size-1-i] for i in range(size)):
|
||||
diag = [self.bingo_card[i][size-1-i] for i in range(size)]
|
||||
logger.debug("System: BINGO achieved, generating new bingo card.")
|
||||
self.generate_bingo_card(size) # Reset board after win
|
||||
return True, f"BINGO! Diagonal: {diag}"
|
||||
|
||||
return False, None
|
||||
|
||||
def extract_emojis(self, text):
|
||||
emojis = []
|
||||
for char in text:
|
||||
cp = ord(char)
|
||||
# Common emoji Unicode ranges
|
||||
if (
|
||||
0x1F600 <= cp <= 0x1F64F or # Emoticons
|
||||
0x1F300 <= cp <= 0x1F5FF or # Symbols & pictographs
|
||||
0x1F680 <= cp <= 0x1F6FF or # Transport & map symbols
|
||||
0x1F1E6 <= cp <= 0x1F1FF or # Regional indicator symbols
|
||||
0x2600 <= cp <= 0x26FF or # Misc symbols
|
||||
0x2700 <= cp <= 0x27BF or # Dingbats
|
||||
0x1F900 <= cp <= 0x1F9FF or # Supplemental symbols & pictographs
|
||||
0x1FA70 <= cp <= 0x1FAFF or # Symbols & pictographs extended-A
|
||||
0x2B50 == cp or # Star
|
||||
0x2B55 == cp # Heavy large circle
|
||||
):
|
||||
emojis.append(char)
|
||||
return emojis
|
||||
|
||||
def emojiMiniGame(self, string_of_text='', nodeID=0, nodeInt=1, emojiSeen=False):
|
||||
from modules.system import meshLeaderboard
|
||||
"""
|
||||
Track emoji usage, Returns a string if the mini-game is won, else None.
|
||||
If emojiSeen is False, only update mostMessages leaderboard and skip emoji logic.
|
||||
"""
|
||||
|
||||
# Only increment for text/chat messages
|
||||
meshLeaderboard['nodeMessageCounts'][nodeID] = meshLeaderboard['nodeMessageCounts'].get(nodeID, 0) + 1
|
||||
|
||||
# Update mostMessages leaderboard
|
||||
if meshLeaderboard['nodeMessageCounts']:
|
||||
max_node = max(meshLeaderboard['nodeMessageCounts'], key=meshLeaderboard['nodeMessageCounts'].get)
|
||||
meshLeaderboard['mostMessages'] = {
|
||||
'nodeID': max_node,
|
||||
'value': meshLeaderboard['nodeMessageCounts'][max_node],
|
||||
'timestamp': time.time()
|
||||
}
|
||||
|
||||
|
||||
emoji = None # Placeholder: extract emoji from string_of_text if needed
|
||||
emojis = self.extract_emojis(string_of_text)
|
||||
if not emojiSeen and not emojis:
|
||||
return None
|
||||
logger.debug(f"System: WoTd: Emoji mini-game processing for nodeID {nodeID} with emojis: {emojis}")
|
||||
# --- 1. Update meshLeaderboard for emoji usage ---
|
||||
if 'emojiCounts' not in meshLeaderboard:
|
||||
meshLeaderboard['emojiCounts'] = {}
|
||||
if 'emojiTypeCounts' not in meshLeaderboard:
|
||||
meshLeaderboard['emojiTypeCounts'] = {}
|
||||
|
||||
meshLeaderboard['emojiCounts'].setdefault(nodeID, {})
|
||||
for emoji in emojis:
|
||||
meshLeaderboard['emojiCounts'][nodeID][emoji] = meshLeaderboard['emojiCounts'][nodeID].get(emoji, 0) + 1
|
||||
|
||||
# --- Update the leaderboard record for most emojis ---
|
||||
# Flatten per-node emoji counts to total per node
|
||||
emoji_totals = {nid: sum(emojicounts.values()) for nid, emojicounts in meshLeaderboard['emojiCounts'].items() if isinstance(emojicounts, dict)}
|
||||
if emoji_totals:
|
||||
max_node = max(emoji_totals, key=emoji_totals.get)
|
||||
meshLeaderboard['mostEmojis'] = {
|
||||
'nodeID': max_node,
|
||||
'value': emoji_totals[max_node],
|
||||
'timestamp': time.time()
|
||||
}
|
||||
|
||||
# --- 2. Track most used of a type (e.g., smileys, animals, etc.) ---
|
||||
emoji_type = self.get_emoji_type(emoji)
|
||||
meshLeaderboard['emojiTypeCounts'].setdefault(emoji_type, {})
|
||||
meshLeaderboard['emojiTypeCounts'][emoji_type][emoji] = meshLeaderboard['emojiTypeCounts'][emoji_type].get(emoji, 0) + 1
|
||||
|
||||
# --- 3. Slot machine mini-game ---
|
||||
if 'emojiSlotWindow' not in meshLeaderboard:
|
||||
meshLeaderboard['emojiSlotWindow'] = []
|
||||
meshLeaderboard['emojiSlotWindow'].append(emoji)
|
||||
# Randomize jackpot length after each win
|
||||
if not hasattr(self, 'slot_jackpot_length'):
|
||||
self.slot_jackpot_length = random.choice([3,4,5]) # JackPot length can be 3, 4, or 5
|
||||
if len(meshLeaderboard['emojiSlotWindow']) > self.slot_jackpot_length:
|
||||
meshLeaderboard['emojiSlotWindow'].pop(0)
|
||||
|
||||
# --- 3a. Detect spam of 3 identical emojis in a row ---
|
||||
if len(meshLeaderboard['emojiSlotWindow']) >= 5:
|
||||
last_three = meshLeaderboard['emojiSlotWindow'][-3:]
|
||||
if len(set(last_three)) == 1:
|
||||
# Option: Randomly add an emoji to break the spam
|
||||
random_emoji = self.get_emoji_type('', randomReturn=True)
|
||||
meshLeaderboard['emojiSlotWindow'].append(random_emoji)
|
||||
logger.debug(f"System: WoTd: Detected emoji spam, added random emoji '{random_emoji}' to slot window.")
|
||||
# Optionally, you can still scramble or pop as well if you want
|
||||
random.shuffle(meshLeaderboard['emojiSlotWindow'])
|
||||
meshLeaderboard['emojiSlotWindow'].pop()
|
||||
|
||||
# # Debug: Show slot window status before jackpot check
|
||||
# logger.debug(
|
||||
# f"Emoji Slot Window: {meshLeaderboard['emojiSlotWindow']} | "
|
||||
# f"Jackpot Length: {self.slot_jackpot_length} | "
|
||||
# f"Unique: {set(meshLeaderboard['emojiSlotWindow'])} | "
|
||||
# f"Needed: {self.slot_jackpot_length - len(meshLeaderboard['emojiSlotWindow'])}"
|
||||
# )
|
||||
|
||||
# Jackpot: all emojis in window are the same
|
||||
if (
|
||||
len(meshLeaderboard['emojiSlotWindow']) == self.slot_jackpot_length and
|
||||
len(set(meshLeaderboard['emojiSlotWindow'])) == 1
|
||||
):
|
||||
winner_msg = f"🎰 JACKPOT! {emoji * self.slot_jackpot_length}"
|
||||
meshLeaderboard['emojiSlotWindow'] = []
|
||||
self.slot_jackpot_length = random.choice([3, 4, 5]) # Randomize jackpot length after win
|
||||
return winner_msg
|
||||
|
||||
return None
|
||||
|
||||
# Example usage:
|
||||
# theWordOfTheDay = WordOfTheDayGame()
|
||||
# happened, entry = theWordOfTheDay.did_it_happen("I love serendipity!")
|
||||
# if happened:
|
||||
# print(f"Found the word of the day: {entry['word']} - {entry['meta']}")
|
||||
133
modules/globalalert.py
Normal file
133
modules/globalalert.py
Normal file
@@ -0,0 +1,133 @@
|
||||
# helper functions to use location data for data outside US/north america
|
||||
# K7MHI Kelly Keeton 2024
|
||||
|
||||
import json # pip install json
|
||||
#from geopy.geocoders import Nominatim # pip install geopy
|
||||
#import maidenhead as mh # pip install maidenhead
|
||||
import requests # pip install requests
|
||||
import bs4 as bs # pip install beautifulsoup4
|
||||
#import xml.dom.minidom
|
||||
from modules.log import logger
|
||||
from modules.settings import urlTimeoutSeconds, NO_ALERTS, myRegionalKeysDE
|
||||
|
||||
trap_list_location_eu = ("ukalert",)
|
||||
trap_list_location_de = ("dealert",)
|
||||
|
||||
def get_govUK_alerts(lat, lon):
|
||||
try:
|
||||
# get UK.gov alerts
|
||||
url = 'https://www.gov.uk/alerts'
|
||||
response = requests.get(url, timeout=urlTimeoutSeconds)
|
||||
soup = bs.BeautifulSoup(response.text, 'html.parser')
|
||||
# the alerts are in <h2 class="govuk-heading-m" id="alert-status">
|
||||
alert = soup.find('h2', class_='govuk-heading-m', id='alert-status')
|
||||
except Exception as e:
|
||||
logger.warning("Error getting UK alerts: " + str(e))
|
||||
return
|
||||
|
||||
if alert:
|
||||
return "🚨" + alert.get_text(strip=True)
|
||||
else:
|
||||
return NO_ALERTS
|
||||
|
||||
def get_nina_alerts():
|
||||
try:
|
||||
# get api.bund.dev alerts
|
||||
alerts = []
|
||||
for regionalKey in myRegionalKeysDE:
|
||||
url = ("https://nina.api.proxy.bund.dev/api31/dashboard/" + regionalKey + ".json")
|
||||
response = requests.get(url, timeout=urlTimeoutSeconds)
|
||||
data = response.json()
|
||||
|
||||
for item in data:
|
||||
title = item["i18nTitle"]["de"]
|
||||
alerts.append(f"🚨 {title}")
|
||||
return "\n".join(alerts) if alerts else NO_ALERTS
|
||||
except Exception as e:
|
||||
logger.warning("Error getting NINA DE alerts: " + str(e))
|
||||
return NO_ALERTS
|
||||
|
||||
def get_wxUKgov():
|
||||
# get UK weather warnings, these look icky
|
||||
url = 'https://www.metoffice.gov.uk/weather/guides/rss'
|
||||
url = 'https://www.metoffice.gov.uk/public/data/PWSCache/WarningsRSS/Region/nw'
|
||||
try:
|
||||
# get UK weather warnings
|
||||
url = 'https://www.metoffice.gov.uk/weather/guides/rss'
|
||||
response = requests.get(url, timeout=urlTimeoutSeconds)
|
||||
soup = bs.BeautifulSoup(response.content, 'xml')
|
||||
|
||||
items = soup.find_all('item')
|
||||
alerts = []
|
||||
|
||||
for item in items:
|
||||
title = item.find('title').get_text(strip=True)
|
||||
description = item.find('description').get_text(strip=True)
|
||||
alerts.append(f"🚨 {title}: {description}")
|
||||
|
||||
return "\n".join(alerts) if alerts else NO_ALERTS
|
||||
except Exception as e:
|
||||
logger.warning("Error getting UK weather warnings: " + str(e))
|
||||
return NO_ALERTS
|
||||
|
||||
|
||||
def get_floodUKgov():
|
||||
# get UK flood warnings, there is so much I need a locals help
|
||||
url = 'https://environment.data.gov.uk/flood-widgets/rss/feed-England.xml'
|
||||
|
||||
return NO_ALERTS
|
||||
|
||||
def get_crimeUKgov(lat, lon):
|
||||
"""
|
||||
Fetches recent street crime data from UK Police API for given lat/lon.
|
||||
Returns a summary string or NO_ALERTS. -- pay for use?
|
||||
"""
|
||||
date = datetime.datetime.now().strftime("%Y-%m")
|
||||
url = f'https://data.police.uk/api/crimes-street/all-crime?date={date}&lat={lat}&lng={lon}'
|
||||
try:
|
||||
response = requests.get(url, timeout=urlTimeoutSeconds)
|
||||
if not response.ok or not response.text.strip():
|
||||
return NO_ALERTS
|
||||
crimes = response.json()
|
||||
if not crimes:
|
||||
return NO_ALERTS
|
||||
# Summarize the first few crimes
|
||||
summaries = []
|
||||
for crime in crimes[:3]:
|
||||
category = crime.get("category", "Unknown")
|
||||
outcome = crime.get("outcome_status", {}).get("category", "No outcome")
|
||||
location = crime.get("location", {}).get("street", {}).get("name", "Unknown location")
|
||||
summaries.append(f"{category.title()} at {location} ({outcome})")
|
||||
return "\n".join(summaries)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error fetching UK crime data: {e}")
|
||||
return NO_ALERTS
|
||||
|
||||
def get_crime_stopsUKgov(lat, lon):
|
||||
"""
|
||||
Fetches recent stop-and-search data from UK Police API for given lat/lon.
|
||||
Returns a summary string or NO_ALERTS. -- pay for use?
|
||||
"""
|
||||
date = datetime.datetime.now().strftime("%Y-%m")
|
||||
url = f'https://data.police.uk/api/stops-street?date={date}&lat={lat}&lng={lon}'
|
||||
try:
|
||||
response = requests.get(url, timeout=urlTimeoutSeconds)
|
||||
if not response.ok or not response.text.strip():
|
||||
return NO_ALERTS
|
||||
stops = response.json()
|
||||
if not stops:
|
||||
return NO_ALERTS
|
||||
# Summarize the first few stops
|
||||
summaries = []
|
||||
for stop in stops[:3]: # Limit to first 3 stops for brevity
|
||||
summary = (
|
||||
f"Date: {stop.get('datetime', 'N/A')}, "
|
||||
f"Outcome: {stop.get('outcome', 'N/A')}, "
|
||||
f"Ethnicity: {stop.get('self_defined_ethnicity', 'N/A')}, "
|
||||
f"Gender: {stop.get('gender', 'N/A')}, "
|
||||
f"Location: {stop.get('location', {}).get('street', {}).get('name', 'N/A')}"
|
||||
)
|
||||
summaries.append(summary)
|
||||
return "\n".join(summaries)
|
||||
except Exception as e:
|
||||
return NO_ALERTS
|
||||
73
modules/gpio.py
Normal file
73
modules/gpio.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# GPIO module for MeshLink, concept code, not implemented
|
||||
# K7MHI Kelly Keeton 2024
|
||||
|
||||
# https://pypi.org/project/gpio/
|
||||
#import gpio
|
||||
|
||||
# https://pythonhosted.org/RPIO/
|
||||
import RPIO
|
||||
|
||||
from modules.log import logger, getPrettyTime
|
||||
trap_list_gpio = ("gpio", "pin", "relay", "switch", "pwm")
|
||||
|
||||
# set up input channel without pull-up
|
||||
RPIO.setup(7, RPIO.IN)
|
||||
|
||||
# set up input channel with pull-up
|
||||
RPIO.setup(8, RPIO.IN, pull_up_down=RPIO.PUD_UP)
|
||||
|
||||
# set up GPIO output channel
|
||||
RPIO.setup(8, RPIO.OUT)
|
||||
|
||||
# change to BOARD numbering schema
|
||||
RPIO.setmode(RPIO.BOARD)
|
||||
|
||||
# set up PWM channel
|
||||
RPIO.setup(12, RPIO.OUT)
|
||||
p = RPIO.PWM(12)
|
||||
|
||||
def gpio_status():
|
||||
# get status of GPIO pins
|
||||
gpio_status = ""
|
||||
gpio_status += "GPIO 7: " + str(RPIO.input(7)) + "\n"
|
||||
gpio_status += "GPIO 8: " + str(RPIO.input(8)) + "\n"
|
||||
gpio_status += "GPIO 12: " + str(RPIO.input(12)) + "\n"
|
||||
return gpio_status
|
||||
|
||||
def gpio_toggle():
|
||||
# toggle GPIO pin 8
|
||||
RPIO.output(8, not RPIO.input(8))
|
||||
return "GPIO 8 toggled"
|
||||
|
||||
def gpio_pwm():
|
||||
# set PWM on GPIO pin 12
|
||||
p.start(50)
|
||||
return "PWM started"
|
||||
|
||||
def gpio_stop():
|
||||
# stop PWM on GPIO pin 12
|
||||
p.stop()
|
||||
return "PWM stopped"
|
||||
|
||||
def gpio_shutdown():
|
||||
# shutdown GPIO
|
||||
RPIO.cleanup()
|
||||
return "GPIO shutdown"
|
||||
|
||||
def trap_gpio(message):
|
||||
# trap for GPIO commands
|
||||
if "status" in message:
|
||||
return gpio_status()
|
||||
elif "toggle" in message:
|
||||
return gpio_toggle()
|
||||
elif "pwm" in message:
|
||||
return gpio_pwm()
|
||||
elif "stop" in message:
|
||||
return gpio_stop()
|
||||
elif "shutdown" in message:
|
||||
return gpio_shutdown()
|
||||
else:
|
||||
return "GPIO command not recognized"
|
||||
|
||||
|
||||
|
||||
423
modules/inventory.md
Normal file
423
modules/inventory.md
Normal file
@@ -0,0 +1,423 @@
|
||||
# Inventory & Point of Sale System
|
||||
|
||||
## Overview
|
||||
|
||||
The inventory module provides a simple point-of-sale (POS) system for mesh networks, enabling inventory management, sales tracking, and cart-based transactions. This system is ideal for:
|
||||
|
||||
- Emergency supply management
|
||||
- Event merchandise sales
|
||||
- Community supply tracking
|
||||
- Remote location inventory
|
||||
- Asset management
|
||||
- Field operations logistics
|
||||
- Tool lending in makerspaces or ham swaps
|
||||
- Tracking and lending shared items like Legos or kits
|
||||
|
||||
> **Tool Lending & Shared Item Tracking:**
|
||||
> The system supports lending out tools or kits (e.g., in a makerspace or ham swap) using the `itemloan` and `itemreturn` commands. You can also track bulk or set-based items like Legos, manage their locations, and log checkouts and returns for community sharing or events.
|
||||
|
||||
## Features
|
||||
|
||||
### 🏪 Simple POS System
|
||||
- **Item Management**: Add, remove, and update inventory items
|
||||
- **Cart System**: Build orders before completing transactions
|
||||
- **Transaction Logging**: Full audit trail of all sales and returns
|
||||
- **Price Tracking**: Track price changes over time
|
||||
- **Location Tracking**: Optional warehouse/location field for items
|
||||
|
||||
### 💰 Financial Features
|
||||
- **Penny Rounding**: USA cash sales support
|
||||
- Cash sales round down to nearest nickel
|
||||
- Taxed sales round up to nearest nickel
|
||||
- **Daily Statistics**: Track sales performance
|
||||
- **Hot Item Detection**: Identify best-selling products
|
||||
- **Revenue Tracking**: Daily sales totals
|
||||
|
||||
### 📊 Reporting
|
||||
- **Inventory Value**: Total inventory worth
|
||||
- **Sales Reports**: Daily transaction summaries
|
||||
- **Best Sellers**: Most popular items
|
||||
|
||||
**Cart System:**
|
||||
- `cartadd <name> <qty>` - Add to cart
|
||||
- `cartremove <name>` - Remove from cart
|
||||
- `cartlist` / `cart` - View cart
|
||||
- `cartbuy` / `cartsell [notes]` - Complete transaction
|
||||
- `cartclear` - Empty cart
|
||||
|
||||
**Item Management:**
|
||||
- `itemadd <name> <qty> [price] [loc]` - Add new item
|
||||
- `itemremove <name>` - Remove item
|
||||
- `itemreset name> <qty> [price] [loc]` - Update item
|
||||
- `itemsell <name> <qty> [notes]` - Quick sale
|
||||
- `itemloan <name> <note>` - Loan/checkout an item
|
||||
- `itemreturn <transaction_id>` - Reverse transaction
|
||||
- `itemlist` - View all inventory
|
||||
- `itemstats` - Daily statistics
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
Add to your `config.ini`:
|
||||
|
||||
```ini
|
||||
[inventory]
|
||||
enabled = True
|
||||
inventory_db = data/inventory.db
|
||||
# Set to True to disable penny precision and round to nickels (USA cash sales)
|
||||
# When True: cash sales round down, taxed sales round up to nearest $0.05
|
||||
# When False (default): normal penny precision ($0.01)
|
||||
disable_penny = False
|
||||
```
|
||||
|
||||
## Commands Reference
|
||||
|
||||
### Item Management
|
||||
|
||||
#### Add Item
|
||||
```
|
||||
itemadd <name> <price> <quantity> [location]
|
||||
```
|
||||
|
||||
Adds a new item to inventory.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
itemadd Radio 149.99 5 Shelf-A
|
||||
itemadd Battery 12.50 20 Warehouse
|
||||
itemadd Water 1.00 100
|
||||
```
|
||||
|
||||
#### Remove Item
|
||||
```
|
||||
itemremove <name>
|
||||
```
|
||||
|
||||
Removes an item from inventory (also removes from all carts).
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
itemremove Radio
|
||||
itemremove "First Aid Kit"
|
||||
```
|
||||
|
||||
#### Update Item
|
||||
```
|
||||
itemreset <name> [price=X] [qty=Y]
|
||||
```
|
||||
|
||||
Updates item price and/or quantity.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
itemreset Radio price=139.99
|
||||
itemreset Battery qty=50
|
||||
itemreset Water price=0.95 qty=200
|
||||
```
|
||||
|
||||
#### Quick Sale
|
||||
```
|
||||
itemsell <name> <quantity> [notes]
|
||||
```
|
||||
|
||||
Sell directly without using cart (for quick transactions).
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
itemsell Battery 2
|
||||
itemsell Water 10 Emergency supply
|
||||
itemsell Radio 1 Field unit sale
|
||||
```
|
||||
|
||||
#### Return Transaction
|
||||
```
|
||||
itemreturn <transaction_id>
|
||||
```
|
||||
|
||||
Reverse a transaction and return items to inventory.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
itemreturn 123
|
||||
itemreturn 45
|
||||
```
|
||||
|
||||
#### List Inventory
|
||||
```
|
||||
itemlist
|
||||
```
|
||||
|
||||
Shows all items with prices, quantities, and total inventory value.
|
||||
|
||||
**Example Response:**
|
||||
```
|
||||
📦 Inventory:
|
||||
Radio: $149.99 x 5 @ Shelf-A = $749.95
|
||||
Battery: $12.50 x 20 @ Warehouse = $250.00
|
||||
Water: $1.00 x 100 = $100.00
|
||||
|
||||
Total Value: $1,099.95
|
||||
```
|
||||
|
||||
#### Statistics
|
||||
```
|
||||
itemstats
|
||||
```
|
||||
|
||||
Shows today's sales performance.
|
||||
|
||||
**Example Response:**
|
||||
```
|
||||
📊 Today's Stats:
|
||||
Sales: 15
|
||||
Revenue: $423.50
|
||||
Hot Item: Battery (8 sold)
|
||||
```
|
||||
|
||||
### Cart System
|
||||
|
||||
#### Add to Cart
|
||||
```
|
||||
cartadd <name> <quantity>
|
||||
```
|
||||
|
||||
Add items to your shopping cart.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
cartadd Radio 2
|
||||
cartadd Battery 4
|
||||
cartadd Water 12
|
||||
```
|
||||
|
||||
#### Remove from Cart
|
||||
```
|
||||
cartremove <name>
|
||||
```
|
||||
|
||||
Remove items from cart.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
cartremove Radio
|
||||
cartremove Battery
|
||||
```
|
||||
|
||||
#### View Cart
|
||||
```
|
||||
cart
|
||||
cartlist
|
||||
```
|
||||
|
||||
Display your current cart contents and total.
|
||||
|
||||
**Example Response:**
|
||||
```
|
||||
🛒 Your Cart:
|
||||
Radio: $149.99 x 2 = $299.98
|
||||
Battery: $12.50 x 4 = $50.00
|
||||
|
||||
Total: $349.98
|
||||
```
|
||||
|
||||
#### Complete Transaction
|
||||
```
|
||||
cartbuy [notes]
|
||||
cartsell [notes]
|
||||
```
|
||||
|
||||
Process the cart as a transaction. Use `cartbuy` for purchases (adds to inventory) or `cartsell` for sales (removes from inventory).
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
cartsell Customer purchase
|
||||
cartbuy Restocking supplies
|
||||
cartsell Event merchandise
|
||||
```
|
||||
|
||||
#### Clear Cart
|
||||
```
|
||||
cartclear
|
||||
```
|
||||
|
||||
Empty your shopping cart without completing a transaction.
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Event Merchandise Sales
|
||||
|
||||
Perfect for festivals, hamfests, or community events:
|
||||
|
||||
```
|
||||
# Setup inventory
|
||||
itemadd Tshirt 20.00 50 Booth-A
|
||||
itemadd Hat 15.00 30 Booth-A
|
||||
itemadd Sticker 5.00 100 Booth-B
|
||||
|
||||
# Customer transaction
|
||||
cartadd Tshirt 2
|
||||
cartadd Hat 1
|
||||
cartsell Festival sale
|
||||
|
||||
# Check daily performance
|
||||
itemstats
|
||||
```
|
||||
|
||||
### 2. Emergency Supply Tracking
|
||||
|
||||
Track supplies during disaster response:
|
||||
|
||||
```
|
||||
# Add emergency supplies
|
||||
itemadd Water 0.00 500 Warehouse-1
|
||||
itemadd MRE 0.00 200 Warehouse-1
|
||||
itemadd Blanket 0.00 100 Warehouse-2
|
||||
|
||||
# Distribute supplies
|
||||
itemsell Water 50 Red Cross distribution
|
||||
itemsell MRE 20 Family shelter
|
||||
|
||||
# Check remaining inventory
|
||||
itemlist
|
||||
```
|
||||
|
||||
### 3. Field Equipment Management
|
||||
|
||||
Manage tools and equipment in remote locations:
|
||||
|
||||
```
|
||||
# Track equipment
|
||||
itemadd Generator 500.00 3 Base-Camp
|
||||
itemadd Radio 200.00 10 Equipment-Room
|
||||
itemadd Battery 15.00 50 Supply-Closet
|
||||
|
||||
# Equipment checkout
|
||||
itemsell Generator 1 Field deployment
|
||||
itemsell Radio 5 Survey team
|
||||
|
||||
# Monitor inventory
|
||||
itemlist
|
||||
itemstats
|
||||
```
|
||||
|
||||
### 4. Community Supply Exchange
|
||||
|
||||
Facilitate supply exchanges within a community:
|
||||
|
||||
```
|
||||
# Add community items
|
||||
itemadd Seeds 2.00 100 Community-Garden
|
||||
itemadd Firewood 10.00 20 Storage-Shed
|
||||
|
||||
# Member transactions
|
||||
cartadd Seeds 5
|
||||
cartadd Firewood 2
|
||||
cartsell Member-123 purchase
|
||||
```
|
||||
|
||||
## Penny Rounding (USA Mode)
|
||||
|
||||
When `disable_penny = True` is set in the configuration, the system implements penny rounding (disabling penny precision). This follows USA practice where pennies are not commonly used in cash transactions.
|
||||
|
||||
### Cash Sales (Round Down)
|
||||
- $10.47 → $10.45
|
||||
- $10.48 → $10.45
|
||||
- $10.49 → $10.45
|
||||
|
||||
### Taxed Sales (Round Up)
|
||||
- $10.47 → $10.50
|
||||
- $10.48 → $10.50
|
||||
- $10.49 → $10.50
|
||||
|
||||
This follows common USA practice where pennies are not used in cash transactions.
|
||||
|
||||
## Database Schema
|
||||
|
||||
The system uses SQLite with four tables:
|
||||
|
||||
### items
|
||||
```sql
|
||||
CREATE TABLE items (
|
||||
item_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_name TEXT UNIQUE NOT NULL,
|
||||
item_price REAL NOT NULL,
|
||||
item_quantity INTEGER NOT NULL DEFAULT 0,
|
||||
location TEXT,
|
||||
created_date TEXT,
|
||||
updated_date TEXT
|
||||
)
|
||||
```
|
||||
|
||||
### transactions
|
||||
```sql
|
||||
CREATE TABLE transactions (
|
||||
transaction_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transaction_type TEXT NOT NULL,
|
||||
transaction_date TEXT NOT NULL,
|
||||
transaction_time TEXT NOT NULL,
|
||||
user_name TEXT,
|
||||
total_amount REAL NOT NULL,
|
||||
notes TEXT
|
||||
)
|
||||
```
|
||||
|
||||
### transaction_items
|
||||
```sql
|
||||
CREATE TABLE transaction_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transaction_id INTEGER NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
price_at_sale REAL NOT NULL,
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(transaction_id),
|
||||
FOREIGN KEY (item_id) REFERENCES items(item_id)
|
||||
)
|
||||
```
|
||||
|
||||
### carts
|
||||
```sql
|
||||
CREATE TABLE carts (
|
||||
cart_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
added_date TEXT,
|
||||
FOREIGN KEY (item_id) REFERENCES items(item_id)
|
||||
)
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Users on the `bbs_ban_list` cannot use inventory commands
|
||||
- Each user has their own cart (identified by node ID)
|
||||
- Transactions are logged with user information for accountability
|
||||
- All database operations use parameterized queries to prevent SQL injection
|
||||
|
||||
## Tips and Best Practices
|
||||
|
||||
1. **Regular Inventory Checks**: Use `itemlist` regularly to monitor stock levels
|
||||
2. **Descriptive Notes**: Add notes to transactions for better tracking
|
||||
3. **Location Tags**: Use consistent location naming for better organization
|
||||
4. **Daily Reviews**: Check `itemstats` at the end of each day
|
||||
5. **Transaction IDs**: Keep track of transaction IDs for potential returns
|
||||
6. **Quantity Updates**: Use `itemreset` to adjust inventory after physical counts
|
||||
7. **Cart Cleanup**: Use `cartclear` if you change your mind before completing a sale
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Item Already Exists
|
||||
If you get "Item already exists" when using `itemadd`, use `itemreset` instead to update the existing item.
|
||||
|
||||
### Insufficient Quantity
|
||||
If you see "Insufficient quantity" error, check available stock with `itemlist` before attempting the sale.
|
||||
|
||||
### Transaction Not Found
|
||||
If `itemreturn` fails, verify the transaction ID exists. Use recent transaction logs to find valid IDs.
|
||||
|
||||
### Cart Not Showing Items
|
||||
Each user has their own cart. Make sure you're using your own node to view your cart.
|
||||
|
||||
|
||||
## Support
|
||||
|
||||
For issues or feature requests, please file an issue on the GitHub repository.
|
||||
747
modules/inventory.py
Normal file
747
modules/inventory.py
Normal file
@@ -0,0 +1,747 @@
|
||||
# Inventory and Point of Sale module for the bot
|
||||
# K7MHI Kelly Keeton 2024
|
||||
# Enhanced POS system with cart management and inventory tracking
|
||||
|
||||
import sqlite3
|
||||
from modules.log import logger
|
||||
from modules.settings import inventory_db, disable_penny, bbs_ban_list
|
||||
import time
|
||||
from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN
|
||||
|
||||
trap_list_inventory = ("item", "itemlist", "itemloan", "itemsell", "itemreturn", "itemadd", "itemremove",
|
||||
"itemreset", "itemstats", "cart", "cartadd", "cartremove", "cartlist",
|
||||
"cartbuy", "cartsell", "cartclear")
|
||||
|
||||
def initialize_inventory_database():
|
||||
"""Initialize the inventory database with all necessary tables"""
|
||||
try:
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
|
||||
# Items table - stores inventory items
|
||||
logger.debug("System: Inventory: Initializing database...")
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS items
|
||||
(item_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_name TEXT UNIQUE NOT NULL,
|
||||
item_price REAL NOT NULL,
|
||||
item_quantity INTEGER NOT NULL DEFAULT 0,
|
||||
location TEXT,
|
||||
created_date TEXT,
|
||||
updated_date TEXT)''')
|
||||
|
||||
# Transactions table - stores sales/purchases
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS transactions
|
||||
(transaction_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transaction_type TEXT NOT NULL,
|
||||
transaction_date TEXT NOT NULL,
|
||||
transaction_time TEXT NOT NULL,
|
||||
user_name TEXT,
|
||||
total_amount REAL NOT NULL,
|
||||
notes TEXT)''')
|
||||
|
||||
# Transaction items table - stores items in each transaction
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS transaction_items
|
||||
(id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transaction_id INTEGER NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
price_at_sale REAL NOT NULL,
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(transaction_id),
|
||||
FOREIGN KEY (item_id) REFERENCES items(item_id))''')
|
||||
|
||||
# Carts table - stores temporary shopping carts
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS carts
|
||||
(cart_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
added_date TEXT,
|
||||
FOREIGN KEY (item_id) REFERENCES items(item_id))''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info("Inventory: Database initialized successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Inventory: Failed to initialize database: {e}")
|
||||
return False
|
||||
|
||||
def round_price(amount, is_taxed_sale=False):
|
||||
"""Round price based on penny rounding settings"""
|
||||
if not disable_penny:
|
||||
return float(Decimal(str(amount)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP))
|
||||
|
||||
# Penny rounding logic
|
||||
decimal_amount = Decimal(str(amount))
|
||||
if is_taxed_sale:
|
||||
# Round up for taxed sales
|
||||
return float(decimal_amount.quantize(Decimal('0.05'), rounding=ROUND_HALF_UP))
|
||||
else:
|
||||
# Round down for cash sales
|
||||
return float(decimal_amount.quantize(Decimal('0.05'), rounding=ROUND_DOWN))
|
||||
|
||||
def add_item(name, price, quantity=0, location=""):
|
||||
"""Add a new item to inventory"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
|
||||
try:
|
||||
# Check if item already exists
|
||||
c.execute("SELECT item_id FROM items WHERE item_name = ?", (name,))
|
||||
existing = c.fetchone()
|
||||
if existing:
|
||||
conn.close()
|
||||
return f"Item '{name}' already exists. Use itemreset to update."
|
||||
|
||||
c.execute("""INSERT INTO items (item_name, item_price, item_quantity, location, created_date, updated_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(name, price, quantity, location, current_date, current_date))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"✅ Item added: {name} - ${price:.2f} - Qty: {quantity}"
|
||||
except sqlite3.OperationalError as e:
|
||||
if "no such table" in str(e):
|
||||
initialize_inventory_database()
|
||||
return add_item(name, price, quantity, location)
|
||||
else:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error adding item: {e}")
|
||||
return "Error adding item."
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error adding item: {e}")
|
||||
return "Error adding item."
|
||||
|
||||
def remove_item(name):
|
||||
"""Remove an item from inventory"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
|
||||
try:
|
||||
c.execute("DELETE FROM items WHERE item_name = ?", (name,))
|
||||
if c.rowcount == 0:
|
||||
conn.close()
|
||||
return f"Item '{name}' not found."
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"🗑️ Item removed: {name}"
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error removing item: {e}")
|
||||
return "Error removing item."
|
||||
|
||||
def reset_item(name, price=None, quantity=None):
|
||||
"""Update item price or quantity"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
|
||||
try:
|
||||
# Check if item exists
|
||||
c.execute("SELECT item_price, item_quantity FROM items WHERE item_name = ?", (name,))
|
||||
item = c.fetchone()
|
||||
if not item:
|
||||
conn.close()
|
||||
return f"Item '{name}' not found."
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if price is not None:
|
||||
updates.append("item_price = ?")
|
||||
params.append(price)
|
||||
|
||||
if quantity is not None:
|
||||
updates.append("item_quantity = ?")
|
||||
params.append(quantity)
|
||||
|
||||
if not updates:
|
||||
conn.close()
|
||||
return "No updates specified."
|
||||
|
||||
updates.append("updated_date = ?")
|
||||
params.append(current_date)
|
||||
params.append(name)
|
||||
|
||||
query = f"UPDATE items SET {', '.join(updates)} WHERE item_name = ?"
|
||||
c.execute(query, params)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
update_msg = []
|
||||
if price is not None:
|
||||
update_msg.append(f"Price: ${price:.2f}")
|
||||
if quantity is not None:
|
||||
update_msg.append(f"Qty: {quantity}")
|
||||
|
||||
return f"🔄 Item updated: {name} - {' - '.join(update_msg)}"
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error resetting item: {e}")
|
||||
return "Error updating item."
|
||||
|
||||
def sell_item(name, quantity, user_name="", notes=""):
|
||||
"""Sell an item (remove from inventory and record transaction)"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
current_time = time.strftime("%H:%M:%S")
|
||||
|
||||
try:
|
||||
# Get item details
|
||||
c.execute("SELECT item_id, item_price, item_quantity FROM items WHERE item_name = ?", (name,))
|
||||
item = c.fetchone()
|
||||
if not item:
|
||||
conn.close()
|
||||
return f"Item '{name}' not found."
|
||||
|
||||
item_id, price, current_qty = item
|
||||
|
||||
if current_qty < quantity:
|
||||
conn.close()
|
||||
return f"Insufficient quantity. Available: {current_qty}"
|
||||
|
||||
# Calculate total with rounding
|
||||
total = round_price(price * quantity, is_taxed_sale=True)
|
||||
|
||||
# Create transaction
|
||||
c.execute("""INSERT INTO transactions (transaction_type, transaction_date, transaction_time,
|
||||
user_name, total_amount, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
("SALE", current_date, current_time, user_name, total, notes))
|
||||
transaction_id = c.lastrowid
|
||||
|
||||
# Add transaction item
|
||||
c.execute("""INSERT INTO transaction_items (transaction_id, item_id, quantity, price_at_sale)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(transaction_id, item_id, quantity, price))
|
||||
|
||||
# Update inventory
|
||||
c.execute("UPDATE items SET item_quantity = item_quantity - ?, updated_date = ? WHERE item_id = ?",
|
||||
(quantity, current_date, item_id))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"💰 Sale: {quantity}x {name} - Total: ${total:.2f}"
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error selling item: {e}")
|
||||
return "Error processing sale."
|
||||
|
||||
def return_item(transaction_id):
|
||||
"""Return items from a transaction (reverse the sale or loan)"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
|
||||
try:
|
||||
# Get transaction details
|
||||
c.execute("SELECT transaction_type FROM transactions WHERE transaction_id = ?", (transaction_id,))
|
||||
transaction = c.fetchone()
|
||||
if not transaction:
|
||||
conn.close()
|
||||
return f"Transaction {transaction_id} not found."
|
||||
transaction_type = transaction[0]
|
||||
|
||||
# Get items in transaction
|
||||
c.execute("""SELECT ti.item_id, ti.quantity, i.item_name
|
||||
FROM transaction_items ti
|
||||
JOIN items i ON ti.item_id = i.item_id
|
||||
WHERE ti.transaction_id = ?""", (transaction_id,))
|
||||
items = c.fetchall()
|
||||
|
||||
if not items:
|
||||
conn.close()
|
||||
return f"No items found for transaction {transaction_id}."
|
||||
|
||||
# Return items to inventory
|
||||
for item_id, quantity, item_name in items:
|
||||
c.execute("UPDATE items SET item_quantity = item_quantity + ?, updated_date = ? WHERE item_id = ?",
|
||||
(quantity, current_date, item_id))
|
||||
|
||||
# Remove transaction and transaction_items
|
||||
c.execute("DELETE FROM transactions WHERE transaction_id = ?", (transaction_id,))
|
||||
c.execute("DELETE FROM transaction_items WHERE transaction_id = ?", (transaction_id,))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if transaction_type == "LOAN":
|
||||
return f"↩️ Loan {transaction_id} returned. Item(s) back in inventory."
|
||||
else:
|
||||
return f"↩️ Transaction {transaction_id} reversed. Items returned to inventory."
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error returning item: {e}")
|
||||
return "Error processing return."
|
||||
|
||||
def loan_item(name, user_name="", note=""):
|
||||
"""Loan an item (checkout/loan to someone, record transaction)"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
current_time = time.strftime("%H:%M:%S")
|
||||
|
||||
try:
|
||||
# Get item details
|
||||
c.execute("SELECT item_id, item_price, item_quantity FROM items WHERE item_name = ?", (name,))
|
||||
item = c.fetchone()
|
||||
if not item:
|
||||
conn.close()
|
||||
return f"Item '{name}' not found."
|
||||
item_id, price, current_qty = item
|
||||
|
||||
if current_qty < 1:
|
||||
conn.close()
|
||||
return f"Insufficient quantity. Available: {current_qty}"
|
||||
|
||||
# Create loan transaction (quantity always 1 for now)
|
||||
c.execute("""INSERT INTO transactions (transaction_type, transaction_date, transaction_time,
|
||||
user_name, total_amount, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
("LOAN", current_date, current_time, user_name, 0.0, note))
|
||||
transaction_id = c.lastrowid
|
||||
|
||||
# Add transaction item
|
||||
c.execute("""INSERT INTO transaction_items (transaction_id, item_id, quantity, price_at_sale)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(transaction_id, item_id, 1, price))
|
||||
|
||||
# Update inventory
|
||||
c.execute("UPDATE items SET item_quantity = item_quantity - 1, updated_date = ? WHERE item_id = ?",
|
||||
(current_date, item_id))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"🔖 Loaned: {name} (note: {note}) [Transaction #{transaction_id}]"
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error loaning item: {e}")
|
||||
return "Error processing loan."
|
||||
|
||||
def get_loans_for_items():
|
||||
"""Return a dict of item_name -> list of loan notes for currently loaned items"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
# Find all active loans (not returned)
|
||||
c.execute("""
|
||||
SELECT i.item_name, t.notes
|
||||
FROM transactions t
|
||||
JOIN transaction_items ti ON t.transaction_id = ti.transaction_id
|
||||
JOIN items i ON ti.item_id = i.item_id
|
||||
WHERE t.transaction_type = 'LOAN'
|
||||
""")
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
loans = {}
|
||||
for item_name, note in rows:
|
||||
loans.setdefault(item_name, []).append(note)
|
||||
return loans
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error fetching loans: {e}")
|
||||
return {}
|
||||
|
||||
def list_items():
|
||||
"""List all items in inventory, with loan info if any"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("SELECT item_name, item_price, item_quantity, location FROM items ORDER BY item_name")
|
||||
items = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
if not items:
|
||||
return "No items in inventory."
|
||||
|
||||
# Get loan info
|
||||
loans = get_loans_for_items()
|
||||
|
||||
result = "📦 Inventory:\n"
|
||||
total_value = 0
|
||||
for name, price, qty, location in items:
|
||||
value = price * qty
|
||||
total_value += value
|
||||
loc_str = f" @ {location}" if location else ""
|
||||
loan_str = ""
|
||||
if name in loans:
|
||||
for note in loans[name]:
|
||||
loan_str += f" [loan: {note}]"
|
||||
result += f"{name}: ${price:.2f} x {qty}{loc_str} = ${value:.2f}{loan_str}\n"
|
||||
|
||||
result += f"\nTotal Value: ${total_value:.2f}"
|
||||
return result.rstrip()
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error listing items: {e}")
|
||||
return "Error listing items."
|
||||
|
||||
def get_stats():
|
||||
"""Get sales statistics"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
|
||||
try:
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
|
||||
# Get today's sales
|
||||
c.execute("""SELECT COUNT(*), SUM(total_amount)
|
||||
FROM transactions
|
||||
WHERE transaction_type = 'SALE' AND transaction_date = ?""",
|
||||
(current_date,))
|
||||
today_stats = c.fetchone()
|
||||
today_count = today_stats[0] or 0
|
||||
today_total = today_stats[1] or 0
|
||||
|
||||
# Get hot item (most sold today)
|
||||
c.execute("""SELECT i.item_name, SUM(ti.quantity) as total_qty
|
||||
FROM transaction_items ti
|
||||
JOIN transactions t ON ti.transaction_id = t.transaction_id
|
||||
JOIN items i ON ti.item_id = i.item_id
|
||||
WHERE t.transaction_date = ? AND t.transaction_type = 'SALE'
|
||||
GROUP BY i.item_name
|
||||
ORDER BY total_qty DESC
|
||||
LIMIT 1""", (current_date,))
|
||||
hot_item = c.fetchone()
|
||||
|
||||
conn.close()
|
||||
|
||||
result = f"📊 Today's Stats:\n"
|
||||
result += f"Sales: {today_count}\n"
|
||||
result += f"Revenue: ${today_total:.2f}\n"
|
||||
if hot_item:
|
||||
result += f"Hot Item: {hot_item[0]} ({hot_item[1]} sold)"
|
||||
else:
|
||||
result += "Hot Item: None"
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error getting stats: {e}")
|
||||
return "Error getting stats."
|
||||
|
||||
def add_to_cart(user_id, item_name, quantity):
|
||||
"""Add item to user's cart"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
|
||||
try:
|
||||
# Get item details
|
||||
c.execute("SELECT item_id, item_quantity FROM items WHERE item_name = ?", (item_name,))
|
||||
item = c.fetchone()
|
||||
if not item:
|
||||
conn.close()
|
||||
return f"Item '{item_name}' not found."
|
||||
|
||||
item_id, available_qty = item
|
||||
|
||||
# Check if item already in cart
|
||||
c.execute("SELECT quantity FROM carts WHERE user_id = ? AND item_id = ?", (user_id, item_id))
|
||||
existing = c.fetchone()
|
||||
|
||||
if existing:
|
||||
new_qty = existing[0] + quantity
|
||||
if new_qty > available_qty:
|
||||
conn.close()
|
||||
return f"Insufficient quantity. Available: {available_qty}"
|
||||
c.execute("UPDATE carts SET quantity = ? WHERE user_id = ? AND item_id = ?",
|
||||
(new_qty, user_id, item_id))
|
||||
else:
|
||||
if quantity > available_qty:
|
||||
conn.close()
|
||||
return f"Insufficient quantity. Available: {available_qty}"
|
||||
c.execute("INSERT INTO carts (user_id, item_id, quantity, added_date) VALUES (?, ?, ?, ?)",
|
||||
(user_id, item_id, quantity, current_date))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"🛒 Added to cart: {quantity}x {item_name}"
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error adding to cart: {e}")
|
||||
return "Error adding to cart."
|
||||
|
||||
def remove_from_cart(user_id, item_name):
|
||||
"""Remove item from user's cart"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
|
||||
try:
|
||||
c.execute("""DELETE FROM carts
|
||||
WHERE user_id = ? AND item_id = (SELECT item_id FROM items WHERE item_name = ?)""",
|
||||
(user_id, item_name))
|
||||
if c.rowcount == 0:
|
||||
conn.close()
|
||||
return f"Item '{item_name}' not in cart."
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return f"🗑️ Removed from cart: {item_name}"
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error removing from cart: {e}")
|
||||
return "Error removing from cart."
|
||||
|
||||
def list_cart(user_id):
|
||||
"""List items in user's cart"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
|
||||
try:
|
||||
c.execute("""SELECT i.item_name, i.item_price, c.quantity
|
||||
FROM carts c
|
||||
JOIN items i ON c.item_id = i.item_id
|
||||
WHERE c.user_id = ?""", (user_id,))
|
||||
items = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
if not items:
|
||||
return "🛒 Cart is empty."
|
||||
|
||||
result = "🛒 Your Cart:\n"
|
||||
total = 0
|
||||
for name, price, qty in items:
|
||||
subtotal = price * qty
|
||||
total += subtotal
|
||||
result += f"{name}: ${price:.2f} x {qty} = ${subtotal:.2f}\n"
|
||||
|
||||
total = round_price(total, is_taxed_sale=True)
|
||||
result += f"\nTotal: ${total:.2f}"
|
||||
return result
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error listing cart: {e}")
|
||||
return "Error listing cart."
|
||||
|
||||
def checkout_cart(user_id, user_name="", transaction_type="SALE", notes=""):
|
||||
"""Process cart as a transaction"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
current_date = time.strftime("%Y-%m-%d")
|
||||
current_time = time.strftime("%H:%M:%S")
|
||||
|
||||
try:
|
||||
# Get cart items
|
||||
c.execute("""SELECT i.item_id, i.item_name, i.item_price, c.quantity, i.item_quantity
|
||||
FROM carts c
|
||||
JOIN items i ON c.item_id = i.item_id
|
||||
WHERE c.user_id = ?""", (user_id,))
|
||||
cart_items = c.fetchall()
|
||||
|
||||
if not cart_items:
|
||||
conn.close()
|
||||
return "Cart is empty."
|
||||
|
||||
# Verify all items have sufficient quantity
|
||||
for item_id, name, price, cart_qty, stock_qty in cart_items:
|
||||
if stock_qty < cart_qty:
|
||||
conn.close()
|
||||
return f"Insufficient quantity for '{name}'. Available: {stock_qty}"
|
||||
|
||||
# Calculate total
|
||||
total = sum(price * qty for _, _, price, qty, _ in cart_items)
|
||||
total = round_price(total, is_taxed_sale=(transaction_type == "SALE"))
|
||||
|
||||
# Create transaction
|
||||
c.execute("""INSERT INTO transactions (transaction_type, transaction_date, transaction_time,
|
||||
user_name, total_amount, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(transaction_type, current_date, current_time, user_name, total, notes))
|
||||
transaction_id = c.lastrowid
|
||||
|
||||
# Process each item
|
||||
for item_id, name, price, quantity, _ in cart_items:
|
||||
# Add to transaction items
|
||||
c.execute("""INSERT INTO transaction_items (transaction_id, item_id, quantity, price_at_sale)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(transaction_id, item_id, quantity, price))
|
||||
|
||||
# Update inventory (subtract for SALE, add for BUY)
|
||||
if transaction_type == "SALE":
|
||||
c.execute("UPDATE items SET item_quantity = item_quantity - ?, updated_date = ? WHERE item_id = ?",
|
||||
(quantity, current_date, item_id))
|
||||
else: # BUY
|
||||
c.execute("UPDATE items SET item_quantity = item_quantity + ?, updated_date = ? WHERE item_id = ?",
|
||||
(quantity, current_date, item_id))
|
||||
|
||||
# Clear cart
|
||||
c.execute("DELETE FROM carts WHERE user_id = ?", (user_id,))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
emoji = "💰" if transaction_type == "SALE" else "📦"
|
||||
return f"{emoji} Transaction #{transaction_id} completed: ${total:.2f}"
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error processing cart: {e}")
|
||||
return "Error processing cart."
|
||||
|
||||
def clear_cart(user_id):
|
||||
"""Clear user's cart"""
|
||||
conn = sqlite3.connect(inventory_db)
|
||||
c = conn.cursor()
|
||||
|
||||
try:
|
||||
c.execute("DELETE FROM carts WHERE user_id = ?", (user_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return "🗑️ Cart cleared."
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
logger.error(f"Inventory: Error clearing cart: {e}")
|
||||
return "Error clearing cart."
|
||||
|
||||
def process_inventory_command(nodeID, message, name="none"):
|
||||
"""Process inventory and POS commands"""
|
||||
# Check ban list
|
||||
if str(nodeID) in bbs_ban_list:
|
||||
logger.warning("System: Inventory attempt from the ban list")
|
||||
return "Unable to process command"
|
||||
|
||||
message_lower = message.lower()
|
||||
parts = message.split()
|
||||
|
||||
try:
|
||||
# Help command
|
||||
if "?" in message_lower:
|
||||
return get_inventory_help()
|
||||
|
||||
# Item management commands
|
||||
if message_lower.startswith("itemadd "):
|
||||
# itemadd <name> <qty> [price] [location]
|
||||
if len(parts) < 3:
|
||||
return "Usage: itemadd <name> <qty> [price] [location]"
|
||||
item_name = parts[1]
|
||||
try:
|
||||
quantity = int(parts[2])
|
||||
except ValueError:
|
||||
return "Invalid quantity."
|
||||
price = 0.0
|
||||
location = ""
|
||||
if len(parts) > 3:
|
||||
try:
|
||||
price = float(parts[3])
|
||||
location = " ".join(parts[4:]) if len(parts) > 4 else ""
|
||||
except ValueError:
|
||||
# If price is omitted, treat parts[3] as location
|
||||
price = 0.0
|
||||
location = " ".join(parts[3:])
|
||||
return add_item(item_name, price, quantity, location)
|
||||
|
||||
elif message_lower.startswith("itemremove "):
|
||||
item_name = " ".join(parts[1:])
|
||||
return remove_item(item_name)
|
||||
|
||||
elif message_lower.startswith("itemreset "):
|
||||
# itemreset name [price=X] [quantity=Y]
|
||||
if len(parts) < 2:
|
||||
return "Usage: itemreset <name> [price=X] [quantity=Y]"
|
||||
item_name = parts[1]
|
||||
price = None
|
||||
quantity = None
|
||||
for part in parts[2:]:
|
||||
if part.startswith("price="):
|
||||
try:
|
||||
price = float(part.split("=")[1])
|
||||
except ValueError:
|
||||
return "Invalid price value."
|
||||
elif part.startswith("quantity=") or part.startswith("qty="):
|
||||
try:
|
||||
quantity = int(part.split("=")[1])
|
||||
except ValueError:
|
||||
return "Invalid quantity value."
|
||||
return reset_item(item_name, price, quantity)
|
||||
|
||||
elif message_lower.startswith("itemsell "):
|
||||
# itemsell name quantity [notes]
|
||||
if len(parts) < 3:
|
||||
return "Usage: itemsell <name> <quantity> [notes]"
|
||||
item_name = parts[1]
|
||||
try:
|
||||
quantity = int(parts[2])
|
||||
notes = " ".join(parts[3:]) if len(parts) > 3 else ""
|
||||
return sell_item(item_name, quantity, name, notes)
|
||||
except ValueError:
|
||||
return "Invalid quantity."
|
||||
|
||||
elif message_lower.startswith("itemreturn "):
|
||||
# itemreturn transaction_id
|
||||
if len(parts) < 2:
|
||||
return "Usage: itemreturn <transaction_id>"
|
||||
try:
|
||||
transaction_id = int(parts[1])
|
||||
return return_item(transaction_id)
|
||||
except ValueError:
|
||||
return "Invalid transaction ID."
|
||||
|
||||
elif message_lower.startswith("itemloan "):
|
||||
# itemloan <name> <note>
|
||||
if len(parts) < 3:
|
||||
return "Usage: itemloan <name> <note>"
|
||||
item_name = parts[1]
|
||||
note = " ".join(parts[2:])
|
||||
return loan_item(item_name, name, note)
|
||||
|
||||
elif message_lower == "itemlist":
|
||||
return list_items()
|
||||
|
||||
elif message_lower == "itemstats":
|
||||
return get_stats()
|
||||
|
||||
# Cart commands
|
||||
elif message_lower.startswith("cartadd "):
|
||||
# cartadd name quantity
|
||||
if len(parts) < 3:
|
||||
return "Usage: cartadd <name> <quantity>"
|
||||
item_name = parts[1]
|
||||
try:
|
||||
quantity = int(parts[2])
|
||||
return add_to_cart(str(nodeID), item_name, quantity)
|
||||
except ValueError:
|
||||
return "Invalid quantity."
|
||||
|
||||
elif message_lower.startswith("cartremove "):
|
||||
item_name = " ".join(parts[1:])
|
||||
return remove_from_cart(str(nodeID), item_name)
|
||||
|
||||
elif message_lower == "cartlist" or message_lower == "cart":
|
||||
return list_cart(str(nodeID))
|
||||
|
||||
elif message_lower.startswith("cartbuy") or message_lower.startswith("cartsell"):
|
||||
transaction_type = "BUY" if "buy" in message_lower else "SALE"
|
||||
notes = " ".join(parts[1:]) if len(parts) > 1 else ""
|
||||
return checkout_cart(str(nodeID), name, transaction_type, notes)
|
||||
|
||||
elif message_lower == "cartclear":
|
||||
return clear_cart(str(nodeID))
|
||||
|
||||
else:
|
||||
return "Invalid command. Send 'item?' for help."
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Inventory: Error processing command: {e}")
|
||||
return "Error processing command."
|
||||
|
||||
def get_inventory_help():
|
||||
"""Return help text for inventory commands"""
|
||||
return (
|
||||
"📦 Inventory Commands:\n"
|
||||
" itemadd <name> <qty> [price] [loc]\n"
|
||||
" itemremove <name>\n"
|
||||
" itemreset name> <qty> [price] [loc]\n"
|
||||
" itemsell <name> <qty> [notes]\n"
|
||||
" itemloan <name> <note>\n"
|
||||
" itemreturn <transaction_id>\n"
|
||||
" itemlist\n"
|
||||
" itemstats\n"
|
||||
"\n"
|
||||
"🛒 Cart Commands:\n"
|
||||
" cartadd <name> <qty>\n"
|
||||
" cartremove <name>\n"
|
||||
" cartlist\n"
|
||||
" cartbuy/cartsell [notes]\n"
|
||||
" cartclear\n"
|
||||
)
|
||||
88
modules/llm.md
Normal file
88
modules/llm.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# How do I use this thing?
|
||||
This is not a full turnkey setup yet?
|
||||
|
||||
|
||||
For Ollama to work, the command line `ollama run 'model'` needs to work properly. Ensure you have enough RAM and your GPU is working as expected. The default model for this project is set to `gemma3:270m`. Ollama can be remote [Ollama Server](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server) works on a pi58GB with 40 second or less response time.
|
||||
|
||||
|
||||
# Ollama local
|
||||
```bash
|
||||
# bash
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
# docker
|
||||
docker run -d -p 3000:8080 --add-host=host.docker.internal:host-gateway -e OLLAMA_API_BASE_URL=http://host.docker.internal:11434 open-webui/open-webui
|
||||
```
|
||||
|
||||
## Update /etc/systemd/system/ollama.service
|
||||
https://github.com/ollama/ollama/issues/703
|
||||
```ini
|
||||
#service file addition not config.ini
|
||||
# [Service]
|
||||
Environment="OLLAMA_HOST=0.0.0.0:11434"
|
||||
```
|
||||
## validation
|
||||
http://IP::11434
|
||||
`Ollama is running`
|
||||
|
||||
## Docs
|
||||
Note for LLM in docker with [NVIDIA](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/docker-specialized.html). Needed for the container with ollama running?
|
||||
|
||||
---
|
||||
|
||||
# OpenWebUI (docker)
|
||||
```bash
|
||||
## ollama in docker
|
||||
docker run -d -p 3000:8080 --gpus all -v open-webui:/app/backend/data --name open-webui ghcr.io/open-webui/open-webui:cuda
|
||||
|
||||
## external ollama
|
||||
docker run -d -p 3000:8080 -e OLLAMA_BASE_URL=https://IP:11434 -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main
|
||||
```
|
||||
wait for engine to build, update the config.ini for the bot
|
||||
|
||||
```ini
|
||||
# Use OpenWebUI instead of direct Ollama API (enables advanced RAG features)
|
||||
useOpenWebUI = False
|
||||
# OpenWebUI server URL (e.g., http://localhost:3000)
|
||||
openWebUIURL = http://localhost:3000
|
||||
# OpenWebUI API key/token (required when useOpenWebUI is True)
|
||||
openWebUIAPIKey = sk-xxxx (see below for help)
|
||||
```
|
||||
|
||||
## Validation
|
||||
http://IP:3000
|
||||
make a new admin user.
|
||||
validate you have models imported or that the system is working for query.
|
||||
make a new user for the bot
|
||||
|
||||
## API Key
|
||||
- upper right settings for the user
|
||||
- settings -> account
|
||||
- get/create the API key for the user
|
||||
|
||||
## Troubleshooting
|
||||
- make sure the OpenWebUI works from the bot node and loads (try lynx etc)
|
||||
- make sure the model in config.ini is also loaded in OpenWebUI and you can use it
|
||||
- make sure **OpenWebUI** can reach **Ollama IP** it should auto import the models
|
||||
- I find using IP and not common use names like localhost which may not work well with docker etc..
|
||||
|
||||
- Check OpenWebUI and Ollama are working
|
||||
- Go to Admin Settings within Open WebUI.
|
||||
- Connections tab
|
||||
- Ollama connection and click on the Manage (wrench icon)
|
||||
- download models directly from the Ollama library
|
||||
- **Once the model is downloaded or imported, it will become available for use within Open WebUI, allowing you to interact with it through the chat interface**
|
||||
|
||||
## Docs
|
||||
[OpenWebUI Quick Start](https://docs.openwebui.com/getting-started/quick-start/)
|
||||
[OpenWebUI API](https://docs.openwebui.com/getting-started/api-endpoints)
|
||||
[OpenWebUI Ollama](https://docs.openwebui.com/getting-started/quick-start/starting-with-ollama/)
|
||||
[Blog OpenWebUI on Pi](https://pimylifeup.com/raspberry-pi-open-webui/)
|
||||
|
||||
https://docs.openwebui.com/tutorials/tips/rag-tutorial#tutorial-configuring-rag-with-open-webui-documentation
|
||||
https://docs.openwebui.com/features/plugin/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
446
modules/llm.py
Normal file
446
modules/llm.py
Normal file
@@ -0,0 +1,446 @@
|
||||
#!/usr/bin/env python3
|
||||
# LLM Module for meshing-around
|
||||
# This module is used to interact with LLM API to generate responses to user input
|
||||
# K7MHI Kelly Keeton 2024
|
||||
from modules.log import logger
|
||||
from modules.settings import (llmModel, ollamaHostName, rawLLMQuery,
|
||||
llmUseWikiContext, useOpenWebUI, openWebUIURL, openWebUIAPIKey, cmdBang, urlTimeoutSeconds, use_kiwix_server)
|
||||
|
||||
# Ollama Client
|
||||
# https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server
|
||||
import requests
|
||||
import json
|
||||
from datetime import datetime
|
||||
if llmUseWikiContext or use_kiwix_server:
|
||||
from modules.wiki import get_wikipedia_summary, get_kiwix_summary
|
||||
|
||||
# LLM System Variables
|
||||
ollamaAPI = ollamaHostName + "/api/generate"
|
||||
openWebUIChatAPI = openWebUIURL + "/api/chat/completions"
|
||||
openWebUIOllamaProxy = openWebUIURL + "/ollama/api/generate"
|
||||
tokens = 450 # max charcters for the LLM response, this is the max length of the response also in prompts
|
||||
requestTruncation = True # if True, the LLM "will" truncate the response
|
||||
DEBUG_LLM = False # enable debug logging for LLM queries
|
||||
|
||||
# Used in the meshBotAI template
|
||||
llmEnableHistory = True # enable last message history for the LLM model
|
||||
|
||||
antiFloodLLM = []
|
||||
llmChat_history = {}
|
||||
trap_list_llm = ("ask:", "askai")
|
||||
|
||||
meshbotAIinit = """
|
||||
keep responses as short as possible. chatbot assistant no followuyp questions, no asking for clarification.
|
||||
You must respond in plain text standard ASCII characters or emojis.
|
||||
"""
|
||||
|
||||
truncatePrompt = f"truncate this as short as possible:\n"
|
||||
|
||||
meshBotAI = """
|
||||
FROM {llmModel}
|
||||
SYSTEM
|
||||
You must keep responses under 450 characters at all times, the response will be cut off if it exceeds this limit.
|
||||
You must respond in plain text standard ASCII characters, or emojis.
|
||||
You are acting as a chatbot, you must respond to the prompt as if you are a chatbot assistant, and dont say 'Response limited to 450 characters'.
|
||||
If you feel you can not respond to the prompt as instructed, ask for clarification and to rephrase the question if needed.
|
||||
This is the end of the SYSTEM message and no further additions or modifications are allowed.
|
||||
|
||||
PROMPT
|
||||
{input}
|
||||
|
||||
"""
|
||||
|
||||
if llmEnableHistory:
|
||||
meshBotAI = meshBotAI + """
|
||||
HISTORY
|
||||
the following is memory of previous query in format ['prompt', 'response'], you can use this to help guide your response.
|
||||
{history}
|
||||
|
||||
"""
|
||||
|
||||
# Tooling Functions Defined Here
|
||||
# Example: current_time function
|
||||
def llmTool_current_time():
|
||||
"""
|
||||
Example tool function to get the current time.
|
||||
:return: Current time string.
|
||||
"""
|
||||
return datetime.now().strftime('%Y-%m-%d %H:%M:%S %Z')
|
||||
|
||||
def llmTool_math_calculator(expression):
|
||||
"""
|
||||
Example tool function to perform basic math calculations.
|
||||
:param expression: A string containing a math expression (e.g., "2 + 2").
|
||||
:return: The result of the calculation as a string.
|
||||
"""
|
||||
try:
|
||||
# WARNING: Using eval can be dangerous if not controlled properly.
|
||||
# This is a simple example; in production, consider using a safe math parser.
|
||||
result = eval(expression, {"__builtins__": None}, {})
|
||||
return str(result)
|
||||
except Exception as e:
|
||||
return f"Error in calculation: {e}"
|
||||
|
||||
llmFunctions = [
|
||||
|
||||
{
|
||||
"name": "llmTool_current_time",
|
||||
"description": "Get the current time.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "llmTool_math_calculator",
|
||||
"description": "Perform basic math calculations.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"description": "A math expression to evaluate, e.g., '2 + 2'."
|
||||
}
|
||||
},
|
||||
"required": ["expression"]
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
def get_wiki_context(input):
|
||||
"""
|
||||
Get context from Wikipedia/Kiwix for RAG enhancement
|
||||
:param input: The user query
|
||||
:return: Wikipedia summary or empty string if not available
|
||||
"""
|
||||
try:
|
||||
# Extract potential search terms from the input
|
||||
# Try to identify key topics/entities for Wikipedia search
|
||||
search_terms = extract_search_terms(input)
|
||||
|
||||
wiki_context = []
|
||||
for term in search_terms[:2]: # Limit to 2 searches to avoid excessive API calls
|
||||
if use_kiwix_server:
|
||||
summary = get_kiwix_summary(term, truncate=False)
|
||||
else:
|
||||
summary = get_wikipedia_summary(term, truncate=False)
|
||||
|
||||
if summary and "error" not in summary.lower() or "html://" not in summary or "ambiguous" not in summary.lower():
|
||||
wiki_context.append(f"Wikipedia context for '{term}': {summary}")
|
||||
|
||||
return '\n'.join(wiki_context) if wiki_context else ''
|
||||
except Exception as e:
|
||||
logger.debug(f"System: LLM Query: Wiki context gathering failed: {e}")
|
||||
return ''
|
||||
|
||||
def llm_extract_topic(input):
|
||||
"""
|
||||
Use LLM to extract the main topic as a single word or short phrase.
|
||||
Always uses raw mode and supports both Ollama and OpenWebUI.
|
||||
:param input: The user query
|
||||
:return: List with one topic string, or empty list on failure
|
||||
"""
|
||||
prompt = (
|
||||
"Summarize the following query into a single word or short phrase that best represents the main topic, "
|
||||
"for use as a Wikipedia search term. Only return the word or phrase, nothing else:\n"
|
||||
f"{input}"
|
||||
)
|
||||
try:
|
||||
if useOpenWebUI and openWebUIAPIKey:
|
||||
result = send_openwebui_query(prompt, max_tokens=10)
|
||||
else:
|
||||
llmQuery = {"model": llmModel, "prompt": prompt, "stream": False, "max_tokens": 10}
|
||||
result = send_ollama_query(llmQuery)
|
||||
topic = result.strip().split('\n')[0]
|
||||
topic = topic.strip(' "\'.,!?;:')
|
||||
if topic:
|
||||
return [topic]
|
||||
except Exception as e:
|
||||
logger.debug(f"LLM topic extraction failed: {e}")
|
||||
return []
|
||||
|
||||
def extract_search_terms(input):
|
||||
"""
|
||||
Extract potential search terms from user input.
|
||||
Enhanced: Try LLM-based topic extraction first, fallback to heuristic.
|
||||
:param input: The user query
|
||||
:return: List of potential search terms
|
||||
"""
|
||||
# Remove common command prefixes
|
||||
for trap in trap_list_llm:
|
||||
if input.lower().startswith(trap):
|
||||
input = input[len(trap):].strip()
|
||||
break
|
||||
|
||||
# Try LLM-based extraction first
|
||||
terms = llm_extract_topic(input)
|
||||
if terms:
|
||||
return terms
|
||||
|
||||
# Fallback: Simple heuristic (existing code)
|
||||
words = input.split()
|
||||
search_terms = []
|
||||
temp_phrase = []
|
||||
for word in words:
|
||||
clean_word = word.strip('.,!?;:')
|
||||
if clean_word and clean_word[0].isupper() and len(clean_word) > 2:
|
||||
temp_phrase.append(clean_word)
|
||||
elif temp_phrase:
|
||||
search_terms.append(' '.join(temp_phrase))
|
||||
temp_phrase = []
|
||||
if temp_phrase:
|
||||
search_terms.append(' '.join(temp_phrase))
|
||||
if not search_terms:
|
||||
search_terms = [input.strip()]
|
||||
if DEBUG_LLM:
|
||||
logger.debug(f"Extracted search terms: {search_terms}")
|
||||
return search_terms[:3] # Limit to 3 terms
|
||||
|
||||
def send_openwebui_query(prompt, model=None, max_tokens=450, context=''):
|
||||
"""
|
||||
Send query to OpenWebUI API for chat completion
|
||||
:param prompt: The user prompt
|
||||
:param model: Model name (optional, defaults to llmModel)
|
||||
:param max_tokens: Max tokens for response
|
||||
:param context: Additional context to include
|
||||
:return: Response text or error message
|
||||
"""
|
||||
if model is None:
|
||||
model = llmModel
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {openWebUIAPIKey}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
messages = []
|
||||
if context:
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": f"Use the following context to help answer questions:\n{context}"
|
||||
})
|
||||
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
})
|
||||
|
||||
data = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False
|
||||
}
|
||||
|
||||
# Debug logging
|
||||
if DEBUG_LLM:
|
||||
logger.debug(f"OpenWebUI payload: {json.dumps(data)}")
|
||||
logger.debug(f"OpenWebUI endpoint: {openWebUIChatAPI}")
|
||||
|
||||
try:
|
||||
result = requests.post(openWebUIChatAPI, headers=headers, json=data, timeout=urlTimeoutSeconds * 5)
|
||||
if DEBUG_LLM:
|
||||
logger.debug(f"OpenWebUI response status: {result.status_code}")
|
||||
logger.debug(f"OpenWebUI response text: {result.text}")
|
||||
if result.status_code == 200:
|
||||
result_json = result.json()
|
||||
# OpenWebUI returns OpenAI-compatible format
|
||||
if 'choices' in result_json and len(result_json['choices']) > 0:
|
||||
response = result_json['choices'][0]['message']['content']
|
||||
return response.strip()
|
||||
else:
|
||||
logger.warning(f"System: OpenWebUI API returned unexpected format")
|
||||
return "⛔️ Response Error"
|
||||
else:
|
||||
logger.warning(f"System: OpenWebUI API returned status code {result.status_code}")
|
||||
return f"⛔️ Request Error"
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"System: OpenWebUI API request failed: {e}")
|
||||
return f"⛔️ Request Error"
|
||||
|
||||
def send_ollama_query(llmQuery):
|
||||
# Send the query to the Ollama API and return the response
|
||||
try:
|
||||
result = requests.post(ollamaAPI, data=json.dumps(llmQuery), timeout= urlTimeoutSeconds * 5)
|
||||
if result.status_code == 200:
|
||||
result_json = result.json()
|
||||
result = result_json.get("response", "")
|
||||
# deepseek has added <think> </think> tags to the response
|
||||
if "<think>" in result:
|
||||
result = result.split("</think>")[1]
|
||||
else:
|
||||
logger.warning(f"System: LLM Query: Ollama API returned status code {result.status_code}")
|
||||
return f"⛔️ Request Error"
|
||||
return result
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"System: LLM Query: Ollama API request failed: {e}")
|
||||
return f"⛔️ Request Error"
|
||||
|
||||
def send_ollama_tooling_query(prompt, functions, model=None, max_tokens=450):
|
||||
"""
|
||||
Send a prompt and function/tool definitions to Ollama API for function calling.
|
||||
:param prompt: The user prompt string.
|
||||
:param functions: List of function/tool definitions (see Ollama API docs).
|
||||
:param model: Model name (optional, defaults to llmModel).
|
||||
:param max_tokens: Max tokens for response.
|
||||
:return: Ollama API response JSON.
|
||||
"""
|
||||
if model is None:
|
||||
model = llmModel
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"functions": functions,
|
||||
"stream": False,
|
||||
"max_tokens": max_tokens
|
||||
}
|
||||
result = requests.post(ollamaAPI, data=json.dumps(payload))
|
||||
if result.status_code == 200:
|
||||
return result.json()
|
||||
else:
|
||||
raise Exception(f"HTTP Error: {result.status_code} - {result.text}")
|
||||
|
||||
def llm_query(input, nodeID=0, location_name=None, init=False):
|
||||
global antiFloodLLM, llmChat_history
|
||||
wikiContext = ''
|
||||
|
||||
# if this is the first initialization of the LLM the query of " " should bring meshbotAIinit OTA shouldnt reach this?
|
||||
# This is for LLM like gemma and others now?
|
||||
if init and rawLLMQuery:
|
||||
logger.warning("System: These LLM models lack a traditional system prompt, they can be verbose and not very helpful be advised.")
|
||||
input = meshbotAIinit
|
||||
elif init:
|
||||
input = input.strip()
|
||||
# classic model for gemma2, deepseek-r1, etc
|
||||
logger.debug(f"System: Using SYSTEM model framework, ideally for gemma2, deepseek-r1, etc")
|
||||
|
||||
if not location_name:
|
||||
location_name = "no location provided "
|
||||
|
||||
# Remove command bang if present
|
||||
if cmdBang and input.startswith('!'):
|
||||
input = input.strip('!').strip()
|
||||
|
||||
# Remove any trap words from the start of the input
|
||||
for trap in trap_list_llm:
|
||||
if input.lower().startswith(trap):
|
||||
input = input[len(trap):].strip()
|
||||
break
|
||||
|
||||
# add the naughty list here to stop the function before we continue
|
||||
# add a list of allowed nodes only to use the function
|
||||
|
||||
# anti flood protection
|
||||
if nodeID in antiFloodLLM:
|
||||
return "Please wait before sending another message"
|
||||
else:
|
||||
antiFloodLLM.append(nodeID)
|
||||
|
||||
# Get Wikipedia/Kiwix context if enabled (RAG)
|
||||
if llmUseWikiContext and input != meshbotAIinit:
|
||||
# get_wiki_context returns a string, but we want to count the items before joining
|
||||
search_terms = extract_search_terms(input)
|
||||
wiki_context_list = []
|
||||
for term in search_terms[:2]:
|
||||
if not use_kiwix_server:
|
||||
summary = get_wiki_context(term)
|
||||
else:
|
||||
summary = get_wiki_context(term)
|
||||
if summary and "error" not in summary.lower():
|
||||
wiki_context_list.append(f"Wikipedia context for '{term}': {summary}")
|
||||
wikiContext = '\n'.join(wiki_context_list) if wiki_context_list else ''
|
||||
if wikiContext:
|
||||
logger.debug(f"System: using Wikipedia/Kiwix context for LLM query got {len(wiki_context_list)} results")
|
||||
|
||||
history = llmChat_history.get(nodeID, ["", ""])
|
||||
|
||||
response = ""
|
||||
result = ""
|
||||
location_name += f" at the current time of {datetime.now().strftime('%Y-%m-%d %H:%M:%S %Z')}"
|
||||
|
||||
try:
|
||||
# Use OpenWebUI if enabled
|
||||
if useOpenWebUI and openWebUIAPIKey:
|
||||
logger.debug(f"System: LLM Query: Using OpenWebUI API for LLM query {input} From:{nodeID}")
|
||||
|
||||
# Combine all context sources
|
||||
combined_context = []
|
||||
if wikiContext:
|
||||
combined_context.append(wikiContext)
|
||||
|
||||
context_str = '\n\n'.join(combined_context)
|
||||
|
||||
# For OpenWebUI, we send a cleaner prompt
|
||||
if rawLLMQuery:
|
||||
result = send_openwebui_query(input, context=context_str, max_tokens=tokens)
|
||||
else:
|
||||
# Use the template for non-raw queries
|
||||
modelPrompt = meshBotAI.format(
|
||||
input=input,
|
||||
context=context_str if combined_context else 'no other context provided',
|
||||
location_name=location_name,
|
||||
llmModel=llmModel,
|
||||
history=history
|
||||
)
|
||||
result = send_openwebui_query(modelPrompt, max_tokens=tokens)
|
||||
else:
|
||||
logger.debug(f"System: LLM Query: Using Ollama API for LLM query {input} From:{nodeID}")
|
||||
# Use standard Ollama API
|
||||
if rawLLMQuery:
|
||||
# sanitize the input to remove tool call syntax
|
||||
if '```' in input:
|
||||
logger.warning("System: LLM Query: Code markdown detected, removing for raw query")
|
||||
input = input.replace('```bash', '').replace('```python', '').replace('```', '')
|
||||
modelPrompt = input
|
||||
|
||||
# Add wiki context to raw queries if available
|
||||
if wikiContext:
|
||||
modelPrompt = f"Context:\n{wikiContext}\n\nQuestion: {input}"
|
||||
else:
|
||||
# Build the query from the template
|
||||
all_context = []
|
||||
if wikiContext:
|
||||
all_context.append(wikiContext)
|
||||
|
||||
context_text = '\n'.join(all_context) if all_context else 'no other context provided'
|
||||
modelPrompt = meshBotAI.format(
|
||||
input=input,
|
||||
context=context_text,
|
||||
location_name=location_name,
|
||||
llmModel=llmModel,
|
||||
history=history
|
||||
)
|
||||
|
||||
llmQuery = {"model": llmModel, "prompt": modelPrompt, "stream": False, "max_tokens": tokens}
|
||||
# Query the model via Ollama web API
|
||||
result = send_ollama_query(llmQuery)
|
||||
|
||||
#logger.debug(f"System: LLM Response: " + result.strip().replace('\n', ' '))
|
||||
except Exception as e:
|
||||
antiFloodLLM.remove(nodeID) # Ensure removal on error
|
||||
logger.warning(f"System: LLM failure: {e}")
|
||||
return "⛔️I am having trouble processing your request, please try again later."
|
||||
|
||||
# cleanup for message output
|
||||
response = result.strip().replace('\n', ' ')
|
||||
|
||||
if rawLLMQuery and requestTruncation and len(response) > 450:
|
||||
# retry loop to truncate the response
|
||||
logger.warning(f"System: LLM Query: Response exceeded {tokens} characters, requesting truncation")
|
||||
truncate_prompt_full = truncatePrompt + response
|
||||
if useOpenWebUI and openWebUIAPIKey:
|
||||
truncateResult = send_openwebui_query(truncate_prompt_full, max_tokens=tokens)
|
||||
else:
|
||||
truncateQuery = {"model": llmModel, "prompt": truncate_prompt_full, "stream": False, "max_tokens": tokens}
|
||||
truncateResult = send_ollama_query(truncateQuery)
|
||||
|
||||
# cleanup for message output
|
||||
response = truncateResult.strip().replace('\n', ' ')
|
||||
|
||||
# done with the query, remove the user from the anti flood list
|
||||
antiFloodLLM.remove(nodeID)
|
||||
|
||||
if llmEnableHistory:
|
||||
llmChat_history[nodeID] = [input, response]
|
||||
|
||||
return response
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,11 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from modules.settings import *
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
import modules.settings as my_settings
|
||||
# if LOGGING_LEVEL is not set in settings.py, default to DEBUG
|
||||
if not my_settings.LOGGING_LEVEL:
|
||||
my_settings.LOGGING_LEVEL = "DEBUG"
|
||||
|
||||
LOGGING_LEVEL = getattr(logging, my_settings.LOGGING_LEVEL)
|
||||
|
||||
class CustomFormatter(logging.Formatter):
|
||||
grey = '\x1b[38;21m'
|
||||
@@ -29,10 +34,23 @@ class CustomFormatter(logging.Formatter):
|
||||
log_fmt = self.FORMATS.get(record.levelno)
|
||||
formatter = logging.Formatter(log_fmt)
|
||||
return formatter.format(record)
|
||||
|
||||
class plainFormatter(logging.Formatter):
|
||||
ansi_codes = [
|
||||
'\x1b[38;21m', '\x1b[38;5;231m', '\x1b[38;5;39m', '\x1b[38;5;226m',
|
||||
'\x1b[38;5;196m', '\x1b[38;5;46m', '\x1b[38;5;129m', '\x1b[31;1m',
|
||||
'\x1b[37;1m', '\x1b[0m'
|
||||
]
|
||||
|
||||
def format(self, record):
|
||||
message = super().format(record)
|
||||
for code in self.ansi_codes:
|
||||
message = message.replace(code, '')
|
||||
return message
|
||||
|
||||
# Create logger
|
||||
logger = logging.getLogger("MeshBot System Logger")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.setLevel(LOGGING_LEVEL)
|
||||
logger.propagate = False
|
||||
|
||||
msgLogger = logging.getLogger("MeshBot Messages Logger")
|
||||
@@ -46,17 +64,36 @@ msgLogFormat = '%(asctime)s | %(message)s'
|
||||
# Create stdout handler for logging to the console
|
||||
stdout_handler = logging.StreamHandler()
|
||||
# Set level for stdout handler (logs DEBUG level and above)
|
||||
stdout_handler.setLevel(logging.DEBUG)
|
||||
stdout_handler.setLevel(LOGGING_LEVEL)
|
||||
# Set format for stdout handler
|
||||
stdout_handler.setFormatter(CustomFormatter(logFormat))
|
||||
|
||||
# Create file handler for logging to a file
|
||||
today = datetime.now()
|
||||
file_handler = logging.FileHandler('messages{}.log'.format(today.strftime('%Y_%m_%d')))
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(logging.Formatter(msgLogFormat))
|
||||
|
||||
# Add handlers to the logger
|
||||
logger.addHandler(stdout_handler)
|
||||
if log_messages_to_file:
|
||||
|
||||
if my_settings.syslog_to_file:
|
||||
# Create file handler for logging to a file
|
||||
file_handler_sys = TimedRotatingFileHandler('logs/meshbot.log', when='midnight', backupCount=my_settings.log_backup_count, encoding='utf-8')
|
||||
file_handler_sys.setLevel(LOGGING_LEVEL) # DEBUG used by default for system logs to disk
|
||||
file_handler_sys.setFormatter(plainFormatter(logFormat))
|
||||
logger.addHandler(file_handler_sys)
|
||||
|
||||
if my_settings.log_messages_to_file:
|
||||
# Create file handler for logging to a file
|
||||
file_handler = TimedRotatingFileHandler('logs/messages.log', when='midnight', backupCount=my_settings.log_backup_count, encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO) # INFO used for messages to disk
|
||||
file_handler.setFormatter(logging.Formatter(msgLogFormat))
|
||||
msgLogger.addHandler(file_handler)
|
||||
|
||||
# Pretty Timestamp
|
||||
def getPrettyTime(seconds):
|
||||
# convert unix time to minutes, hours, days, or years for simple display
|
||||
if seconds < 60:
|
||||
return f"{int(seconds)}s"
|
||||
elif seconds < 3600:
|
||||
return f"{int(round(seconds / 60))}m"
|
||||
elif seconds < 86400:
|
||||
return f"{int(round(seconds / 3600))}h"
|
||||
elif seconds < 31536000:
|
||||
return f"{int(round(seconds / 86400))}d"
|
||||
else:
|
||||
return f"{int(round(seconds / 31536000))}y"
|
||||
68
modules/qrz.py
Normal file
68
modules/qrz.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# Module to respomnd to new nodes we havent seen before with a hello message
|
||||
# K7MHI Kelly Keeton 2024
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from modules.log import logger
|
||||
from modules.settings import qrz_db
|
||||
|
||||
def initalize_qrz_database():
|
||||
try:
|
||||
# If the database file doesn't exist, it will be created by sqlite3.connect
|
||||
if not os.path.exists(qrz_db):
|
||||
logger.info(f"QRZ database file '{qrz_db}' not found. Creating new database.")
|
||||
conn = sqlite3.connect(qrz_db)
|
||||
c = conn.cursor()
|
||||
# Create the table if it doesn't exist
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS qrz
|
||||
(qrz_id INTEGER PRIMARY KEY, qrz_call TEXT, qrz_name TEXT, qrz_qth TEXT, qrz_notes TEXT)''')
|
||||
conn.commit()
|
||||
return True
|
||||
except sqlite3.Error as e:
|
||||
logger.error(f"Error initializing QRZ database: {e}")
|
||||
return False
|
||||
finally:
|
||||
if 'conn' in locals():
|
||||
conn.close()
|
||||
|
||||
def never_seen_before(nodeID):
|
||||
# check if we have seen this node before and sent a hello message
|
||||
conn = sqlite3.connect(qrz_db)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("SELECT * FROM qrz WHERE qrz_call = ?", (nodeID,))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
if row is None:
|
||||
# we have not seen this node before
|
||||
return True
|
||||
else:
|
||||
# we have seen this node before
|
||||
return False
|
||||
except sqlite3.OperationalError as e:
|
||||
if "no such table" in str(e):
|
||||
initalize_qrz_database()
|
||||
logger.warning("QRZ database table not found, created new table")
|
||||
# we have not seen this node before
|
||||
return True
|
||||
else:
|
||||
raise
|
||||
|
||||
def hello(nodeID, name):
|
||||
# send a hello message
|
||||
conn = sqlite3.connect(qrz_db)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("INSERT INTO qrz (qrz_call, qrz_name) VALUES (?, ?)", (nodeID, str(name)))
|
||||
except sqlite3.OperationalError as e:
|
||||
if "no such table" in str(e):
|
||||
initalize_qrz_database()
|
||||
c.execute("INSERT INTO qrz (qrz_call, qrz_name) VALUES (?, ?)", (nodeID, str(name)))
|
||||
else:
|
||||
raise
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return True
|
||||
|
||||
|
||||
|
||||
55
modules/radio.md
Normal file
55
modules/radio.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Radio Module: Meshages TTS (Text-to-Speech) Setup
|
||||
|
||||
The radio module supports audible mesh messages using the [KittenTTS](https://github.com/KittenML/KittenTTS) engine. This allows the bot to generate and play speech from text, making mesh alerts and messages audible on your device.
|
||||
|
||||
## Features
|
||||
|
||||
- Converts mesh messages to speech using KittenTTS.
|
||||
|
||||
## Installation
|
||||
|
||||
1. **Install Python dependencies:**
|
||||
|
||||
- `kittentts` is the TTS engine.
|
||||
|
||||
`pip install https://github.com/KittenML/KittenTTS/releases/download/0.1/kittentts-0.1.0-py3-none-any.whl`
|
||||
|
||||
2. **Install PortAudio (required for sounddevice):**
|
||||
|
||||
- **macOS:**
|
||||
```sh
|
||||
brew install portaudio
|
||||
```
|
||||
- **Linux (Debian/Ubuntu):**
|
||||
```sh
|
||||
sudo apt-get install portaudio19-dev
|
||||
```
|
||||
- **Windows:**
|
||||
No extra step needed; `sounddevice` will use the default audio driver.
|
||||
|
||||
## Configuration
|
||||
|
||||
- Enable TTS in your `config.ini`:
|
||||
```ini
|
||||
[radioMon]
|
||||
meshagesTTS = True
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
When enabled, the bot will generate and play speech for mesh messages using the selected voice.
|
||||
No additional user action is required.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If you see errors about missing `sounddevice` or `portaudio`, ensure you have installed the dependencies above.
|
||||
- On macOS, you may need to allow microphone/audio access for your terminal.
|
||||
- If you have audio issues, check your system’s default output device.
|
||||
|
||||
## References
|
||||
|
||||
- [KittenTTS GitHub](https://github.com/KittenML/KittenTTS)
|
||||
- [KittenTTS Model on HuggingFace](https://huggingface.co/KittenML/kitten-tts-nano-0.2)
|
||||
- [sounddevice documentation](https://python-sounddevice.readthedocs.io/)
|
||||
|
||||
---
|
||||
687
modules/radio.py
687
modules/radio.py
@@ -1,19 +1,240 @@
|
||||
# meshing around with hamlib as a source for info to send to mesh network
|
||||
# detect signal strength and frequency of active channel if appears to be in use send to mesh network
|
||||
# depends on rigctld running externally as a network service
|
||||
# 2024 Kelly Keeton K7MHI
|
||||
# also can use VOX detection with a microphone and vosk speech to text to send voice messages to mesh network
|
||||
# requires vosk and sounddevice python modules. will auto download needed. more from https://alphacephei.com/vosk/models and unpack
|
||||
# 2025 Kelly Keeton K7MHI
|
||||
|
||||
# WSJT-X and JS8Call UDP Monitoring
|
||||
# Based on WSJT-X UDP protocol specification
|
||||
# Reference: https://github.com/ckuhtz/ham/blob/main/mcast/recv_decode.py
|
||||
|
||||
|
||||
import socket
|
||||
import asyncio
|
||||
from modules.settings import *
|
||||
import socket
|
||||
import struct
|
||||
import json
|
||||
from modules.log import logger
|
||||
|
||||
# verbose debug logging for trap words function
|
||||
debugVoxTmsg = False
|
||||
|
||||
from modules.settings import (
|
||||
radio_detection_enabled,
|
||||
rigControlServerAddress,
|
||||
signalDetectionThreshold,
|
||||
signalHoldTime,
|
||||
signalCooldown,
|
||||
signalCycleLimit,
|
||||
voxDetectionEnabled,
|
||||
useLocalVoxModel,
|
||||
localVoxModelPath,
|
||||
voxLanguage,
|
||||
voxInputDevice,
|
||||
voxTrapList,
|
||||
voxOnTrapList,
|
||||
voxEnableCmd,
|
||||
ERROR_FETCHING_DATA,
|
||||
meshagesTTS,
|
||||
)
|
||||
|
||||
# module global variables
|
||||
previousStrength = -40
|
||||
signalCycle = 0
|
||||
|
||||
FREQ_NAME_MAP = {
|
||||
462562500: "GRMS CH1",
|
||||
462587500: "GRMS CH2",
|
||||
462612500: "GRMS CH3",
|
||||
462637500: "GRMS CH4",
|
||||
462662500: "GRMS CH5",
|
||||
462687500: "GRMS CH6",
|
||||
462712500: "GRMS CH7",
|
||||
467562500: "GRMS CH8",
|
||||
467587500: "GRMS CH9",
|
||||
467612500: "GRMS CH10",
|
||||
467637500: "GRMS CH11",
|
||||
467662500: "GRMS CH12",
|
||||
467687500: "GRMS CH13",
|
||||
467712500: "GRMS CH14",
|
||||
467737500: "GRMS CH15",
|
||||
462550000: "GRMS CH16",
|
||||
462575000: "GMRS CH17",
|
||||
462600000: "GMRS CH18",
|
||||
462625000: "GMRS CH19",
|
||||
462675000: "GMRS CH20",
|
||||
462670000: "GMRS CH21",
|
||||
462725000: "GMRS CH22",
|
||||
462725500: "GMRS CH23",
|
||||
467575000: "GMRS CH24",
|
||||
467600000: "GMRS CH25",
|
||||
467625000: "GMRS CH26",
|
||||
467650000: "GMRS CH27",
|
||||
467675000: "GMRS CH28",
|
||||
467700000: "FRS CH1",
|
||||
462650000: "FRS CH5",
|
||||
462700000: "FRS CH7",
|
||||
462737500: "FRS CH16",
|
||||
146520000: "2M Simplex Calling",
|
||||
446000000: "70cm Simplex Calling",
|
||||
156800000: "Marine CH16",
|
||||
# Add more as needed
|
||||
}
|
||||
|
||||
# --- WSJT-X and JS8Call Settings Initialization ---
|
||||
wsjtxMsgQueue = [] # Queue for WSJT-X detected messages
|
||||
js8callMsgQueue = [] # Queue for JS8Call detected messages
|
||||
wsjtx_enabled = False
|
||||
js8call_enabled = False
|
||||
wsjtx_udp_port = 2237
|
||||
js8call_udp_port = 2442
|
||||
watched_callsigns = []
|
||||
wsjtx_udp_address = '127.0.0.1'
|
||||
js8call_tcp_address = '127.0.0.1'
|
||||
js8call_tcp_port = 2442
|
||||
# WSJT-X UDP Protocol Message Types
|
||||
WSJTX_HEARTBEAT = 0
|
||||
WSJTX_STATUS = 1
|
||||
WSJTX_DECODE = 2
|
||||
WSJTX_CLEAR = 3
|
||||
WSJTX_REPLY = 4
|
||||
WSJTX_QSO_LOGGED = 5
|
||||
WSJTX_CLOSE = 6
|
||||
WSJTX_REPLAY = 7
|
||||
WSJTX_HALT_TX = 8
|
||||
WSJTX_FREE_TEXT = 9
|
||||
WSJTX_WSPR_DECODE = 10
|
||||
WSJTX_LOCATION = 11
|
||||
WSJTX_LOGGED_ADIF = 12
|
||||
|
||||
|
||||
try:
|
||||
from modules.settings import (
|
||||
wsjtx_detection_enabled,
|
||||
wsjtx_udp_server_address,
|
||||
wsjtx_watched_callsigns,
|
||||
js8call_detection_enabled,
|
||||
js8call_server_address,
|
||||
js8call_watched_callsigns
|
||||
)
|
||||
wsjtx_enabled = wsjtx_detection_enabled
|
||||
js8call_enabled = js8call_detection_enabled
|
||||
|
||||
# Use a local list to collect callsigns before assigning to watched_callsigns
|
||||
callsigns = []
|
||||
|
||||
if wsjtx_enabled:
|
||||
if ':' in wsjtx_udp_server_address:
|
||||
wsjtx_udp_address, port_str = wsjtx_udp_server_address.split(':')
|
||||
wsjtx_udp_port = int(port_str)
|
||||
if wsjtx_watched_callsigns:
|
||||
callsigns.extend([cs.strip() for cs in wsjtx_watched_callsigns.split(',') if cs.strip()])
|
||||
|
||||
if js8call_enabled:
|
||||
if ':' in js8call_server_address:
|
||||
js8call_tcp_address, port_str = js8call_server_address.split(':')
|
||||
js8call_tcp_port = int(port_str)
|
||||
if js8call_watched_callsigns:
|
||||
callsigns.extend([cs.strip() for cs in js8call_watched_callsigns.split(',') if cs.strip()])
|
||||
|
||||
# Clean up and deduplicate callsigns, uppercase for matching
|
||||
watched_callsigns = list({cs.upper() for cs in callsigns})
|
||||
|
||||
except ImportError:
|
||||
logger.debug("System: RadioMon: WSJT-X/JS8Call settings not configured")
|
||||
except Exception as e:
|
||||
logger.warning(f"System: RadioMon: Error loading WSJT-X/JS8Call settings: {e}")
|
||||
|
||||
|
||||
if radio_detection_enabled:
|
||||
# used by hamlib detection
|
||||
import socket
|
||||
|
||||
if voxDetectionEnabled:
|
||||
# methods available for trap word processing, these can be called by VOX detection when trap words are detected
|
||||
from mesh_bot import tell_joke, handle_wxc, handle_moon, handle_sun, handle_riverFlow, handle_tide, handle_satpass
|
||||
botMethods = {
|
||||
"joke": tell_joke,
|
||||
"weather": handle_wxc,
|
||||
"moon": handle_moon,
|
||||
"daylight": handle_sun,
|
||||
"river": handle_riverFlow,
|
||||
"tide": handle_tide,
|
||||
"satellite": handle_satpass}
|
||||
# module global variables
|
||||
previousVoxState = False
|
||||
voxHoldTime = signalHoldTime
|
||||
|
||||
try:
|
||||
import sounddevice as sd # pip install sounddevice sudo apt install portaudio19-dev
|
||||
from vosk import Model, KaldiRecognizer # pip install vosk
|
||||
import json
|
||||
q = asyncio.Queue(maxsize=32) # queue for audio data
|
||||
|
||||
if useLocalVoxModel:
|
||||
voxModel = Model(lang=localVoxModelPath) # use built in model for specified language
|
||||
else:
|
||||
voxModel = Model(lang=voxLanguage) # use built in model for specified language
|
||||
|
||||
except Exception as e:
|
||||
print(f"System: RadioMon: Error importing VOX dependencies: {e}")
|
||||
print(f"To use VOX detection please install the vosk and sounddevice python modules")
|
||||
print(f"pip install vosk sounddevice")
|
||||
print(f"sounddevice needs pulseaudio, apt-get install portaudio19-dev")
|
||||
voxDetectionEnabled = False
|
||||
logger.error(f"System: RadioMon: VOX detection disabled due to import error")
|
||||
|
||||
if meshagesTTS:
|
||||
try:
|
||||
# TTS for meshages imports
|
||||
logger.debug("System: RadioMon: Initializing TTS model for audible meshages")
|
||||
import sounddevice as sd
|
||||
from kittentts import KittenTTS
|
||||
ttsModel = KittenTTS("KittenML/kitten-tts-nano-0.2")
|
||||
available_voices = [
|
||||
'expr-voice-2-m', 'expr-voice-2-f', 'expr-voice-3-m', 'expr-voice-3-f',
|
||||
'expr-voice-4-m', 'expr-voice-4-f', 'expr-voice-5-m', 'expr-voice-5-f'
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"To use Meshages TTS please review the radio.md documentation for setup instructions.")
|
||||
meshagesTTS = False
|
||||
|
||||
async def generate_and_play_tts(text, voice, samplerate=24000):
|
||||
"""Async: Generate speech and play audio."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
logger.debug(f"System: RadioMon: Generating TTS for text: {text} with voice: {voice}")
|
||||
audio = await asyncio.to_thread(ttsModel.generate, text, voice=voice)
|
||||
if audio is None or len(audio) == 0:
|
||||
return
|
||||
await asyncio.to_thread(sd.play, audio, samplerate)
|
||||
await asyncio.to_thread(sd.wait)
|
||||
del audio
|
||||
except Exception as e:
|
||||
logger.warning(f"System: RadioMon: Error in generate_and_play_tts: {e}")
|
||||
|
||||
def get_freq_common_name(freq):
|
||||
freq = int(freq)
|
||||
name = FREQ_NAME_MAP.get(freq)
|
||||
if name:
|
||||
return name
|
||||
else:
|
||||
# Return MHz if not found
|
||||
return f"{freq/1000000} Mhz"
|
||||
|
||||
def get_hamlib(msg="f"):
|
||||
# get data from rigctld server
|
||||
if "socket" not in globals():
|
||||
logger.warning("System: RadioMon: 'socket' module not imported. Hamlib disabled.")
|
||||
return ERROR_FETCHING_DATA
|
||||
try:
|
||||
rigControlSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
rigControlSocket.settimeout(2)
|
||||
rigControlSocket.connect((rigControlServerAddress.split(":")[0],int(rigControlServerAddress.split(":")[1])))
|
||||
except Exception as e:
|
||||
print(f"\nSystem: Error connecting to rigctld: {e}")
|
||||
logger.error(f"System: RadioMon: Error connecting to rigctld: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
try:
|
||||
@@ -27,112 +248,49 @@ def get_hamlib(msg="f"):
|
||||
data = data.replace(b'\n',b'')
|
||||
return data.decode("utf-8").rstrip()
|
||||
except Exception as e:
|
||||
print(f"\nSystem: Error fetching data from rigctld: {e}")
|
||||
logger.error(f"System: RadioMon: Error fetching data from rigctld: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
def get_freq_common_name(freq):
|
||||
freq = int(freq)
|
||||
if freq == 462562500:
|
||||
return "GRMS CH1"
|
||||
elif freq == 462587500:
|
||||
return "GRMS CH2"
|
||||
elif freq == 462612500:
|
||||
return "GRMS CH3"
|
||||
elif freq == 462637500:
|
||||
return "GRMS CH4"
|
||||
elif freq == 462662500:
|
||||
return "GRMS CH5"
|
||||
elif freq == 462687500:
|
||||
return "GRMS CH6"
|
||||
elif freq == 462712500:
|
||||
return "GRMS CH7"
|
||||
elif freq == 467562500:
|
||||
return "GRMS CH8"
|
||||
elif freq == 467587500:
|
||||
return "GRMS CH9"
|
||||
elif freq == 467612500:
|
||||
return "GRMS CH10"
|
||||
elif freq == 467637500:
|
||||
return "GRMS CH11"
|
||||
elif freq == 467662500:
|
||||
return "GRMS CH12"
|
||||
elif freq == 467687500:
|
||||
return "GRMS CH13"
|
||||
elif freq == 467712500:
|
||||
return "GRMS CH14"
|
||||
elif freq == 467737500:
|
||||
return "GRMS CH15"
|
||||
elif freq == 462550000:
|
||||
return "GRMS CH16"
|
||||
elif freq == 462575000:
|
||||
return "GMRS CH17"
|
||||
elif freq == 462600000:
|
||||
return "GMRS CH18"
|
||||
elif freq == 462625000:
|
||||
return "GMRS CH19"
|
||||
elif freq == 462675000:
|
||||
return "GMRS CH20"
|
||||
elif freq == 462670000:
|
||||
return "GMRS CH21"
|
||||
elif freq == 462725000:
|
||||
return "GMRS CH22"
|
||||
elif freq == 462725500:
|
||||
return "GMRS CH23"
|
||||
elif freq == 467575000:
|
||||
return "GMRS CH24"
|
||||
elif freq == 467600000:
|
||||
return "GMRS CH25"
|
||||
elif freq == 467625000:
|
||||
return "GMRS CH26"
|
||||
elif freq == 467650000:
|
||||
return "GMRS CH27"
|
||||
elif freq == 467675000:
|
||||
return "GMRS CH28"
|
||||
elif freq == 467700000:
|
||||
return "FRS CH1"
|
||||
elif freq == 462575000:
|
||||
return "FRS CH2"
|
||||
elif freq == 462600000:
|
||||
return "FRS CH3"
|
||||
elif freq == 462650000:
|
||||
return "FRS CH5"
|
||||
elif freq == 462675000:
|
||||
return "FRS CH6"
|
||||
elif freq == 462700000:
|
||||
return "FRS CH7"
|
||||
elif freq == 462725000:
|
||||
return "FRS CH8"
|
||||
elif freq == 462562500:
|
||||
return "FRS CH9"
|
||||
elif freq == 462587500:
|
||||
return "FRS CH10"
|
||||
elif freq == 462612500:
|
||||
return "FRS CH11"
|
||||
elif freq == 462637500:
|
||||
return "FRS CH12"
|
||||
elif freq == 462662500:
|
||||
return "FRS CH13"
|
||||
elif freq == 462687500:
|
||||
return "FRS CH14"
|
||||
elif freq == 462712500:
|
||||
return "FRS CH15"
|
||||
elif freq == 462737500:
|
||||
return "FRS CH16"
|
||||
elif freq == 146520000:
|
||||
return "2M Simplex Calling"
|
||||
elif freq == 446000000:
|
||||
return "70cm Simplex Calling"
|
||||
elif freq == 156800000:
|
||||
return "Marine CH16"
|
||||
else:
|
||||
#return Mhz
|
||||
freq = freq/1000000
|
||||
return f"{freq} Mhz"
|
||||
|
||||
def get_sig_strength():
|
||||
strength = get_hamlib('l STRENGTH')
|
||||
return strength
|
||||
|
||||
def checkVoxTrapWords(text):
|
||||
try:
|
||||
if not voxOnTrapList:
|
||||
logger.debug(f"System: RadioMon: VOX detected: {text}")
|
||||
return text
|
||||
if text:
|
||||
traps = [voxTrapList] if isinstance(voxTrapList, str) else voxTrapList
|
||||
text_lower = text.lower()
|
||||
for trap in traps:
|
||||
trap_clean = trap.strip()
|
||||
trap_lower = trap_clean.lower()
|
||||
idx = text_lower.find(trap_lower)
|
||||
if debugVoxTmsg:
|
||||
logger.debug(f"System: RadioMon: VOX checking for trap word '{trap_lower}' in: '{text}' (index: {idx})")
|
||||
if idx != -1:
|
||||
new_text = text[idx + len(trap_clean):].strip()
|
||||
if debugVoxTmsg:
|
||||
logger.debug(f"System: RadioMon: VOX detected trap word '{trap_lower}' in: '{text}' (remaining: '{new_text}')")
|
||||
new_words = new_text.split()
|
||||
if voxEnableCmd:
|
||||
for word in new_words:
|
||||
if word in botMethods:
|
||||
logger.info(f"System: RadioMon: VOX action '{word}' with '{new_text}'")
|
||||
if word == "joke":
|
||||
return botMethods[word](vox=True)
|
||||
else:
|
||||
return botMethods[word](None, None, None, vox=True)
|
||||
logger.debug(f"System: RadioMon: VOX returning text after trap word '{trap_lower}': '{new_text}'")
|
||||
return new_text
|
||||
if debugVoxTmsg:
|
||||
logger.debug(f"System: RadioMon: VOX no trap word found in: '{text}'")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"System: RadioMon: Error in checkVoxTrapWords: {e}")
|
||||
return None
|
||||
|
||||
async def signalWatcher():
|
||||
global previousStrength
|
||||
global signalCycle
|
||||
@@ -140,6 +298,7 @@ async def signalWatcher():
|
||||
signalStrength = int(get_sig_strength())
|
||||
if signalStrength >= previousStrength and signalStrength > signalDetectionThreshold:
|
||||
message = f"Detected {get_freq_common_name(get_hamlib('f'))} active. S-Meter:{signalStrength}dBm"
|
||||
logger.debug(f"System: RadioMon: {message}. Waiting for {signalHoldTime} seconds")
|
||||
previousStrength = signalStrength
|
||||
signalCycle = 0
|
||||
await asyncio.sleep(signalHoldTime)
|
||||
@@ -156,4 +315,322 @@ async def signalWatcher():
|
||||
signalCycle = 0
|
||||
previousStrength = -40
|
||||
|
||||
# end of file
|
||||
async def make_vox_callback(loop, q):
|
||||
def vox_callback(indata, frames, time, status):
|
||||
if status:
|
||||
logger.warning(f"System: RadioMon: VOX input status: {status}")
|
||||
try:
|
||||
loop.call_soon_threadsafe(q.put_nowait, bytes(indata))
|
||||
except asyncio.QueueFull:
|
||||
# Drop the oldest item and add the new one
|
||||
try:
|
||||
q.get_nowait() # Remove oldest
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
try:
|
||||
loop.call_soon_threadsafe(q.put_nowait, bytes(indata))
|
||||
except asyncio.QueueFull:
|
||||
# If still full, just drop this frame
|
||||
logger.debug("System: RadioMon: VOX queue full, dropping audio frame")
|
||||
except RuntimeError:
|
||||
# Loop may be closed
|
||||
pass
|
||||
return vox_callback
|
||||
|
||||
async def voxMonitor():
|
||||
global previousVoxState, voxMsgQueue
|
||||
try:
|
||||
model = voxModel
|
||||
device_info = sd.query_devices(voxInputDevice, 'input')
|
||||
samplerate = 16000
|
||||
logger.debug(f"System: RadioMon: VOX monitor started on device {device_info['name']} with samplerate {samplerate} using trap words: {voxTrapList if voxOnTrapList else 'none'}")
|
||||
rec = KaldiRecognizer(model, samplerate)
|
||||
loop = asyncio.get_running_loop()
|
||||
callback = await make_vox_callback(loop, q)
|
||||
with sd.RawInputStream(
|
||||
device=voxInputDevice,
|
||||
samplerate=samplerate,
|
||||
blocksize=4000,
|
||||
dtype='int16',
|
||||
channels=1,
|
||||
callback=callback
|
||||
):
|
||||
while True:
|
||||
data = await q.get()
|
||||
if rec.AcceptWaveform(data):
|
||||
result = rec.Result()
|
||||
text = json.loads(result).get("text", "")
|
||||
# process text
|
||||
if text and text != 'huh':
|
||||
result = checkVoxTrapWords(text)
|
||||
if result:
|
||||
# If result is a function return, handle it (send to mesh, log, etc.)
|
||||
# If it's just text, handle as a normal message
|
||||
voxMsgQueue.append(result)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
except Exception as e:
|
||||
logger.warning(f"System: RadioMon: Error in VOX monitor: {e}")
|
||||
|
||||
def decode_wsjtx_packet(data):
|
||||
"""Decode WSJT-X UDP packet according to the protocol specification"""
|
||||
try:
|
||||
# WSJT-X uses Qt's QDataStream format (big-endian)
|
||||
magic = struct.unpack('>I', data[0:4])[0]
|
||||
if magic != 0xADBCCBDA:
|
||||
return None
|
||||
|
||||
schema_version = struct.unpack('>I', data[4:8])[0]
|
||||
msg_type = struct.unpack('>I', data[8:12])[0]
|
||||
|
||||
offset = 12
|
||||
|
||||
# Helper to read Qt QString (4-byte length + UTF-8 data)
|
||||
def read_qstring(data, offset):
|
||||
if offset + 4 > len(data):
|
||||
return "", offset
|
||||
length = struct.unpack('>I', data[offset:offset+4])[0]
|
||||
offset += 4
|
||||
if length == 0xFFFFFFFF: # Null string
|
||||
return "", offset
|
||||
if offset + length > len(data):
|
||||
return "", offset
|
||||
text = data[offset:offset+length].decode('utf-8', errors='ignore')
|
||||
return text, offset + length
|
||||
|
||||
# Decode DECODE message (type 2)
|
||||
if msg_type == WSJTX_DECODE:
|
||||
# Read fields according to WSJT-X protocol
|
||||
wsjtx_id, offset = read_qstring(data, offset)
|
||||
|
||||
# Read other decode fields: new, time, snr, delta_time, delta_frequency, mode, message
|
||||
if offset + 1 > len(data):
|
||||
return None
|
||||
new = struct.unpack('>?', data[offset:offset+1])[0]
|
||||
offset += 1
|
||||
|
||||
if offset + 4 > len(data):
|
||||
return None
|
||||
time_val = struct.unpack('>I', data[offset:offset+4])[0]
|
||||
offset += 4
|
||||
|
||||
if offset + 4 > len(data):
|
||||
return None
|
||||
snr = struct.unpack('>i', data[offset:offset+4])[0]
|
||||
offset += 4
|
||||
|
||||
if offset + 8 > len(data):
|
||||
return None
|
||||
delta_time = struct.unpack('>d', data[offset:offset+8])[0]
|
||||
offset += 8
|
||||
|
||||
if offset + 4 > len(data):
|
||||
return None
|
||||
delta_frequency = struct.unpack('>I', data[offset:offset+4])[0]
|
||||
offset += 4
|
||||
|
||||
mode, offset = read_qstring(data, offset)
|
||||
message, offset = read_qstring(data, offset)
|
||||
|
||||
return {
|
||||
'type': 'decode',
|
||||
'id': wsjtx_id,
|
||||
'new': new,
|
||||
'time': time_val,
|
||||
'snr': snr,
|
||||
'delta_time': delta_time,
|
||||
'delta_frequency': delta_frequency,
|
||||
'mode': mode,
|
||||
'message': message
|
||||
}
|
||||
|
||||
# Decode QSO_LOGGED message (type 5)
|
||||
elif msg_type == WSJTX_QSO_LOGGED:
|
||||
wsjtx_id, offset = read_qstring(data, offset)
|
||||
|
||||
# Read QSO logged fields
|
||||
if offset + 8 > len(data):
|
||||
return None
|
||||
date_off = struct.unpack('>Q', data[offset:offset+8])[0]
|
||||
offset += 8
|
||||
|
||||
if offset + 8 > len(data):
|
||||
return None
|
||||
time_off = struct.unpack('>Q', data[offset:offset+8])[0]
|
||||
offset += 8
|
||||
|
||||
dx_call, offset = read_qstring(data, offset)
|
||||
dx_grid, offset = read_qstring(data, offset)
|
||||
|
||||
return {
|
||||
'type': 'qso_logged',
|
||||
'id': wsjtx_id,
|
||||
'dx_call': dx_call,
|
||||
'dx_grid': dx_grid
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"System: RadioMon: Error decoding WSJT-X packet: {e}")
|
||||
return None
|
||||
|
||||
def check_callsign_match(message, callsigns):
|
||||
"""Check if any watched callsign appears in the message
|
||||
|
||||
Uses word boundary matching to avoid false positives like matching
|
||||
'K7' when looking for 'K7MHI'. Callsigns are expected to be
|
||||
separated by spaces or be at the start/end of the message.
|
||||
"""
|
||||
if not callsigns:
|
||||
return True # If no filter, accept all
|
||||
|
||||
message_upper = message.upper()
|
||||
# Split message into words for exact matching
|
||||
words = message_upper.split()
|
||||
|
||||
for callsign in callsigns:
|
||||
callsign_upper = callsign.upper()
|
||||
# Pre-compute patterns for portable/mobile suffixes
|
||||
callsign_with_slash = callsign_upper + '/'
|
||||
callsign_with_dash = callsign_upper + '-'
|
||||
slash_callsign = '/' + callsign_upper
|
||||
dash_callsign = '-' + callsign_upper
|
||||
|
||||
# Check if callsign appears as a complete word
|
||||
if callsign_upper in words:
|
||||
return True
|
||||
|
||||
# Check for callsigns in compound forms like "K7MHI/P" or "K7MHI-7"
|
||||
for word in words:
|
||||
if (word.startswith(callsign_with_slash) or
|
||||
word.startswith(callsign_with_dash) or
|
||||
word.endswith(slash_callsign) or
|
||||
word.endswith(dash_callsign)):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def wsjtxMonitor():
|
||||
"""Monitor WSJT-X UDP broadcasts for decode messages"""
|
||||
if not wsjtx_enabled:
|
||||
logger.warning("System: RadioMon: WSJT-X monitoring called but not enabled")
|
||||
return
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((wsjtx_udp_address, wsjtx_udp_port))
|
||||
sock.setblocking(False)
|
||||
|
||||
logger.info(f"System: RadioMon: WSJT-X UDP listener started on {wsjtx_udp_address}:{wsjtx_udp_port}")
|
||||
if watched_callsigns:
|
||||
logger.info(f"System: RadioMon: Watching for callsigns: {', '.join(watched_callsigns)}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
data, addr = sock.recvfrom(4096)
|
||||
decoded = decode_wsjtx_packet(data)
|
||||
|
||||
if decoded and decoded['type'] == 'decode':
|
||||
message = decoded['message']
|
||||
mode = decoded['mode']
|
||||
snr = decoded['snr']
|
||||
|
||||
# Check if message contains watched callsigns
|
||||
if check_callsign_match(message, watched_callsigns):
|
||||
msg_text = f"WSJT-X {mode}: {message} (SNR: {snr:+d}dB)"
|
||||
logger.info(f"System: RadioMon: {msg_text}")
|
||||
wsjtxMsgQueue.append(msg_text)
|
||||
|
||||
except BlockingIOError:
|
||||
# No data available
|
||||
await asyncio.sleep(0.1)
|
||||
except Exception as e:
|
||||
logger.debug(f"System: RadioMon: Error in WSJT-X monitor loop: {e}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"System: RadioMon: Error starting WSJT-X monitor: {e}")
|
||||
|
||||
async def js8callMonitor():
|
||||
"""Monitor JS8Call TCP API for messages"""
|
||||
if not js8call_enabled:
|
||||
logger.warning("System: RadioMon: JS8Call monitoring called but not enabled")
|
||||
return
|
||||
|
||||
try:
|
||||
logger.info(f"System: RadioMon: JS8Call TCP listener connecting to {js8call_tcp_address}:{js8call_tcp_port}")
|
||||
if watched_callsigns:
|
||||
logger.info(f"System: RadioMon: Watching for callsigns: {', '.join(watched_callsigns)}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Connect to JS8Call TCP API
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
sock.connect((js8call_tcp_address, js8call_tcp_port))
|
||||
sock.setblocking(False)
|
||||
|
||||
logger.info("System: RadioMon: Connected to JS8Call API")
|
||||
|
||||
buffer = ""
|
||||
while True:
|
||||
try:
|
||||
data = sock.recv(4096)
|
||||
if not data:
|
||||
logger.warning("System: RadioMon: JS8Call connection closed")
|
||||
break
|
||||
|
||||
buffer += data.decode('utf-8', errors='ignore')
|
||||
|
||||
# Process complete JSON messages (newline delimited)
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
msg_type = msg.get('type', '')
|
||||
|
||||
# Handle RX.DIRECTED and RX.ACTIVITY messages
|
||||
if msg_type in ['RX.DIRECTED', 'RX.ACTIVITY']:
|
||||
params = msg.get('params', {})
|
||||
text = params.get('TEXT', '')
|
||||
from_call = params.get('FROM', '')
|
||||
snr = params.get('SNR', 0)
|
||||
|
||||
if text and check_callsign_match(text, watched_callsigns):
|
||||
msg_text = f"JS8Call from {from_call}: {text} (SNR: {snr:+d}dB)"
|
||||
logger.info(f"System: RadioMon: {msg_text}")
|
||||
js8callMsgQueue.append(msg_text)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.debug(f"System: RadioMon: Invalid JSON from JS8Call: {line[:100]}")
|
||||
except Exception as e:
|
||||
logger.debug(f"System: RadioMon: Error processing JS8Call message: {e}")
|
||||
|
||||
except BlockingIOError:
|
||||
await asyncio.sleep(0.1)
|
||||
except socket.timeout:
|
||||
await asyncio.sleep(0.1)
|
||||
except Exception as e:
|
||||
logger.debug(f"System: RadioMon: Error in JS8Call receive loop: {e}")
|
||||
break
|
||||
|
||||
sock.close()
|
||||
logger.warning("System: RadioMon: JS8Call connection lost, reconnecting in 5s...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
except socket.timeout:
|
||||
logger.warning("System: RadioMon: JS8Call connection timeout, retrying in 5s...")
|
||||
await asyncio.sleep(5)
|
||||
except Exception as e:
|
||||
logger.warning(f"System: RadioMon: Error connecting to JS8Call: {e}")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"System: RadioMon: Error starting JS8Call monitor: {e}")
|
||||
|
||||
# end of file
|
||||
|
||||
184
modules/rss.py
Normal file
184
modules/rss.py
Normal file
@@ -0,0 +1,184 @@
|
||||
# rss feed module for meshing-around 2025
|
||||
from modules.log import logger
|
||||
from modules.settings import rssFeedURL, rssFeedNames, rssMaxItems, rssTruncate, urlTimeoutSeconds, ERROR_FETCHING_DATA, newsAPI_KEY, newsAPIsort
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
import html
|
||||
from html.parser import HTMLParser
|
||||
import bs4 as bs
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Common User-Agent for all RSS requests
|
||||
COMMON_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'
|
||||
|
||||
class MLStripper(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.reset()
|
||||
self.fed = []
|
||||
def handle_data(self, d):
|
||||
self.fed.append(d)
|
||||
def get_data(self):
|
||||
return ''.join(self.fed)
|
||||
|
||||
def strip_tags(html_text):
|
||||
# use BeautifulSoup to strip HTML tags
|
||||
if not html_text:
|
||||
return ""
|
||||
soup = bs.BeautifulSoup(html_text, "html.parser")
|
||||
text = soup.get_text(separator=" ", strip=True)
|
||||
return ' '.join(text.split())
|
||||
|
||||
RSS_FEED_URLS = rssFeedURL
|
||||
RSS_FEED_NAMES = rssFeedNames
|
||||
RSS_RETURN_COUNT = rssMaxItems
|
||||
RSS_TRIM_LENGTH = rssTruncate
|
||||
|
||||
def get_rss_feed(msg):
|
||||
# Determine which feed to use
|
||||
feed_name = ""
|
||||
msg_lower = msg.lower() if msg else ""
|
||||
if msg_lower and any(name.lower() in msg_lower for name in RSS_FEED_NAMES):
|
||||
for name in RSS_FEED_NAMES:
|
||||
if name.lower() in msg_lower:
|
||||
feed_name = name
|
||||
break
|
||||
else:
|
||||
logger.debug(f"RSS: No feed name found in message '{msg}'. Using default feed.")
|
||||
feed_name = RSS_FEED_NAMES[0] if RSS_FEED_NAMES else "default"
|
||||
|
||||
try:
|
||||
idx = RSS_FEED_NAMES.index(feed_name)
|
||||
feed_url = RSS_FEED_URLS[idx]
|
||||
except (ValueError, IndexError):
|
||||
logger.warning(f"RSS: Feed '{feed_name}' not found in RSS_FEED_URLS ({RSS_FEED_URLS}).")
|
||||
return f"Feed '{feed_name}' not found."
|
||||
|
||||
if "?" in msg_lower:
|
||||
return f"Fetches the latest {RSS_RETURN_COUNT} entries RSS feeds. Available feeds are: {', '.join(RSS_FEED_NAMES)}. To fetch a specific feed, include its name in your request."
|
||||
|
||||
# Fetch and parse the RSS feed
|
||||
try:
|
||||
logger.debug(f"Fetching RSS feed from {feed_url} from message '{msg}'")
|
||||
agent = {'User-Agent': COMMON_USER_AGENT}
|
||||
request = urllib.request.Request(feed_url, headers=agent)
|
||||
with urllib.request.urlopen(request, timeout=urlTimeoutSeconds) as response:
|
||||
xml_data = response.read()
|
||||
root = ET.fromstring(xml_data)
|
||||
|
||||
# Find all <item> (RSS) and <entry> (Atom) elements, regardless of namespace
|
||||
items = []
|
||||
for elem in root.iter():
|
||||
if elem.tag.endswith('item') or elem.tag.endswith('entry'):
|
||||
items.append(elem)
|
||||
items = items[:RSS_RETURN_COUNT]
|
||||
|
||||
if not items:
|
||||
logger.debug(f"No RSS or Atom feed entries found in feed xml_data: {xml_data[:500]}...")
|
||||
return "No RSS or Atom feed entries found."
|
||||
|
||||
formatted_entries = []
|
||||
seen_first3 = set() # Track first 3 words (lowercased) to avoid duplicates
|
||||
for item in items:
|
||||
# Helper to try multiple tag names
|
||||
def find_any(item, tags):
|
||||
for tag in tags:
|
||||
val = item.findtext(tag)
|
||||
if val:
|
||||
return val
|
||||
return None
|
||||
|
||||
title = find_any(item, [
|
||||
'title',
|
||||
'{http://purl.org/rss/1.0/}title',
|
||||
'{http://www.w3.org/2005/Atom}title'
|
||||
])
|
||||
|
||||
# Atom links are often attributes, not text
|
||||
link = find_any(item, [
|
||||
'link',
|
||||
'{http://purl.org/rss/1.0/}link',
|
||||
'{http://www.w3.org/2005/Atom}link'
|
||||
])
|
||||
if not link:
|
||||
link_elem = item.find('{http://www.w3.org/2005/Atom}link')
|
||||
if link_elem is not None and 'href' in link_elem.attrib:
|
||||
link = link_elem.attrib['href']
|
||||
|
||||
description = find_any(item, [
|
||||
'description',
|
||||
'{http://purl.org/rss/1.0/}description',
|
||||
'{http://purl.org/rss/1.0/modules/content/}encoded',
|
||||
'{http://www.w3.org/2005/Atom}summary',
|
||||
'{http://www.w3.org/2005/Atom}content'
|
||||
])
|
||||
pub_date = find_any(item, [
|
||||
'pubDate',
|
||||
'{http://purl.org/dc/elements/1.1/}date',
|
||||
'{http://www.w3.org/2005/Atom}updated'
|
||||
])
|
||||
|
||||
# Unescape HTML entities and strip tags
|
||||
description = html.unescape(description) if description else ""
|
||||
description = strip_tags(description)
|
||||
if len(description) > RSS_TRIM_LENGTH:
|
||||
description = description[:RSS_TRIM_LENGTH - 3] + "..."
|
||||
|
||||
# Duplicate check: use first 3 words of description (or title if description is empty)
|
||||
text_for_dupe = description if description else (title or "")
|
||||
first3 = " ".join(text_for_dupe.lower().split()[:3])
|
||||
if first3 in seen_first3:
|
||||
continue
|
||||
seen_first3.add(first3)
|
||||
|
||||
formatted_entries.append(f"{title}\n{description}\n")
|
||||
return "\n".join(formatted_entries)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching RSS feed from {feed_url}: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
def get_newsAPI(user_search="meshtastic", message_from_id=None, deviceID=None, isDM=False):
|
||||
# Fetch news from NewsAPI.org
|
||||
user_search = user_search.strip()
|
||||
# check api_throttle
|
||||
from modules.system import api_throttle
|
||||
check_throttle = api_throttle(message_from_id, deviceID, apiName="NewsAPI")
|
||||
if check_throttle:
|
||||
return check_throttle # Return throttle message if applicable
|
||||
|
||||
if user_search.lower().startswith("latest"):
|
||||
user_search = user_search[6:].strip()
|
||||
if not user_search:
|
||||
user_search = "meshtastic"
|
||||
try:
|
||||
last_week = datetime.now() - timedelta(days=7)
|
||||
newsAPIurl = (
|
||||
f"https://newsapi.org/v2/everything?"
|
||||
f"q={user_search}&language=en&from={last_week.strftime('%Y-%m-%d')}&sortBy={newsAPIsort}shedAt&pageSize=5&apiKey={newsAPI_KEY}"
|
||||
)
|
||||
|
||||
response = requests.get(newsAPIurl, headers={"User-Agent": COMMON_USER_AGENT}, timeout=urlTimeoutSeconds)
|
||||
news_data = response.json()
|
||||
|
||||
if news_data.get("status") != "ok":
|
||||
error_message = news_data.get("message", "Unknown error")
|
||||
logger.error(f"NewsAPI error: {error_message}")
|
||||
return ERROR_FETCHING_DATA
|
||||
logger.debug(f"System: NewsAPI Searching for '{user_search}' got {news_data.get('totalResults', 0)} results")
|
||||
articles = news_data.get("articles", [])[:3]
|
||||
news_list = []
|
||||
for article in articles:
|
||||
title = article.get("title", "No Title")
|
||||
url = article.get("url", "")
|
||||
description = article.get("description", '')
|
||||
news_list.append(f"📰{title}\n{description}")
|
||||
|
||||
# Make a nice newspaper style output
|
||||
msg = f"🗞️:"
|
||||
for item in news_list:
|
||||
msg += item + "\n\n"
|
||||
return msg.strip()
|
||||
except Exception as e:
|
||||
logger.error(f"System: NewsAPI fetching news: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
199
modules/scheduler.py
Normal file
199
modules/scheduler.py
Normal file
@@ -0,0 +1,199 @@
|
||||
# modules/scheduler.py 2025 meshing-around
|
||||
# Scheduler module for mesh_bot
|
||||
import asyncio
|
||||
import schedule
|
||||
from datetime import datetime
|
||||
from modules.log import logger
|
||||
from modules.system import send_message
|
||||
from modules.settings import MOTD, schedulerMotd, schedulerMessage, schedulerChannel, schedulerInterface, schedulerValue, schedulerTime, schedulerInterval
|
||||
|
||||
async def run_scheduler_loop(interval=1):
|
||||
logger.debug(f"System: Scheduler loop started Tasks: {len(schedule.jobs)}, Details:{extract_schedule_fields(schedule.get_jobs())}")
|
||||
try:
|
||||
last_logged_minute = -1
|
||||
while True:
|
||||
try:
|
||||
# Log scheduled jobs every 20 minutes
|
||||
now = datetime.now()
|
||||
if now.minute % 20 == 0 and now.minute != last_logged_minute:
|
||||
logger.debug(f"System: Scheduled Tasks {len(schedule.jobs)}, Details:{extract_schedule_fields(schedule.get_jobs())}")
|
||||
last_logged_minute = now.minute
|
||||
schedule.run_pending()
|
||||
except Exception as e:
|
||||
logger.error(f"System: Scheduler loop exception: {e}")
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("System: Scheduler loop cancelled, shutting down.")
|
||||
|
||||
def safe_int(val, default=0, type=''):
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
if val != '':
|
||||
logger.debug(f"System: Scheduler config {type} error '{val}' to int, using default {default}")
|
||||
return default
|
||||
|
||||
def extract_schedule_fields(jobs):
|
||||
"""
|
||||
Extracts 'Every ... (last run: [...], next run: ...)' from schedule.get_jobs() output without regex.
|
||||
"""
|
||||
jobs_str = str(jobs)
|
||||
results = []
|
||||
# Split by '), ' to separate jobs, then add ')' back except last
|
||||
parts = jobs_str.split('), ')
|
||||
for i, part in enumerate(parts):
|
||||
if not part.endswith(')'):
|
||||
part += ')'
|
||||
# Find the start of 'Every'
|
||||
start = part.find('Every')
|
||||
if start != -1:
|
||||
# Find the start of 'do <lambda>()'
|
||||
do_idx = part.find('do ')
|
||||
if do_idx != -1:
|
||||
summary = part[start:do_idx].strip()
|
||||
# Find the (last run: ... next run: ...) part
|
||||
paren_idx = part.find('(', do_idx)
|
||||
if paren_idx != -1:
|
||||
summary += ' ' + part[paren_idx:].strip()
|
||||
while '<function ' in summary:
|
||||
f_start = summary.find('<function ')
|
||||
f_end = summary.find('>', f_start)
|
||||
if f_end == -1:
|
||||
break
|
||||
func_str = summary[f_start+10:f_end]
|
||||
func_name = func_str.split(' ')[0]
|
||||
summary = summary[:f_start] + func_name + summary[f_end+1:]
|
||||
results.append(summary)
|
||||
return results
|
||||
|
||||
def setup_scheduler(
|
||||
schedulerMotd, MOTD, schedulerMessage, schedulerChannel, schedulerInterface,
|
||||
schedulerValue, schedulerTime, schedulerInterval):
|
||||
try:
|
||||
# Methods imported from mesh_bot for scheduling tasks
|
||||
from mesh_bot import (
|
||||
tell_joke,
|
||||
welcome_message,
|
||||
handle_wxc,
|
||||
handle_moon,
|
||||
handle_sun,
|
||||
handle_riverFlow,
|
||||
handle_tide,
|
||||
handle_satpass,
|
||||
handleNews,
|
||||
handle_mwx,
|
||||
sysinfo,
|
||||
)
|
||||
from modules.rss import get_rss_feed
|
||||
except ImportError as e:
|
||||
logger.warning(f"Some mesh_bot schedule features are unavailable by option disable in config.ini: {e} comment out the use of these methods in your custom_scheduler.py")
|
||||
|
||||
# Setup the scheduler based on configuration
|
||||
schedulerValue = schedulerValue.lower().strip()
|
||||
schedulerTime = schedulerTime.strip()
|
||||
schedulerInterval = schedulerInterval.strip()
|
||||
schedulerChannel = safe_int(schedulerChannel, 0, type="channel")
|
||||
schedulerInterface = safe_int(schedulerInterface, 1, type="interface")
|
||||
schedulerIntervalInt = safe_int(schedulerInterval, 5, type="interval")
|
||||
|
||||
try:
|
||||
scheduler_message = MOTD if schedulerMotd else schedulerMessage
|
||||
|
||||
def send_sched_msg():
|
||||
send_message(scheduler_message, schedulerChannel, 0, schedulerInterface)
|
||||
|
||||
# Basic Scheduler Options
|
||||
basicOptions = ['day', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun', 'hour', 'min']
|
||||
if any(option in schedulerValue for option in basicOptions):
|
||||
if schedulerValue == 'day':
|
||||
if schedulerTime:
|
||||
# Specific time each day
|
||||
schedule.every().day.at(schedulerTime).do(send_sched_msg)
|
||||
else:
|
||||
# Every N days
|
||||
schedule.every(schedulerIntervalInt).days.do(send_sched_msg)
|
||||
elif 'mon' in schedulerValue and schedulerTime:
|
||||
schedule.every().monday.at(schedulerTime).do(send_sched_msg)
|
||||
elif 'tue' in schedulerValue and schedulerTime:
|
||||
schedule.every().tuesday.at(schedulerTime).do(send_sched_msg)
|
||||
elif 'wed' in schedulerValue and schedulerTime:
|
||||
schedule.every().wednesday.at(schedulerTime).do(send_sched_msg)
|
||||
elif 'thu' in schedulerValue and schedulerTime:
|
||||
schedule.every().thursday.at(schedulerTime).do(send_sched_msg)
|
||||
elif 'fri' in schedulerValue and schedulerTime:
|
||||
schedule.every().friday.at(schedulerTime).do(send_sched_msg)
|
||||
elif 'sat' in schedulerValue and schedulerTime:
|
||||
schedule.every().saturday.at(schedulerTime).do(send_sched_msg)
|
||||
elif 'sun' in schedulerValue and schedulerTime:
|
||||
schedule.every().sunday.at(schedulerTime).do(send_sched_msg)
|
||||
elif 'hour' in schedulerValue:
|
||||
schedule.every(schedulerIntervalInt).hours.do(send_sched_msg)
|
||||
elif 'min' in schedulerValue:
|
||||
schedule.every(schedulerIntervalInt).minutes.do(send_sched_msg)
|
||||
logger.debug(f"System: Starting the basic scheduler to send '{scheduler_message}' on schedule '{schedulerValue}' every {schedulerIntervalInt} interval at time '{schedulerTime}' on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'joke' in schedulerValue:
|
||||
schedule.every(schedulerIntervalInt).minutes.do(
|
||||
lambda: send_message(tell_joke(), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the joke scheduler to send a joke every {schedulerIntervalInt} minutes on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'link' in schedulerValue:
|
||||
schedule.every(schedulerIntervalInt).hours.do(
|
||||
lambda: send_message("bbslink MeshBot looking for peers", schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the link scheduler to send link messages every {schedulerIntervalInt} hours on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'weather' in schedulerValue:
|
||||
schedule.every().day.at(schedulerTime).do(
|
||||
lambda: send_message(handle_wxc(0, schedulerInterface, 'wx', days=1), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the weather scheduler to send weather updates every {schedulerIntervalInt} hours on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'news' in schedulerValue:
|
||||
schedule.every(schedulerIntervalInt).hours.do(
|
||||
lambda: send_message(handleNews(0, schedulerInterface, 'readnews', False), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the news scheduler to send news updates every {schedulerIntervalInt} hours on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'readrss' in schedulerValue:
|
||||
schedule.every(schedulerIntervalInt).hours.do(
|
||||
lambda: send_message(get_rss_feed(''), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the RSS scheduler to send RSS feeds every {schedulerIntervalInt} hours on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'mwx' in schedulerValue:
|
||||
schedule.every().day.at(schedulerTime).do(
|
||||
lambda: send_message(handle_mwx(0, schedulerInterface, 'mwx'), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the marine weather scheduler to send marine weather updates at {schedulerTime} on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'sysinfo' in schedulerValue:
|
||||
schedule.every(schedulerIntervalInt).hours.do(
|
||||
lambda: send_message(sysinfo('', 0, schedulerInterface, False), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the sysinfo scheduler to send system information every {schedulerIntervalInt} hours on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'tide' in schedulerValue:
|
||||
schedule.every().day.at(schedulerTime).do(
|
||||
lambda: send_message(handle_tide(0, schedulerInterface, schedulerChannel), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the tide scheduler to send tide information at {schedulerTime} on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'solar' in schedulerValue:
|
||||
schedule.every().day.at(schedulerTime).do(
|
||||
lambda: send_message(handle_sun(0, schedulerInterface, schedulerChannel), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the scheduler to send solar information at {schedulerTime} on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'verse' in schedulerValue:
|
||||
from modules.filemon import read_verse
|
||||
schedule.every().day.at(schedulerTime).do(
|
||||
lambda: send_message(read_verse(), schedulerChannel, 0, schedulerInterface)
|
||||
)
|
||||
logger.debug(f"System: Starting the verse scheduler to send a verse at {schedulerTime} on Device:{schedulerInterface} Channel:{schedulerChannel}")
|
||||
elif 'custom' in schedulerValue:
|
||||
try:
|
||||
from modules.custom_scheduler import setup_custom_schedules # type: ignore
|
||||
setup_custom_schedules(
|
||||
send_message, tell_joke, welcome_message, handle_wxc, MOTD,
|
||||
schedulerChannel, schedulerInterface)
|
||||
logger.debug(f"System: Starting the custom_scheduler.py ")
|
||||
schedule.every().monday.at("12:00").do(
|
||||
lambda: logger.info("System: Scheduled Broadcast Enabled Reminder")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Custom scheduler file not found or failed to import. cp etc/custom_scheduler.template modules/custom_scheduler.py")
|
||||
except Exception as e:
|
||||
logger.error(f"System: Scheduler Error {e}")
|
||||
return True
|
||||
@@ -1,14 +1,16 @@
|
||||
# Settings for MeshBot and PongBot
|
||||
# 2024 Kelly Keeton K7MHI
|
||||
import configparser
|
||||
|
||||
# messages
|
||||
NO_DATA_NOGPS = "No location data: does your device have GPS?"
|
||||
ERROR_FETCHING_DATA = "error fetching data"
|
||||
WELCOME_MSG = 'MeshBot, here for you like a friend who is not. Try sending: ping @foo or, cmd? for more'
|
||||
WELCOME_MSG = 'MeshBot, here for you like a friend who is not. Try sending: ping @foo or, CMD? for more'
|
||||
EMERGENCY_RESPONSE = "MeshBot detected a possible request for Emergency Assistance and alerted a wider audience."
|
||||
MOTD = 'Thanks for using MeshBOT! Have a good day!'
|
||||
NO_ALERTS = "No weather alerts found."
|
||||
NO_ALERTS = "No alerts found."
|
||||
|
||||
# setup the global variables
|
||||
MESSAGE_CHUNK_SIZE = 160 # message chunk size for sending at high success rate
|
||||
SITREP_NODE_COUNT = 3 # number of nodes to report in the sitrep
|
||||
msg_history = [] # message history for the store and forward feature
|
||||
bbs_ban_list = [] # list of banned users, imported from config
|
||||
@@ -18,33 +20,131 @@ antiSpam = True # anti-spam feature to prevent flooding public channel
|
||||
ping_enabled = True # ping feature to respond to pings, ack's etc.
|
||||
sitrep_enabled = True # sitrep feature to respond to sitreps
|
||||
lastHamLibAlert = 0 # last alert from hamlib
|
||||
max_retry_count1 = 4 # max retry count for interface 1
|
||||
max_retry_count2 = 4 # max retry count for interface 2
|
||||
lastFileAlert = 0 # last alert from file monitor
|
||||
max_retry_count1 = max_retry_count2 = max_retry_count3 = max_retry_count4 = max_retry_count5 = max_retry_count6 = max_retry_count7 = max_retry_count8 = max_retry_count9 = 4 # default retry count for interfaces
|
||||
retry_int1 = False
|
||||
retry_int2 = False
|
||||
wiki_return_limit = 3 # limit the number of sentences returned off the first paragraph first hit
|
||||
GAMEDELAY = 28800 # 8 hours in seconds for game mode holdoff
|
||||
cmdHistory = [] # list to hold the last commands
|
||||
seenNodes = [] # list to hold the last seen nodes
|
||||
cmdHistory = [] # list to hold the command history for lheard and history commands
|
||||
msg_history = [] # list to hold the message history for the messages command
|
||||
max_bytes = 200 # Meshtastic has ~237 byte limit, use conservative 200 bytes for message content
|
||||
voxMsgQueue = [] # queue for VOX detected messages
|
||||
tts_read_queue = [] # queue for TTS messages
|
||||
wsjtxMsgQueue = [] # queue for WSJT-X detected messages
|
||||
js8callMsgQueue = [] # queue for JS8Call detected messages
|
||||
autoBanlist = [] # list of nodes to autoban for repeated offenses
|
||||
apiThrottleList = [] # list of nodes to throttle API requests for repeated offenses
|
||||
# Game trackers
|
||||
surveyTracker = [] # Survey game tracker
|
||||
tictactoeTracker = [] # TicTacToe game tracker
|
||||
hamtestTracker = [] # Ham radio test tracker
|
||||
hangmanTracker = [] # Hangman game tracker
|
||||
golfTracker = [] # GolfSim game tracker
|
||||
mastermindTracker = [] # Mastermind game tracker
|
||||
vpTracker = [] # Video Poker game tracker
|
||||
jackTracker = [] # Blackjack game tracker
|
||||
lemonadeTracker = [] # Lemonade Stand game tracker
|
||||
dwPlayerTracker = [] # DopeWars player tracker
|
||||
jackTracker = [] # Jack game tracker
|
||||
mindTracker = [] # Mastermind (mmind) game tracker
|
||||
battleshipTracker = [] # Battleship game tracker
|
||||
|
||||
# Memory Management Constants
|
||||
MAX_MSG_HISTORY = 250
|
||||
MAX_CMD_HISTORY = 250
|
||||
MAX_SEEN_NODES = 1000
|
||||
CLEANUP_INTERVAL = 86400 # 24 hours in seconds
|
||||
GAMEDELAY = 3 * CLEANUP_INTERVAL # 3 days in seconds
|
||||
|
||||
# Read the config file, if it does not exist, create basic config file
|
||||
config = configparser.ConfigParser()
|
||||
config_file = "config.ini"
|
||||
|
||||
try:
|
||||
config.read(config_file)
|
||||
config.read(config_file, encoding='utf-8')
|
||||
except Exception as e:
|
||||
print(f"System: Error reading config file: {e}")
|
||||
# exit if we can't read the config file
|
||||
print(f"System: Check the config.ini against config.template file for missing sections or values.")
|
||||
print(f"System: Exiting...")
|
||||
exit(1)
|
||||
|
||||
if config.sections() == []:
|
||||
print(f"System: Error reading config file: {config_file} is empty or does not exist.")
|
||||
config['interface'] = {'type': 'serial', 'port': "/dev/ttyACM0", 'hostname': '', 'mac': ''}
|
||||
config['general'] = {'respond_by_dm_only': 'True', 'defaultChannel': '0', 'motd': MOTD,
|
||||
'welcome_message': WELCOME_MSG, 'zuluTime': 'False'}
|
||||
config['general'] = {'respond_by_dm_only': 'True', 'defaultChannel': '0', 'motd': MOTD, 'welcome_message': WELCOME_MSG, 'zuluTime': 'False'}
|
||||
config.write(open(config_file, 'w'))
|
||||
print (f"System: Config file created, check {config_file} or review the config.template")
|
||||
|
||||
if 'sentry' not in config:
|
||||
config['sentry'] = {'SentryEnabled': 'False', 'SentryChannel': '2', 'SentryHoldoff': '9', 'sentryIgnoreList': '', 'SentryRadius': '100'}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'location' not in config:
|
||||
config['location'] = {'enabled': 'True', 'lat': '48.50', 'lon': '-123.0', 'fuzzConfigLocation': 'True',}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'bbs' not in config:
|
||||
config['bbs'] = {'enabled': 'False', 'bbsdb': 'data/bbsdb.pkl', 'bbs_ban_list': '', 'bbs_admin_list': ''}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'repeater' not in config:
|
||||
config['repeater'] = {'enabled': 'False', 'repeater_channels': ''}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'radioMon' not in config:
|
||||
config['radioMon'] = {'enabled': 'False', 'rigControlServerAddress': 'localhost:4532', 'sigWatchBrodcastCh': '2', 'signalDetectionThreshold': '-10', 'signalHoldTime': '10', 'signalCooldown': '5', 'signalCycleLimit': '5'}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'games' not in config:
|
||||
config['games'] = {'dopeWars': 'True', 'lemonade': 'True', 'blackjack': 'True', 'videoPoker': 'True'}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'messagingSettings' not in config:
|
||||
config['messagingSettings'] = {'responseDelay': '0.7', 'splitDelay': '0', 'MESSAGE_CHUNK_SIZE': '160'}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'fileMon' not in config:
|
||||
config['fileMon'] = {'enabled': 'False', 'file_path': 'alert.txt', 'broadcastCh': '2'}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'scheduler' not in config:
|
||||
config['scheduler'] = {'enabled': 'False'}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'emergencyHandler' not in config:
|
||||
config['emergencyHandler'] = {'enabled': 'False', 'alert_channel': '2', 'alert_interface': '1', 'email': ''}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'smtp' not in config:
|
||||
config['smtp'] = {'sysopEmails': '', 'enableSMTP': 'False', 'enableImap': 'False'}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'checklist' not in config:
|
||||
config['checklist'] = {'enabled': 'False', 'checklist_db': 'data/checklist.db'}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'qrz' not in config:
|
||||
config['qrz'] = {'enabled': 'False', 'qrz_db': 'data/qrz.db', 'qrz_hello_string': 'send CMD or DM me for more info.'}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'inventory' not in config:
|
||||
config['inventory'] = {'enabled': 'False', 'inventory_db': 'data/inventory.db', 'disable_penny': 'False'}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
if 'location' not in config:
|
||||
config['location'] = {'locations_db': 'data/locations.db', 'public_location_admin_manage': 'False', 'delete_public_locations_admins_only': 'False'}
|
||||
config.write(open(config_file, 'w'))
|
||||
|
||||
# interface1 settings
|
||||
interface1_type = config['interface'].get('type', 'serial')
|
||||
port1 = config['interface'].get('port', '')
|
||||
hostname1 = config['interface'].get('hostname', '')
|
||||
mac1 = config['interface'].get('mac', '')
|
||||
interface1_enabled = True # gotta have at least one interface
|
||||
|
||||
# interface2 settings
|
||||
if 'interface2' in config:
|
||||
@@ -56,40 +156,372 @@ if 'interface2' in config:
|
||||
else:
|
||||
interface2_enabled = False
|
||||
|
||||
# variables
|
||||
# interface3 settings
|
||||
if 'interface3' in config:
|
||||
interface3_type = config['interface3'].get('type', 'serial')
|
||||
port3 = config['interface3'].get('port', '')
|
||||
hostname3 = config['interface3'].get('hostname', '')
|
||||
mac3 = config['interface3'].get('mac', '')
|
||||
interface3_enabled = config['interface3'].getboolean('enabled', False)
|
||||
else:
|
||||
interface3_enabled = False
|
||||
|
||||
# interface4 settings
|
||||
if 'interface4' in config:
|
||||
interface4_type = config['interface4'].get('type', 'serial')
|
||||
port4 = config['interface4'].get('port', '')
|
||||
hostname4 = config['interface4'].get('hostname', '')
|
||||
mac4 = config['interface4'].get('mac', '')
|
||||
interface4_enabled = config['interface4'].getboolean('enabled', False)
|
||||
else:
|
||||
interface4_enabled = False
|
||||
|
||||
# interface5 settings
|
||||
if 'interface5' in config:
|
||||
interface5_type = config['interface5'].get('type', 'serial')
|
||||
port5 = config['interface5'].get('port', '')
|
||||
hostname5 = config['interface5'].get('hostname', '')
|
||||
mac5 = config['interface5'].get('mac', '')
|
||||
interface5_enabled = config['interface5'].getboolean('enabled', False)
|
||||
else:
|
||||
interface5_enabled = False
|
||||
|
||||
# interface6 settings
|
||||
if 'interface6' in config:
|
||||
interface6_type = config['interface6'].get('type', 'serial')
|
||||
port6 = config['interface6'].get('port', '')
|
||||
hostname6 = config['interface6'].get('hostname', '')
|
||||
mac6 = config['interface6'].get('mac', '')
|
||||
interface6_enabled = config['interface6'].getboolean('enabled', False)
|
||||
else:
|
||||
interface6_enabled = False
|
||||
|
||||
# interface7 settings
|
||||
if 'interface7' in config:
|
||||
interface7_type = config['interface7'].get('type', 'serial')
|
||||
port7 = config['interface7'].get('port', '')
|
||||
hostname7 = config['interface7'].get('hostname', '')
|
||||
mac7 = config['interface7'].get('mac', '')
|
||||
interface7_enabled = config['interface7'].getboolean('enabled', False)
|
||||
else:
|
||||
interface7_enabled = False
|
||||
|
||||
# interface8 settings
|
||||
if 'interface8' in config:
|
||||
interface8_type = config['interface8'].get('type', 'serial')
|
||||
port8 = config['interface8'].get('port', '')
|
||||
hostname8 = config['interface8'].get('hostname', '')
|
||||
mac8 = config['interface8'].get('mac', '')
|
||||
interface8_enabled = config['interface8'].getboolean('enabled', False)
|
||||
else:
|
||||
interface8_enabled = False
|
||||
|
||||
# interface9 settings
|
||||
if 'interface9' in config:
|
||||
interface9_type = config['interface9'].get('type', 'serial')
|
||||
port9 = config['interface9'].get('port', '')
|
||||
hostname9 = config['interface9'].get('hostname', '')
|
||||
mac9 = config['interface9'].get('mac', '')
|
||||
interface9_enabled = config['interface9'].getboolean('enabled', False)
|
||||
else:
|
||||
interface9_enabled = False
|
||||
|
||||
multiple_interface = False
|
||||
if interface2_enabled or interface3_enabled or interface4_enabled or interface5_enabled or interface6_enabled or interface7_enabled or interface8_enabled or interface9_enabled:
|
||||
multiple_interface = True
|
||||
|
||||
|
||||
# variables from the config.ini file
|
||||
try:
|
||||
storeFlimit = config['general'].getint('StoreLimit', 3) # default 3 messages for S&F
|
||||
# general
|
||||
useDMForResponse = config['general'].getboolean('respond_by_dm_only', True)
|
||||
publicChannel = config['general'].getint('defaultChannel', 0) # the meshtastic public channel
|
||||
location_enabled = config['location'].getboolean('enabled', False)
|
||||
ignoreChannels = config['general'].get('ignoreChannels', '').split(',') # ignore these channels
|
||||
ignoreDefaultChannel = config['general'].getboolean('ignoreDefaultChannel', False)
|
||||
cmdBang = config['general'].getboolean('cmdBang', False) # default off
|
||||
explicitCmd = config['general'].getboolean('explicitCmd', True) # default on
|
||||
zuluTime = config['general'].getboolean('zuluTime', False) # aka 24 hour time
|
||||
log_messages_to_file = config['general'].getboolean('LogMessagesToFile', False) # default off
|
||||
log_backup_count = config['general'].getint('LogBackupCount', 32) # default 32 days
|
||||
syslog_to_file = config['general'].getboolean('SyslogToFile', True) # default on
|
||||
LOGGING_LEVEL = config['general'].get('sysloglevel', 'DEBUG') # default DEBUG
|
||||
urlTimeoutSeconds = config['general'].getint('urlTimeout', 15) # default 15 seconds for URL fetch timeout
|
||||
store_forward_enabled = config['general'].getboolean('StoreForward', True)
|
||||
storeFlimit = config['general'].getint('StoreLimit', 3) # default 3 messages for S&F
|
||||
reverseSF = config['general'].getboolean('reverseSF', False) # default False, send oldest first
|
||||
welcome_message = config['general'].get('welcome_message', WELCOME_MSG)
|
||||
welcome_message = (f"{welcome_message}").replace('\\n', '\n') # allow for newlines in the welcome message
|
||||
motd_enabled = config['general'].getboolean('motdEnabled', True)
|
||||
MOTD = config['general'].get('motd', MOTD)
|
||||
autoPingInChannel = config['general'].getboolean('autoPingInChannel', False)
|
||||
enableCmdHistory = config['general'].getboolean('enableCmdHistory', True)
|
||||
lheardCmdIgnoreNode = config['general'].get('lheardCmdIgnoreNode', '').split(',')
|
||||
whoami_enabled = config['general'].getboolean('whoami', True)
|
||||
dad_jokes_enabled = config['general'].getboolean('DadJokes', False)
|
||||
dad_jokes_emojiJokes = config['general'].getboolean('DadJokesEmoji', False)
|
||||
bee_enabled = config['general'].getboolean('bee', False) # 🐝 off by default undocumented
|
||||
bible_enabled = config['general'].getboolean('verse', False) # verse command
|
||||
solar_conditions_enabled = config['general'].getboolean('spaceWeather', True)
|
||||
wikipedia_enabled = config['general'].getboolean('wikipedia', False)
|
||||
use_kiwix_server = config['general'].getboolean('useKiwixServer', False)
|
||||
kiwix_url = config['general'].get('kiwixURL', 'http://127.0.0.1:8080')
|
||||
kiwix_library_name = config['general'].get('kiwixLibraryName', 'wikipedia_en_100_nopic_2024-06')
|
||||
llm_enabled = config['general'].getboolean('ollama', False) # https://ollama.com
|
||||
ollamaHostName = config['general'].get('ollamaHostName', 'http://localhost:11434') # default localhost
|
||||
llmModel = config['general'].get('ollamaModel', 'gemma3:270m') # default gemma3:270m
|
||||
rawLLMQuery = config['general'].getboolean('rawLLMQuery', True) #default True
|
||||
llmReplyToNonCommands = config['general'].getboolean('llmReplyToNonCommands', True) # default True
|
||||
llmUseWikiContext = config['general'].getboolean('llmUseWikiContext', False) # default False
|
||||
useOpenWebUI = config['general'].getboolean('useOpenWebUI', False) # default False
|
||||
openWebUIURL = config['general'].get('openWebUIURL', 'http://localhost:3000') # default localhost:3000
|
||||
openWebUIAPIKey = config['general'].get('openWebUIAPIKey', '') # default empty
|
||||
dont_retry_disconnect = config['general'].getboolean('dont_retry_disconnect', False) # default False, retry on disconnect
|
||||
favoriteNodeList = config['general'].get('favoriteNodeList', '').split(',')
|
||||
enableEcho = config['general'].getboolean('enableEcho', False) # default False
|
||||
echoChannel = config['general'].getint('echoChannel', '9') # default 9, empty string to ignore
|
||||
rssEnable = config['general'].getboolean('rssEnable', True) # default True
|
||||
rssFeedURL = config['general'].get('rssFeedURL', 'http://www.hackaday.com/rss.xml,https://www.arrl.org/rss/arrl.rss').split(',')
|
||||
rssMaxItems = config['general'].getint('rssMaxItems', 3) # default 3 items
|
||||
rssTruncate = config['general'].getint('rssTruncate', 100) # default 100 characters
|
||||
rssFeedNames = config['general'].get('rssFeedNames', 'default,arrl').split(',')
|
||||
newsAPI_KEY = config['general'].get('newsAPI_KEY', '') # default empty
|
||||
newsAPIregion = config['general'].get('newsAPIregion', 'us') # default us
|
||||
enable_headlines = config['general'].getboolean('enableNewsAPI', False) # default False
|
||||
newsAPIsort = config['general'].get('sort_by', 'relevancy') # default publishedAt
|
||||
|
||||
# sentry
|
||||
sentry_enabled = config['sentry'].getboolean('SentryEnabled', False) # default False
|
||||
secure_channel = config['sentry'].getint('SentryChannel', 2) # default 2
|
||||
secure_interface = config['sentry'].getint('SentryInterface', 1) # default 1
|
||||
sentry_holdoff = config['sentry'].getint('SentryHoldoff', 9) # default 9
|
||||
sentryIgnoreList = config['sentry'].get('sentryIgnoreList', '').split(',')
|
||||
sentryWatchList = config['sentry'].get('sentryWatchList', '').split(',')
|
||||
sentry_radius = config['sentry'].getint('SentryRadius', 100) # default 100 meters
|
||||
email_sentry_alerts = config['sentry'].getboolean('emailSentryAlerts', False) # default False
|
||||
highfly_enabled = config['sentry'].getboolean('highFlyingAlert', True) # default True
|
||||
highfly_altitude = config['sentry'].getint('highFlyingAlertAltitude', 2000) # default 2000 meters
|
||||
highfly_channel = config['sentry'].getint('highFlyingAlertChannel', 2) # default 2
|
||||
highfly_interface = config['sentry'].getint('highFlyingAlertInterface', 1) # default 1
|
||||
highfly_ignoreList = config['sentry'].get('highFlyingIgnoreList', '').split(',') # default empty
|
||||
highfly_check_openskynetwork = config['sentry'].getboolean('highflyOpenskynetwork', True) # default True check with OpenSkyNetwork if highfly detected
|
||||
detctionSensorAlert = config['sentry'].getboolean('detectionSensorAlert', False) # default False
|
||||
reqLocationEnabled = config['sentry'].getboolean('reqLocationEnabled', False) # default False
|
||||
cmdShellSentryAlerts = config['sentry'].getboolean('cmdShellSentryAlerts', False) # default False
|
||||
sentryAlertNear = config['sentry'].get('sentryAlertNear', 'sentry_alert_near.sh') # default sentry_alert_near.sh
|
||||
sentryAlertFar = config['sentry'].get('sentryAlertFar', 'sentry_alert_far.sh') # default sentry_alert_far.sh
|
||||
|
||||
# location
|
||||
location_enabled = config['location'].getboolean('enabled', True)
|
||||
latitudeValue = config['location'].getfloat('lat', 48.50)
|
||||
longitudeValue = config['location'].getfloat('lon', -123.0)
|
||||
zuluTime = config['general'].getboolean('zuluTime', False)
|
||||
welcome_message = config['general'].get(f'welcome_message', WELCOME_MSG)
|
||||
welcome_message = (f"{welcome_message}").replace('\\n', '\n') # allow for newlines in the welcome message
|
||||
solar_conditions_enabled = config['solar'].getboolean('enabled', False)
|
||||
fuzz_config_location = config['location'].getboolean('fuzzConfigLocation', True) # default True
|
||||
fuzzItAll = config['location'].getboolean('fuzzAllLocations', False) # default False, only fuzz config location
|
||||
use_meteo_wxApi = config['location'].getboolean('UseMeteoWxAPI', False) # default False use NOAA
|
||||
use_metric = config['location'].getboolean('useMetric', False) # default Imperial units
|
||||
repeater_lookup = config['location'].get('repeaterLookup', 'rbook') # default repeater lookup source
|
||||
n2yoAPIKey = config['location'].get('n2yoAPIKey', '') # default empty
|
||||
satListConfig = config['location'].get('satList', '25544').split(',') # default 25544 ISS
|
||||
riverListDefault = config['location'].get('riverList', '').split(',') # default None
|
||||
coastalEnabled = config['location'].getboolean('coastalEnabled', False) # default False
|
||||
myCoastalZone = config['location'].get('myCoastalZone', None) # default None
|
||||
coastalForecastDays = config['location'].getint('coastalForecastDays', 3) # default 3 days
|
||||
|
||||
# location alerts
|
||||
alert_duration = config['location'].getint('alertDuration', 20) # default 20 minutes
|
||||
if alert_duration < 10: # the API calls need throttle time
|
||||
alert_duration = 10
|
||||
eAlertBroadcastEnabled = config['location'].getboolean('eAlertBroadcastEnabled', False) # old deprecated name
|
||||
ipawsAlertEnabled = config['location'].getboolean('ipawsAlertEnabled', False) # default False new ^
|
||||
# Keep both in sync for backward compatibility
|
||||
if eAlertBroadcastEnabled or ipawsAlertEnabled:
|
||||
eAlertBroadcastEnabled = True
|
||||
ipawsAlertEnabled = True
|
||||
wxAlertBroadcastEnabled = config['location'].getboolean('wxAlertBroadcastEnabled', False) # default False
|
||||
volcanoAlertBroadcastEnabled = config['location'].getboolean('volcanoAlertBroadcastEnabled', False) # default False
|
||||
enableGBalerts = config['location'].getboolean('enableGBalerts', False) # default False
|
||||
enableDEalerts = config['location'].getboolean('enableDEalerts', False) # default False
|
||||
|
||||
ignoreEASenable = config['location'].getboolean('ignoreEASenable', False) # default False
|
||||
ignoreEASwords = config['location'].get('ignoreEASwords', 'test,advisory').split(',') # default test,advisory
|
||||
ignoreFEMAenable = config['location'].getboolean('ignoreFEMAenable', True) # default True
|
||||
ignoreFEMAwords = config['location'].get('ignoreFEMAwords', 'test,exercise').split(',') # default test,exercise
|
||||
ignoreUSGSEnable = config['location'].getboolean('ignoreVolcanoEnable', False) # default False
|
||||
ignoreUSGSWords = config['location'].get('ignoreVolcanoWords', 'test,advisory').split(',') # default test,advisory
|
||||
|
||||
forecastDuration = config['location'].getint('NOAAforecastDuration', 4) # NOAA forcast days
|
||||
numWxAlerts = config['location'].getint('NOAAalertCount', 2) # default 2 alerts
|
||||
enableExtraLocationWx = config['location'].getboolean('enableExtraLocationWx', False) # default False
|
||||
myStateFIPSList = config['location'].get('myFIPSList', '').split(',') # default empty
|
||||
mySAMEList = config['location'].get('mySAMEList', '').split(',') # default empty
|
||||
myRegionalKeysDE = config['location'].get('myRegionalKeysDE', '110000000000').split(',') # default city Berlin
|
||||
eAlertBroadcastChannel = config['location'].get('eAlertBroadcastCh', '').split(',') # default empty
|
||||
|
||||
# any US alerts enabled
|
||||
usAlerts = (
|
||||
ipawsAlertEnabled or
|
||||
wxAlertBroadcastEnabled or
|
||||
volcanoAlertBroadcastEnabled or
|
||||
eAlertBroadcastEnabled
|
||||
)
|
||||
|
||||
# emergency response
|
||||
emergency_responder_enabled = config['emergencyHandler'].getboolean('enabled', False)
|
||||
emergency_responder_alert_channel = config['emergencyHandler'].getint('alert_channel', 2) # default 2
|
||||
emergency_responder_alert_interface = config['emergencyHandler'].getint('alert_interface', 1) # default 1
|
||||
emergency_responder_email = config['emergencyHandler'].get('email', '').split(',')
|
||||
|
||||
|
||||
# bbs
|
||||
bbs_enabled = config['bbs'].getboolean('enabled', False)
|
||||
bbsdb = config['bbs'].get('bbsdb', 'bbsdb.pkl')
|
||||
dad_jokes_enabled = config['general'].getboolean('DadJokes', False)
|
||||
store_forward_enabled = config['general'].getboolean('StoreForward', False)
|
||||
log_messages_to_file = config['general'].getboolean('LogMessagesToFile', True) # default True
|
||||
config['general'].get('motd', MOTD)
|
||||
urlTimeoutSeconds = config['general'].getint('URL_TIMEOUT', 10) # default 10 seconds
|
||||
forecastDuration = config['general'].getint('DAYS_OF_WEATHER', 4) # default days of weather
|
||||
numWxAlerts = config['general'].getint('ALERT_COUNT', 2) # default 2 alerts
|
||||
bbsdb = config['bbs'].get('bbsdb', 'data/bbsdb.pkl')
|
||||
bbs_ban_list = config['bbs'].get('bbs_ban_list', '').split(',')
|
||||
bbs_admin_list = config['bbs'].get('bbs_admin_list', '').split(',')
|
||||
bbs_link_enabled = config['bbs'].getboolean('bbslink_enabled', False)
|
||||
bbs_link_whitelist = config['bbs'].get('bbslink_whitelist', '').split(',')
|
||||
bbsAPI_enabled = config['bbs'].getboolean('bbsAPI_enabled', False)
|
||||
|
||||
# checklist
|
||||
checklist_enabled = config['checklist'].getboolean('enabled', False)
|
||||
checklist_db = config['checklist'].get('checklist_db', 'data/checklist.db')
|
||||
reverse_in_out = config['checklist'].getboolean('reverse_in_out', False)
|
||||
checklist_auto_approve = config['checklist'].getboolean('auto_approve', True) # default True
|
||||
|
||||
# qrz hello
|
||||
qrz_hello_enabled = config['qrz'].getboolean('enabled', False)
|
||||
qrz_db = config['qrz'].get('qrz_db', 'data/qrz.db')
|
||||
qrz_hello_string = config['qrz'].get('qrz_hello_string', 'MeshBot says Hello! DM for more info.')
|
||||
train_qrz = config['qrz'].getboolean('training', True)
|
||||
|
||||
# inventory and POS
|
||||
inventory_enabled = config['inventory'].getboolean('enabled', False)
|
||||
inventory_db = config['inventory'].get('inventory_db', 'data/inventory.db')
|
||||
disable_penny = config['inventory'].getboolean('disable_penny', False)
|
||||
|
||||
# location mapping
|
||||
locations_db = config['location'].get('locations_db', 'data/locations.db')
|
||||
public_location_admin_manage = config['location'].getboolean('public_location_admin_manage', False)
|
||||
delete_public_locations_admins_only = config['location'].getboolean('delete_public_locations_admins_only', False)
|
||||
|
||||
# E-Mail Settings
|
||||
sysopEmails = config['smtp'].get('sysopEmails', '').split(',')
|
||||
enableSMTP = config['smtp'].getboolean('enableSMTP', False)
|
||||
enableImap = config['smtp'].getboolean('enableImap', False)
|
||||
SMTP_SERVER = config['smtp'].get('SMTP_SERVER', 'smtp.gmail.com')
|
||||
SMTP_PORT = config['smtp'].getint('SMTP_PORT', 587)
|
||||
FROM_EMAIL = config['smtp'].get('FROM_EMAIL', 'none@gmail.com')
|
||||
SMTP_AUTH = config['smtp'].getboolean('SMTP_AUTH', True)
|
||||
SMTP_USERNAME = config['smtp'].get('SMTP_USERNAME', FROM_EMAIL)
|
||||
SMTP_PASSWORD = config['smtp'].get('SMTP_PASSWORD', 'password')
|
||||
EMAIL_SUBJECT = config['smtp'].get('EMAIL_SUBJECT', 'Meshtastic✉️')
|
||||
IMAP_SERVER = config['smtp'].get('IMAP_SERVER', 'imap.gmail.com')
|
||||
IMAP_PORT = config['smtp'].getint('IMAP_PORT', 993)
|
||||
IMAP_USERNAME = config['smtp'].get('IMAP_USERNAME', SMTP_USERNAME)
|
||||
IMAP_PASSWORD = config['smtp'].get('IMAP_PASSWORD', SMTP_PASSWORD)
|
||||
IMAP_FOLDER = config['smtp'].get('IMAP_FOLDER', 'inbox')
|
||||
|
||||
# repeater
|
||||
repeater_enabled = config['repeater'].getboolean('enabled', False)
|
||||
repeater_channels = config['repeater'].get('repeater_channels', '').split(',')
|
||||
radio_dectection_enabled = config['radioMon'].getboolean('enabled', False)
|
||||
|
||||
# scheduler
|
||||
scheduler_enabled = config['scheduler'].getboolean('enabled', False)
|
||||
schedulerInterface = config['scheduler'].getint('interface', 1) # default interface 1
|
||||
schedulerChannel = config['scheduler'].getint('channel', 2) # default channel 2
|
||||
schedulerMessage = config['scheduler'].get('message', 'Scheduled message') # default message
|
||||
schedulerInterval = config['scheduler'].get('interval', '') # default empty
|
||||
schedulerTime = config['scheduler'].get('time', '') # default empty
|
||||
schedulerValue = config['scheduler'].get('value', '') # default empty
|
||||
schedulerMotd = config['scheduler'].getboolean('schedulerMotd', False) # default False
|
||||
|
||||
# radio monitoring
|
||||
radio_detection_enabled = config['radioMon'].getboolean('enabled', False)
|
||||
dxspotter_enabled = config['radioMon'].getboolean('dxspotter_enabled', True) # default True
|
||||
rigControlServerAddress = config['radioMon'].get('rigControlServerAddress', 'localhost:4532') # default localhost:4532
|
||||
sigWatchBrodcastCh = config['radioMon'].get('sigWatchBrodcastCh', '2').split(',') # default Channel 2
|
||||
sigWatchBroadcastCh = config['radioMon'].get('sigWatchBroadcastCh', '2').split(',') # default Channel 2
|
||||
sigWatchBroadcastInterface = config['radioMon'].getint('sigWatchBroadcastInterface', 1) # default interface 1
|
||||
signalDetectionThreshold = config['radioMon'].getint('signalDetectionThreshold', -10) # default -10 dBm
|
||||
signalHoldTime = config['radioMon'].getint('signalHoldTime', 10) # default 10 seconds
|
||||
signalCooldown = config['radioMon'].getint('signalCooldown', 5) # default 1 second
|
||||
signalCycleLimit = config['radioMon'].getint('signalCycleLimit', 5) # default 5 cycles, used with SIGNAL_COOLDOWN
|
||||
except KeyError as e:
|
||||
voxDetectionEnabled = config['radioMon'].getboolean('voxDetectionEnabled', False) # default VOX detection disabled
|
||||
voxDescription = config['radioMon'].get('voxDescription', 'VOX') # default VOX detected audio message
|
||||
useLocalVoxModel = config['radioMon'].getboolean('useLocalVoxModel', False) # default False
|
||||
localVoxModelPath = config['radioMon'].get('localVoxModelPath', 'no') # default models/vox.tflite
|
||||
voxLanguage = config['radioMon'].get('voxLanguage', 'en-US') # default en-US
|
||||
voxInputDevice = config['radioMon'].get('voxInputDevice', 'default') # default default
|
||||
voxOnTrapList = config['radioMon'].getboolean('voxOnTrapList', False) # default False
|
||||
voxTrapList = config['radioMon'].get('voxTrapList', 'chirpy').split(',') # default chirpy
|
||||
voxEnableCmd = config['radioMon'].getboolean('voxEnableCmd', True) # default True
|
||||
meshagesTTS = config['radioMon'].getboolean('meshagesTTS', False) # default False
|
||||
ttsChannels = config['radioMon'].get('ttsChannels', '2').split(',') # default Channel 2
|
||||
ttsnoWelcome = config['radioMon'].getboolean('ttsnoWelcome', False) # default False
|
||||
|
||||
# WSJT-X and JS8Call monitoring
|
||||
wsjtx_detection_enabled = config['radioMon'].getboolean('wsjtxDetectionEnabled', False) # default WSJT-X detection disabled
|
||||
wsjtx_udp_server_address = config['radioMon'].get('wsjtxUdpServerAddress', '127.0.0.1:2237') # default localhost:2237
|
||||
wsjtx_watched_callsigns = config['radioMon'].get('wsjtxWatchedCallsigns', '') # default empty (all callsigns)
|
||||
js8call_detection_enabled = config['radioMon'].getboolean('js8callDetectionEnabled', False) # default JS8Call detection disabled
|
||||
js8call_server_address = config['radioMon'].get('js8callServerAddress', '127.0.0.1:2442') # default localhost:2442
|
||||
js8call_watched_callsigns = config['radioMon'].get('js8callWatchedCallsigns', '') # default empty (all callsigns)
|
||||
|
||||
# file monitor
|
||||
file_monitor_enabled = config['fileMon'].getboolean('filemon_enabled', False)
|
||||
file_monitor_file_path = config['fileMon'].get('file_path', 'alert.txt') # default alert.txt
|
||||
file_monitor_broadcastCh = config['fileMon'].get('broadcastCh', '2').split(',') # default Channel 2
|
||||
read_news_enabled = config['fileMon'].getboolean('enable_read_news', False) # default disabled
|
||||
news_file_path = config['fileMon'].get('news_file_path', '../data/news.txt') # default ../data/news.txt
|
||||
news_random_line_only = config['fileMon'].getboolean('news_random_line', False) # default False
|
||||
news_block_mode = config['fileMon'].getboolean('news_block_mode', False) # default False
|
||||
if news_random_line_only and news_block_mode:
|
||||
news_random_line_only = False
|
||||
enable_runShellCmd = config['fileMon'].getboolean('enable_runShellCmd', False) # default False
|
||||
allowXcmd = config['fileMon'].getboolean('allowXcmd', False) # default False
|
||||
xCmd2factorEnabled = config['fileMon'].getboolean('twoFactor_enabled', True) # default True
|
||||
xCmd2factor_timeout = config['fileMon'].getint('twoFactor_timeout', 100) # default 100 seconds
|
||||
|
||||
# games
|
||||
game_hop_limit = config['games'].getint('game_hop_limit', 5) # default 5 hops
|
||||
disable_emojis_in_games = config['games'].getboolean('disable_emojis', False) # default False
|
||||
dopewars_enabled = config['games'].getboolean('dopeWars', True)
|
||||
lemonade_enabled = config['games'].getboolean('lemonade', True)
|
||||
blackjack_enabled = config['games'].getboolean('blackjack', True)
|
||||
videoPoker_enabled = config['games'].getboolean('videoPoker', True)
|
||||
mastermind_enabled = config['games'].getboolean('mastermind', True)
|
||||
golfSim_enabled = config['games'].getboolean('golfSim', True)
|
||||
hangman_enabled = config['games'].getboolean('hangman', True)
|
||||
hamtest_enabled = config['games'].getboolean('hamtest', True)
|
||||
tictactoe_enabled = config['games'].getboolean('tictactoe', True)
|
||||
quiz_enabled = config['games'].getboolean('quiz', False)
|
||||
survey_enabled = config['games'].getboolean('survey', False)
|
||||
default_survey = config['games'].get('defaultSurvey', 'example') # default example
|
||||
surveyRecordID = config['games'].getboolean('surveyRecordID', True)
|
||||
surveyRecordLocation = config['games'].getboolean('surveyRecordLocation', True)
|
||||
wordOfTheDay = config['games'].getboolean('wordOfTheDay', True)
|
||||
battleship_enabled = config['games'].getboolean('battleShip', True)
|
||||
|
||||
# messaging settings
|
||||
responseDelay = config['messagingSettings'].getfloat('responseDelay', 0.7) # default 0.7
|
||||
splitDelay = config['messagingSettings'].getfloat('splitDelay', 0) # default 0
|
||||
MESSAGE_CHUNK_SIZE = config['messagingSettings'].getint('MESSAGE_CHUNK_SIZE', 160) # default 160 chars
|
||||
wantAck = config['messagingSettings'].getboolean('wantAck', False) # default False
|
||||
maxBuffer = config['messagingSettings'].getint('maxBuffer', 200) # default 200 bytes
|
||||
enableHopLogs = config['messagingSettings'].getboolean('enableHopLogs', False) # default False
|
||||
debugMetadata = config['messagingSettings'].getboolean('debugMetadata', False) # default False
|
||||
metadataFilter = config['messagingSettings'].get('metadataFilter', '').split(',') # default empty
|
||||
DEBUGpacket = config['messagingSettings'].getboolean('DEBUGpacket', False) # default False
|
||||
noisyNodeLogging = config['messagingSettings'].getboolean('noisyNodeLogging', False) # default False
|
||||
logMetaStats = config['messagingSettings'].getboolean('logMetaStats', True) # default True
|
||||
noisyTelemetryLimit = config['messagingSettings'].getint('noisyTelemetryLimit', 5) # default 5 packets
|
||||
autoBanEnabled = config['messagingSettings'].getboolean('autoBanEnabled', False) # default False
|
||||
autoBanThreshold = config['messagingSettings'].getint('autoBanThreshold', 5) # default 5 offenses
|
||||
autoBanTimeframe = config['messagingSettings'].getint('autoBanTimeframe', 3600) # default 1 hour in seconds
|
||||
apiThrottleValue = config['messagingSettings'].getint('apiThrottleValue', 20) # default 20 requests
|
||||
|
||||
# data persistence settings
|
||||
dataPersistence_enabled = config.getboolean('dataPersistence', 'enabled', fallback=True) # default True
|
||||
dataPersistence_interval = config.getint('dataPersistence', 'interval', fallback=300) # default 300 seconds (5 minutes)
|
||||
except Exception as e:
|
||||
print(f"System: Error reading config file: {e}")
|
||||
print(f"System: Check the config.ini against config.template file for missing sections or values.")
|
||||
print(f"System: Exiting...")
|
||||
print("System: Check the config.ini against config.template file for missing sections or values.")
|
||||
print("System: Exiting...")
|
||||
exit(1)
|
||||
|
||||
277
modules/smtp.py
Normal file
277
modules/smtp.py
Normal file
@@ -0,0 +1,277 @@
|
||||
# SMTP module for the meshing-around bot
|
||||
# 2024 Idea and code bits from https://github.com/tremmert81
|
||||
# https://avtech.com/articles/138/list-of-email-to-sms-addresses/
|
||||
# 2024 Kelly Keeton K7MHI
|
||||
|
||||
from modules.log import logger
|
||||
from modules.settings import (
|
||||
SMTP_SERVER, SMTP_PORT, SMTP_AUTH, SMTP_USERNAME, SMTP_PASSWORD,
|
||||
FROM_EMAIL, EMAIL_SUBJECT, enableImap, IMAP_SERVER, IMAP_PORT,
|
||||
IMAP_USERNAME, IMAP_PASSWORD, IMAP_FOLDER, sysopEmails, bbs_ban_list
|
||||
)
|
||||
import pickle
|
||||
import time
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
# System variables
|
||||
trap_list_smtp = ("email:", "setemail", "sms:", "setsms", "clearsms")
|
||||
smtpThrottle = {}
|
||||
SMTP_TIMEOUT = 10
|
||||
|
||||
if enableImap:
|
||||
# Import IMAP library
|
||||
import imaplib
|
||||
import email
|
||||
|
||||
# Send email
|
||||
def send_email(to_email, message, nodeID=0):
|
||||
global smtpThrottle
|
||||
|
||||
# Clean up email address
|
||||
to_email = to_email.strip()
|
||||
|
||||
# Basic email validation
|
||||
if "@" not in to_email or "." not in to_email:
|
||||
logger.warning(f"System: Invalid email address format: {to_email}")
|
||||
return False
|
||||
|
||||
# throttle email to prevent abuse
|
||||
if to_email in smtpThrottle:
|
||||
if smtpThrottle[to_email] > time.time() - 120:
|
||||
logger.warning("System: Email throttled for " + to_email[:-6])
|
||||
return "⛔️Email throttled, try again later"
|
||||
smtpThrottle[to_email] = time.time()
|
||||
|
||||
# check if email is in the ban list
|
||||
if nodeID in bbs_ban_list:
|
||||
logger.warning("System: Email blocked for " + str(nodeID))
|
||||
return "⛔️Email throttled, try again later"
|
||||
# Send email
|
||||
try:
|
||||
# Create message
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = FROM_EMAIL
|
||||
msg['To'] = to_email
|
||||
msg['Subject'] = EMAIL_SUBJECT
|
||||
msg.attach(MIMEText(message, 'plain'))
|
||||
|
||||
# Connect to SMTP server
|
||||
server = smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=SMTP_TIMEOUT)
|
||||
try:
|
||||
# login /auth
|
||||
if SMTP_PORT == 587:
|
||||
server.starttls()
|
||||
if SMTP_AUTH:
|
||||
server.login(SMTP_USERNAME, SMTP_PASSWORD)
|
||||
except Exception as e:
|
||||
logger.warning(f"System: Failed to login to SMTP server: {str(e)}")
|
||||
return
|
||||
|
||||
# Send email; this command will hold the program until the email is sent
|
||||
server.send_message(msg)
|
||||
server.quit()
|
||||
|
||||
logger.info("System: Email sent to: " + to_email[:-6])
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"System: Failed to send email: {str(e)}")
|
||||
return False
|
||||
|
||||
def check_email(nodeID, sysop=False):
|
||||
if not enableImap:
|
||||
return
|
||||
|
||||
try:
|
||||
# Connect to IMAP server
|
||||
mail = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT, timeout=SMTP_TIMEOUT)
|
||||
mail.login(IMAP_USERNAME, IMAP_PASSWORD)
|
||||
mail.select(IMAP_FOLDER)
|
||||
|
||||
# Search for new emails
|
||||
status, data = mail.search(None, 'UNSEEN')
|
||||
if status == 'OK':
|
||||
for num in data[0].split():
|
||||
status, data = mail.fetch(num, '(RFC822)')
|
||||
if status == 'OK':
|
||||
email_message = email.message_from_bytes(data[0][1])
|
||||
email_from = email_message['from']
|
||||
email_subject = email_message['subject']
|
||||
email_body = ""
|
||||
|
||||
if not sysop:
|
||||
# Check if email is whitelisted by particpant in the mesh
|
||||
for address in sms_db[nodeID]:
|
||||
if address in email_from:
|
||||
email_body = email_message.get_payload()
|
||||
logger.info("System: Email received from: " + email_from[:-6] + " for " + str(nodeID))
|
||||
return email_body.strip()
|
||||
else:
|
||||
# Check if email is from sysop
|
||||
for address in sysopEmails:
|
||||
if address in email_from:
|
||||
email_body = email_message.get_payload()
|
||||
logger.info("System: SysOp Email received from: " + email_from[:-6] + " for sysop")
|
||||
return email_body.strip()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("System: Failed to check email: " + str(e))
|
||||
return False
|
||||
|
||||
# initalize email db
|
||||
email_db = {}
|
||||
try:
|
||||
with open('data/email_db.pickle', 'rb') as f:
|
||||
email_db = pickle.load(f)
|
||||
except:
|
||||
logger.warning("System: Email db not found, creating a new one")
|
||||
with open('data/email_db.pickle', 'wb') as f:
|
||||
pickle.dump(email_db, f)
|
||||
|
||||
def store_email(nodeID, email):
|
||||
global email_db
|
||||
|
||||
# if not in db, add it
|
||||
logger.debug("System: Setting E-Mail for " + str(nodeID))
|
||||
email_db[nodeID] = email
|
||||
|
||||
# save to a pickle for persistence, this is a simple db, be mindful of risk
|
||||
with open('data/email_db.pickle', 'wb') as f:
|
||||
pickle.dump(email_db, f)
|
||||
f.close()
|
||||
return True
|
||||
|
||||
|
||||
# initalize SMS db
|
||||
sms_db = [{'nodeID': 0, 'sms':[]}]
|
||||
try:
|
||||
with open('data/sms_db.pickle', 'rb') as f:
|
||||
sms_db = pickle.load(f)
|
||||
except:
|
||||
logger.warning("System: SMS db not found, creating a new one")
|
||||
with open('data/sms_db.pickle', 'wb') as f:
|
||||
pickle.dump(sms_db, f)
|
||||
|
||||
def store_sms(nodeID, sms):
|
||||
global sms_db
|
||||
try:
|
||||
logger.debug("System: Setting SMS for " + str(nodeID))
|
||||
# if the nodeID has over 5 sms addresses warn and return
|
||||
for item in sms_db:
|
||||
if item['nodeID'] == nodeID:
|
||||
if len(item['sms']) >= 5:
|
||||
logger.warning("System: 📵SMS limit reached for " + str(nodeID))
|
||||
return False
|
||||
# if not in db, add it
|
||||
if nodeID not in sms_db:
|
||||
sms_db.append({'nodeID': nodeID, 'sms': sms})
|
||||
else:
|
||||
# if in db, update it
|
||||
for item in sms_db:
|
||||
if item['nodeID'] == nodeID:
|
||||
item['sms'].append(sms)
|
||||
|
||||
# save to a pickle for persistence, this is a simple db, be mindful of risk
|
||||
with open('data/sms_db.pickle', 'wb') as f:
|
||||
pickle.dump(sms_db, f)
|
||||
f.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("System: Failed to store SMS: " + str(e))
|
||||
return False
|
||||
|
||||
def handle_sms(nodeID, message):
|
||||
global sms_db
|
||||
# if clearsms, remove all sms for node
|
||||
if message.lower().startswith("clearsms"):
|
||||
if any(item['nodeID'] == nodeID for item in sms_db):
|
||||
# remove record from db for nodeID
|
||||
sms_db = [item for item in sms_db if item['nodeID'] != nodeID]
|
||||
# update the pickle
|
||||
with open('data/sms_db.pickle', 'wb') as f:
|
||||
pickle.dump(sms_db, f)
|
||||
f.close()
|
||||
return "📲 address cleared"
|
||||
return "📲No address to clear"
|
||||
|
||||
# send SMS to SMS in db. if none ask for one
|
||||
if message.lower().startswith("setsms"):
|
||||
message = message.split(" ", 1)
|
||||
if len(message[1]) < 5:
|
||||
return "?📲setsms: example@phone.co"
|
||||
if "@" not in message[1] and "." not in message[1]:
|
||||
return "📲Please provide a valid email address"
|
||||
if store_sms(nodeID, message[1]):
|
||||
return "📲SMS address set 📪"
|
||||
else:
|
||||
return "⛔️Failed to set address"
|
||||
|
||||
if message.lower().startswith("sms:"):
|
||||
message = message.split(" ", 1)
|
||||
if any(item['nodeID'] == nodeID for item in sms_db):
|
||||
count = 0
|
||||
# for all dict items maching nodeID in sms_db send sms
|
||||
for item in sms_db:
|
||||
if item['nodeID'] == nodeID:
|
||||
smsEmail = item['sms']
|
||||
logger.info("System: Sending SMS for " + str(nodeID) + " to " + smsEmail[:-6])
|
||||
if send_email(smsEmail, message[1], nodeID):
|
||||
count += 1
|
||||
else:
|
||||
return "⛔️Failed to send SMS"
|
||||
return "📲SMS sent " + str(count) + " addresses 📤"
|
||||
else:
|
||||
return "📲No address set, use 📲setsms"
|
||||
|
||||
return "Error: ⛔️ not understood. use:setsms example@phone.co"
|
||||
|
||||
def handle_email(nodeID, message):
|
||||
global email_db
|
||||
try:
|
||||
# send email to email in db. if none ask for one
|
||||
if message.lower().startswith("setemail"):
|
||||
message = message.split(" ", 1)
|
||||
if len(message) < 2:
|
||||
return "📧Please provide an email address"
|
||||
email_addr = message[1].strip()
|
||||
if "@" not in email_addr or "." not in email_addr:
|
||||
return "📧Please provide a valid email address"
|
||||
if store_email(nodeID, email_addr):
|
||||
return "📧Email address set 📪"
|
||||
return "Error: ⛔️ Failed to set email address"
|
||||
|
||||
if message.lower().startswith("email:"):
|
||||
parts = message.split(" ", 1)
|
||||
if len(parts) < 2:
|
||||
return "Error: ⛔️ format should be: email: message or, email: address@example.com #message"
|
||||
|
||||
content = parts[1].strip()
|
||||
|
||||
# Check if this is a direct email with address
|
||||
if "@" in content and "#" in content:
|
||||
# Split into email and message
|
||||
addr_msg = content.split("#", 1)
|
||||
if len(addr_msg) != 2:
|
||||
return "Error: ⛔️ Message format should be: email: address@example.com #message"
|
||||
|
||||
to_email = addr_msg[0].strip()
|
||||
message_body = addr_msg[1].strip()
|
||||
|
||||
logger.info(f"System: Sending email for {nodeID} to {to_email}")
|
||||
if send_email(to_email, message_body, nodeID):
|
||||
return "📧Email-sent 📤"
|
||||
return "⛔️Failed to send email"
|
||||
|
||||
# Using stored email address
|
||||
elif nodeID in email_db:
|
||||
logger.info(f"System: Sending email for {nodeID} to stored address")
|
||||
if send_email(email_db[nodeID], content, nodeID):
|
||||
return "📧Email-sent 📤"
|
||||
return "⛔️Failed to send email"
|
||||
|
||||
return "Error: ⛔️ no email on file. use: setemail"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"System: Email handling error: {str(e)}")
|
||||
return "⛔️Failed to process email command"
|
||||
@@ -1,138 +0,0 @@
|
||||
# helper functions to get HF band conditions, DRAP X-ray flux, and sunrise/sunset times
|
||||
# some code from https://github.com/Murturtle/MeshLink
|
||||
# K7MHI Kelly Keeton 2024
|
||||
|
||||
import requests # pip install requests
|
||||
import xml.dom.minidom
|
||||
from datetime import datetime
|
||||
import ephem # pip install pyephem
|
||||
from datetime import timedelta
|
||||
from modules.settings import *
|
||||
|
||||
trap_list_solarconditions = ("sun", "solar", "hfcond")
|
||||
|
||||
def hf_band_conditions():
|
||||
# ham radio HF band conditions
|
||||
hf_cond = ""
|
||||
band_cond = requests.get("https://www.hamqsl.com/solarxml.php", timeout=urlTimeoutSeconds)
|
||||
if(band_cond.ok):
|
||||
solarxml = xml.dom.minidom.parseString(band_cond.text)
|
||||
for i in solarxml.getElementsByTagName("band"):
|
||||
hf_cond += i.getAttribute("time")[0]+i.getAttribute("name") +"="+str(i.childNodes[0].data)+"\n"
|
||||
else:
|
||||
hf_cond += ERROR_FETCHING_DATA
|
||||
hf_cond = hf_cond[:-1] # remove the last newline
|
||||
return hf_cond
|
||||
|
||||
def solar_conditions():
|
||||
# radio related solar conditions from hamsql.com
|
||||
solar_cond = ""
|
||||
solar_cond = requests.get("https://www.hamqsl.com/solarxml.php", timeout=urlTimeoutSeconds)
|
||||
if(solar_cond.ok):
|
||||
solar_xml = xml.dom.minidom.parseString(solar_cond.text)
|
||||
for i in solar_xml.getElementsByTagName("solardata"):
|
||||
solar_a_index = i.getElementsByTagName("aindex")[0].childNodes[0].data
|
||||
solar_k_index = i.getElementsByTagName("kindex")[0].childNodes[0].data
|
||||
solar_xray = i.getElementsByTagName("xray")[0].childNodes[0].data
|
||||
solar_flux = i.getElementsByTagName("solarflux")[0].childNodes[0].data
|
||||
sunspots = i.getElementsByTagName("sunspots")[0].childNodes[0].data
|
||||
signalnoise = i.getElementsByTagName("signalnoise")[0].childNodes[0].data
|
||||
solar_cond = "A-Index: " + solar_a_index + "\nK-Index: " + solar_k_index + "\nSunspots: " + sunspots + "\nX-Ray Flux: " + solar_xray + "\nSolar Flux: " + solar_flux + "\nSignal Noise: " + signalnoise
|
||||
else:
|
||||
solar_cond += ERROR_FETCHING_DATA
|
||||
return solar_cond
|
||||
|
||||
def drap_xray_conditions():
|
||||
# DRAP X-ray flux conditions, from NOAA direct
|
||||
drap_cond = ""
|
||||
drap_cond = requests.get("https://services.swpc.noaa.gov/text/drap_global_frequencies.txt", timeout=urlTimeoutSeconds)
|
||||
if(drap_cond.ok):
|
||||
drap_list = drap_cond.text.split('\n')
|
||||
x_filter = '# X-RAY Message :'
|
||||
for line in drap_list:
|
||||
if x_filter in line:
|
||||
xray_flux = line.split(": ")[1]
|
||||
else:
|
||||
xray_flux += ERROR_FETCHING_DATA
|
||||
return xray_flux
|
||||
|
||||
def get_sun(lat=0, lon=0):
|
||||
# get sunrise and sunset times using callers location or default
|
||||
obs = ephem.Observer()
|
||||
obs.date = datetime.now()
|
||||
sun = ephem.Sun()
|
||||
if lat != 0 and lon != 0:
|
||||
obs.lat = str(lat)
|
||||
obs.lon = str(lon)
|
||||
else:
|
||||
obs.lat = str(latitudeValue)
|
||||
obs.lon = str(longitudeValue)
|
||||
|
||||
sun.compute(obs)
|
||||
sun_table = {}
|
||||
sun_table['azimuth'] = sun.az
|
||||
sun_table['altitude'] = sun.alt
|
||||
|
||||
# get the next rise and set times
|
||||
local_sunrise = ephem.localtime(obs.next_rising(sun))
|
||||
local_sunset = ephem.localtime(obs.next_setting(sun))
|
||||
if zuluTime:
|
||||
sun_table['rise_time'] = local_sunrise.strftime('%a %d %H:%M')
|
||||
sun_table['set_time'] = local_sunset.strftime('%a %d %H:%M')
|
||||
else:
|
||||
sun_table['rise_time'] = local_sunrise.strftime('%a %d %I:%M%p')
|
||||
sun_table['set_time'] = local_sunset.strftime('%a %d %I:%M%p')
|
||||
# if sunset is before sunrise, then it's tomorrow
|
||||
if local_sunset < local_sunrise:
|
||||
local_sunset = ephem.localtime(obs.next_setting(sun)) + timedelta(1)
|
||||
if zuluTime:
|
||||
sun_table['set_time'] = local_sunset.strftime('%a %d %H:%M')
|
||||
else:
|
||||
sun_table['set_time'] = local_sunset.strftime('%a %d %I:%M%p')
|
||||
sun_data = "SunRise: " + sun_table['rise_time'] + "\nSet: " + sun_table['set_time']
|
||||
return sun_data
|
||||
|
||||
def get_moon(lat=0, lon=0):
|
||||
# get moon phase and rise/set times using callers location or default
|
||||
# the phase calculation mght not be accurate (followup later)
|
||||
obs = ephem.Observer()
|
||||
moon = ephem.Moon()
|
||||
if lat != 0 and lon != 0:
|
||||
obs.lat = str(lat)
|
||||
obs.lon = str(lon)
|
||||
else:
|
||||
obs.lat = str(latitudeValue)
|
||||
obs.lon = str(longitudeValue)
|
||||
|
||||
obs.date = datetime.now()
|
||||
moon.compute(obs)
|
||||
moon_table = {}
|
||||
moon_phase = ['NewMoon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', 'FullMoon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'][round(moon.phase / (2 * ephem.pi) * 8) % 8]
|
||||
moon_table['phase'] = moon_phase
|
||||
moon_table['illumination'] = moon.phase
|
||||
moon_table['azimuth'] = moon.az
|
||||
moon_table['altitude'] = moon.alt
|
||||
|
||||
local_moonrise = ephem.localtime(obs.next_rising(moon))
|
||||
local_moonset = ephem.localtime(obs.next_setting(moon))
|
||||
if zuluTime:
|
||||
moon_table['rise_time'] = local_moonrise.strftime('%a %d %H:%M')
|
||||
moon_table['set_time'] = local_moonset.strftime('%a %d %H:%M')
|
||||
else:
|
||||
moon_table['rise_time'] = local_moonrise.strftime('%a %d %I:%M%p')
|
||||
moon_table['set_time'] = local_moonset.strftime('%a %d %I:%M%p')
|
||||
|
||||
local_next_full_moon = ephem.localtime(ephem.next_full_moon((obs.date)))
|
||||
local_next_new_moon = ephem.localtime(ephem.next_new_moon((obs.date)))
|
||||
if zuluTime:
|
||||
moon_table['next_full_moon'] = local_next_full_moon.strftime('%a %b %d %H:%M')
|
||||
moon_table['next_new_moon'] = local_next_new_moon.strftime('%a %b %d %H:%M')
|
||||
else:
|
||||
moon_table['next_full_moon'] = local_next_full_moon.strftime('%a %b %d %I:%M%p')
|
||||
moon_table['next_new_moon'] = local_next_new_moon.strftime('%a %b %d %I:%M%p')
|
||||
|
||||
moon_data = "MoonRise:" + moon_table['rise_time'] + "\nSet:" + moon_table['set_time'] + \
|
||||
"\nPhase:" + moon_table['phase'] + " @:" + str('{0:.2f}'.format(moon_table['illumination'])) + "%" \
|
||||
+ "\nFullMoon:" + moon_table['next_full_moon'] + "\nNewMoon:" + moon_table['next_new_moon']
|
||||
|
||||
return moon_data
|
||||
334
modules/space.py
Normal file
334
modules/space.py
Normal file
@@ -0,0 +1,334 @@
|
||||
# helper functions to get HF band conditions, DRAP X-ray flux, and sunrise/sunset times
|
||||
# HF code from https://github.com/Murturtle/MeshLink
|
||||
# K7MHI Kelly Keeton 2024
|
||||
|
||||
import requests # pip install requests
|
||||
import xml.dom.minidom
|
||||
from datetime import datetime
|
||||
import ephem # pip install pyephem
|
||||
from datetime import timezone
|
||||
from modules.log import logger, getPrettyTime
|
||||
from modules.settings import (latitudeValue, longitudeValue, zuluTime,
|
||||
n2yoAPIKey, urlTimeoutSeconds, use_metric,
|
||||
ERROR_FETCHING_DATA, NO_DATA_NOGPS, NO_ALERTS)
|
||||
import math
|
||||
|
||||
trap_list_solarconditions = ("sun", "moon", "solar", "hfcond", "satpass", "howtall")
|
||||
|
||||
def hf_band_conditions():
|
||||
# ham radio HF band conditions
|
||||
hf_cond = ""
|
||||
signalnoise = ""
|
||||
band_cond = requests.get("https://www.hamqsl.com/solarxml.php", timeout=urlTimeoutSeconds)
|
||||
if(band_cond.ok):
|
||||
solarxml = xml.dom.minidom.parseString(band_cond.text)
|
||||
for i in solarxml.getElementsByTagName("band"):
|
||||
hf_cond += i.getAttribute("time")[0]+i.getAttribute("name") +"="+str(i.childNodes[0].data)+"\n"
|
||||
hf_cond = hf_cond[:-1] # remove the last newline
|
||||
for i in solarxml.getElementsByTagName("solardata"):
|
||||
signalnoise = i.getElementsByTagName("signalnoise")[0].childNodes[0].data
|
||||
hf_cond += "\nQRN:" + signalnoise
|
||||
else:
|
||||
logger.error("Solar: Error fetching HF band conditions")
|
||||
hf_cond = ERROR_FETCHING_DATA
|
||||
|
||||
return hf_cond
|
||||
|
||||
def solar_conditions():
|
||||
# radio related solar conditions from hamsql.com
|
||||
solar_cond = ""
|
||||
try:
|
||||
solar_cond = requests.get("https://www.hamqsl.com/solarxml.php", timeout=urlTimeoutSeconds)
|
||||
if solar_cond.ok:
|
||||
try:
|
||||
solar_xml = xml.dom.minidom.parseString(solar_cond.text)
|
||||
except Exception as e:
|
||||
logger.error(f"Solar: XML parse error: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
for i in solar_xml.getElementsByTagName("solardata"):
|
||||
solar_a_index = i.getElementsByTagName("aindex")[0].childNodes[0].data
|
||||
solar_k_index = i.getElementsByTagName("kindex")[0].childNodes[0].data
|
||||
solar_xray = i.getElementsByTagName("xray")[0].childNodes[0].data
|
||||
solar_flux = i.getElementsByTagName("solarflux")[0].childNodes[0].data
|
||||
sunspots = i.getElementsByTagName("sunspots")[0].childNodes[0].data
|
||||
signalnoise = i.getElementsByTagName("signalnoise")[0].childNodes[0].data
|
||||
solar_cond = "A-Index: " + solar_a_index + "\nK-Index: " + solar_k_index + "\nSunspots: " + sunspots + "\nX-Ray Flux: " + solar_xray + "\nSolar Flux: " + solar_flux + "\nSignal Noise: " + signalnoise
|
||||
else:
|
||||
logger.error("Solar: Error fetching solar conditions")
|
||||
solar_cond = ERROR_FETCHING_DATA
|
||||
except Exception as e:
|
||||
logger.error(f"Solar: Exception fetching or parsing: {e}")
|
||||
solar_cond = ERROR_FETCHING_DATA
|
||||
return solar_cond
|
||||
|
||||
def drap_xray_conditions():
|
||||
# DRAP X-ray flux conditions, from NOAA direct
|
||||
drap_cond = ""
|
||||
drap_cond = requests.get("https://services.swpc.noaa.gov/text/drap_global_frequencies.txt", timeout=urlTimeoutSeconds)
|
||||
if(drap_cond.ok):
|
||||
drap_list = drap_cond.text.split('\n')
|
||||
x_filter = '# X-RAY Message :'
|
||||
for line in drap_list:
|
||||
if x_filter in line:
|
||||
xray_flux = line.split(": ")[1]
|
||||
else:
|
||||
logger.error("Error fetching DRAP X-ray flux")
|
||||
xray_flux = ERROR_FETCHING_DATA
|
||||
return xray_flux
|
||||
|
||||
def get_noaa_scales_summary():
|
||||
"""
|
||||
Show latest observed, 24-hour max, and predicted geomagnetic, storm, and blackout data.
|
||||
"""
|
||||
try:
|
||||
response = requests.get("https://services.swpc.noaa.gov/products/noaa-scales.json", timeout=urlTimeoutSeconds)
|
||||
if response.ok:
|
||||
data = response.json()
|
||||
today = datetime.utcnow().date()
|
||||
latest_entry = None
|
||||
latest_dt = None
|
||||
max_g_today = None
|
||||
max_g_scale = -1
|
||||
predicted_g = None
|
||||
predicted_g_scale = -1
|
||||
|
||||
# Find latest observed and 24-hour max for today
|
||||
for entry in data.values():
|
||||
date_str = entry.get("DateStamp")
|
||||
time_str = entry.get("TimeStamp")
|
||||
if date_str and time_str:
|
||||
try:
|
||||
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M:%S")
|
||||
g = entry.get("G", {})
|
||||
g_scale = int(g.get("Scale", -1)) if g.get("Scale") else -1
|
||||
# Latest observed for today
|
||||
if dt.date() == today:
|
||||
if latest_dt is None or dt > latest_dt:
|
||||
latest_dt = dt
|
||||
latest_entry = entry
|
||||
# 24-hour max for today
|
||||
if g_scale > max_g_scale:
|
||||
max_g_scale = g_scale
|
||||
max_g_today = entry
|
||||
# Predicted (future)
|
||||
elif dt.date() > today:
|
||||
if g_scale > predicted_g_scale:
|
||||
predicted_g_scale = g_scale
|
||||
predicted_g = entry
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def format_entry(label, entry):
|
||||
if not entry:
|
||||
return f"{label}: No data"
|
||||
g = entry.get("G", {})
|
||||
s = entry.get("S", {})
|
||||
r = entry.get("R", {})
|
||||
parts = [f"{label} {g.get('Text', 'N/A')} (G:{g.get('Scale', 'N/A')})"]
|
||||
|
||||
# Only show storm if it's happening
|
||||
if s.get("Text") and s.get("Text") != "none":
|
||||
parts.append(f"Currently:{s.get('Text')} (S:{s.get('Scale', 'N/A')})")
|
||||
|
||||
# Only show blackout if it's not "none" or scale is not 0
|
||||
if r.get("Text") and r.get("Text") != "none" and r.get("Scale") not in [None, "0", 0]:
|
||||
parts.append(f"RF Blackout:{r.get('Text')} (R:{r.get('Scale', 'N/A')})")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
output = []
|
||||
#output.append(format_entry("Latest Observed", latest_entry))
|
||||
output.append(format_entry("24hrMax:", max_g_today))
|
||||
output.append(format_entry("Predicted:", predicted_g))
|
||||
return "\n".join(output)
|
||||
else:
|
||||
return NO_ALERTS
|
||||
except Exception as e:
|
||||
logger.warning(f"Error fetching services.swpc.noaa.gov: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
def get_sun(lat=0, lon=0):
|
||||
# get sunrise and sunset times using callers location or default
|
||||
obs = ephem.Observer()
|
||||
obs.date = datetime.now(timezone.utc)
|
||||
sun = ephem.Sun()
|
||||
if lat != 0 and lon != 0:
|
||||
obs.lat = str(lat)
|
||||
obs.lon = str(lon)
|
||||
else:
|
||||
obs.lat = str(latitudeValue)
|
||||
obs.lon = str(longitudeValue)
|
||||
|
||||
sun.compute(obs)
|
||||
sun_table = {}
|
||||
|
||||
# get the sun azimuth and altitude
|
||||
sun_table['azimuth'] = sun.az
|
||||
sun_table['altitude'] = sun.alt
|
||||
|
||||
# sun is up include altitude
|
||||
if sun_table['altitude'] > 0:
|
||||
sun_table['altitude'] = sun.alt
|
||||
else:
|
||||
sun_table['altitude'] = 0
|
||||
|
||||
# get the next rise and set times
|
||||
local_sunrise = ephem.localtime(obs.next_rising(sun))
|
||||
local_sunset = ephem.localtime(obs.next_setting(sun))
|
||||
if zuluTime:
|
||||
sun_table['rise_time'] = local_sunrise.strftime('%a %d %H:%M')
|
||||
sun_table['set_time'] = local_sunset.strftime('%a %d %H:%M')
|
||||
else:
|
||||
sun_table['rise_time'] = local_sunrise.strftime('%a %d %I:%M%p')
|
||||
sun_table['set_time'] = local_sunset.strftime('%a %d %I:%M%p')
|
||||
|
||||
# if sunset is before sunrise, then data will be for tomorrow format sunset first and sunrise second
|
||||
if local_sunset < local_sunrise:
|
||||
sun_data = "SunSet: " + sun_table['set_time'] + "\nRise: " + sun_table['rise_time']
|
||||
else:
|
||||
sun_data = "SunRise: " + sun_table['rise_time'] + "\nSet: " + sun_table['set_time']
|
||||
|
||||
sun_data += "\nDaylight: " + str((local_sunset - local_sunrise).seconds // 3600) + "h " + str(((local_sunset - local_sunrise).seconds // 60) % 60) + "m"
|
||||
|
||||
if sun_table['altitude'] > 0:
|
||||
sun_data += "\nRemaining: " + str((local_sunset - datetime.now()).seconds // 3600) + "h " + str(((local_sunset - datetime.now()).seconds // 60) % 60) + "m"
|
||||
|
||||
sun_data += "\nAzimuth: " + str('{0:.2f}'.format(sun_table['azimuth'] * 180 / ephem.pi)) + "°"
|
||||
if sun_table['altitude'] > 0:
|
||||
sun_data += "\nAltitude: " + str('{0:.2f}'.format(sun_table['altitude'] * 180 / ephem.pi)) + "°"
|
||||
return sun_data
|
||||
|
||||
def get_moon(lat=0, lon=0):
|
||||
# get moon phase and rise/set times using callers location or default
|
||||
obs = ephem.Observer()
|
||||
moon = ephem.Moon()
|
||||
if lat != 0 and lon != 0:
|
||||
obs.lat = str(lat)
|
||||
obs.lon = str(lon)
|
||||
else:
|
||||
obs.lat = str(latitudeValue)
|
||||
obs.lon = str(longitudeValue)
|
||||
|
||||
obs.date = datetime.now(timezone.utc)
|
||||
moon.compute(obs)
|
||||
moon_table = {}
|
||||
illum = moon.phase # 0 = new, 50 = first/last quarter, 100 = full
|
||||
|
||||
if illum < 1.0:
|
||||
moon_phase = 'New Moon🌑'
|
||||
elif illum < 49:
|
||||
moon_phase = 'Waxing Crescent 🌒'
|
||||
elif 49 <= illum < 51:
|
||||
moon_phase = 'First Quarter 🌓'
|
||||
elif illum < 99:
|
||||
moon_phase = 'Waxing Gibbous 🌔'
|
||||
elif illum >= 99:
|
||||
moon_phase = 'Full Moon🌕'
|
||||
elif illum > 51:
|
||||
moon_phase = 'Waning Gibbous 🌖'
|
||||
elif 51 >= illum > 49:
|
||||
moon_phase = 'Last Quarter 🌗'
|
||||
else:
|
||||
moon_phase = 'Waning Crescent 🌘'
|
||||
|
||||
moon_table['phase'] = moon_phase
|
||||
moon_table['illumination'] = moon.phase
|
||||
moon_table['azimuth'] = moon.az
|
||||
moon_table['altitude'] = moon.alt
|
||||
|
||||
local_moonrise = ephem.localtime(obs.next_rising(moon))
|
||||
local_moonset = ephem.localtime(obs.next_setting(moon))
|
||||
if zuluTime:
|
||||
moon_table['rise_time'] = local_moonrise.strftime('%a %d %H:%M')
|
||||
moon_table['set_time'] = local_moonset.strftime('%a %d %H:%M')
|
||||
else:
|
||||
moon_table['rise_time'] = local_moonrise.strftime('%a %d %I:%M%p')
|
||||
moon_table['set_time'] = local_moonset.strftime('%a %d %I:%M%p')
|
||||
|
||||
local_next_full_moon = ephem.localtime(ephem.next_full_moon((obs.date)))
|
||||
local_next_new_moon = ephem.localtime(ephem.next_new_moon((obs.date)))
|
||||
if zuluTime:
|
||||
moon_table['next_full_moon'] = local_next_full_moon.strftime('%a %b %d %H:%M')
|
||||
moon_table['next_new_moon'] = local_next_new_moon.strftime('%a %b %d %H:%M')
|
||||
else:
|
||||
moon_table['next_full_moon'] = local_next_full_moon.strftime('%a %b %d %I:%M%p')
|
||||
moon_table['next_new_moon'] = local_next_new_moon.strftime('%a %b %d %I:%M%p')
|
||||
|
||||
moon_data = "MoonRise: " + moon_table['rise_time'] + "\nSet: " + moon_table['set_time'] + \
|
||||
"\nPhase: " + moon_table['phase'] + " @: " + str('{0:.2f}'.format(moon_table['illumination'])) + "%" \
|
||||
+ "\nFullMoon: " + moon_table['next_full_moon'] + "\nNewMoon: " + moon_table['next_new_moon']
|
||||
|
||||
# if moon is in the sky, add azimuth and altitude
|
||||
if moon_table['altitude'] > 0:
|
||||
moon_data += "\nAz: " + str('{0:.2f}'.format(moon_table['azimuth'] * 180 / ephem.pi)) + "°" + \
|
||||
"\nAlt: " + str('{0:.2f}'.format(moon_table['altitude'] * 180 / ephem.pi)) + "°"
|
||||
|
||||
return moon_data
|
||||
|
||||
def getNextSatellitePass(satellite, lat=0, lon=0):
|
||||
pass_data = ''
|
||||
# get the next satellite pass for a given satellite
|
||||
visualPassAPI = "https://api.n2yo.com/rest/v1/satellite/visualpasses/"
|
||||
if lat == 0 and lon == 0:
|
||||
lat = latitudeValue
|
||||
lon = longitudeValue
|
||||
# API URL
|
||||
if n2yoAPIKey == '':
|
||||
logger.error("System: Missing API key free at https://www.n2yo.com/login/")
|
||||
return "not configured, bug your sysop"
|
||||
url = visualPassAPI + str(satellite) + "/" + str(lat) + "/" + str(lon) + "/0/2/300/" + "&apiKey=" + n2yoAPIKey
|
||||
# get the next pass data
|
||||
try:
|
||||
if not int(satellite):
|
||||
raise Exception("Invalid satellite number")
|
||||
next_pass_data = requests.get(url, timeout=urlTimeoutSeconds)
|
||||
if(next_pass_data.ok):
|
||||
pass_json = next_pass_data.json()
|
||||
if 'info' in pass_json and 'passescount' in pass_json['info'] and pass_json['info']['passescount'] > 0:
|
||||
satname = pass_json['info']['satname']
|
||||
pass_time = pass_json['passes'][0]['startUTC']
|
||||
pass_duration = pass_json['passes'][0]['duration']
|
||||
pass_maxEl = pass_json['passes'][0]['maxEl']
|
||||
pass_rise_time = datetime.fromtimestamp(pass_time).strftime('%a %d %I:%M%p')
|
||||
pass_startAzCompass = pass_json['passes'][0]['startAzCompass']
|
||||
pass_set_time = datetime.fromtimestamp(pass_time + pass_duration).strftime('%a %d %I:%M%p')
|
||||
pass__endAzCompass = pass_json['passes'][0]['endAzCompass']
|
||||
pass_data = f"{satname} @{pass_rise_time} Az: {pass_startAzCompass} for{getPrettyTime(pass_duration)}, MaxEl: {pass_maxEl}° Set @{pass_set_time} Az: {pass__endAzCompass}"
|
||||
elif pass_json['info']['passescount'] == 0:
|
||||
satname = pass_json['info']['satname']
|
||||
pass_data = f"{satname} has no upcoming passes"
|
||||
else:
|
||||
logger.error(f"System: Error fetching satellite pass data {satellite}")
|
||||
pass_data = ERROR_FETCHING_DATA
|
||||
except Exception as e:
|
||||
logger.warning(f"System: User supplied value {satellite} unknown or invalid")
|
||||
pass_data = "Provide NORAD# example use: 🛰️satpass 25544,33591"
|
||||
return pass_data
|
||||
|
||||
def measureHeight(lat=0, lon=0, shadow=0):
|
||||
# measure height of a given location using sun angle and shadow length
|
||||
if lat == 0 and lon == 0:
|
||||
return NO_DATA_NOGPS
|
||||
if shadow == 0:
|
||||
return NO_ALERTS
|
||||
obs = ephem.Observer()
|
||||
obs.lat = str(lat)
|
||||
obs.lon = str(lon)
|
||||
obs.date = datetime.now(timezone.utc)
|
||||
sun = ephem.Sun()
|
||||
sun.compute(obs)
|
||||
sun_altitude = sun.alt * 180 / ephem.pi
|
||||
if sun_altitude <= 0:
|
||||
return "☀️Sun is below horizon, I dont belive your shadow measurement"
|
||||
try:
|
||||
if use_metric:
|
||||
height = float(shadow) * math.tan(sun.alt)
|
||||
return f"📏Object Height: {height:.2f} m (Shadow: {shadow} m, 📐Sun Alt: {sun_altitude:.2f}°)"
|
||||
else:
|
||||
# Assume shadow is in feet if imperial, otherwise convert from meters to feet
|
||||
shadow_ft = float(shadow)
|
||||
height_ft = shadow_ft * math.tan(sun.alt)
|
||||
return f"📏Object Height: {height_ft:.2f} ft (Shadow: {shadow_ft} ft, 📐Sun Alt: {sun_altitude:.2f}°)"
|
||||
except Exception as e:
|
||||
logger.error(f"Space: Error calculating height: {e}")
|
||||
return NO_ALERTS
|
||||
280
modules/survey.py
Normal file
280
modules/survey.py
Normal file
@@ -0,0 +1,280 @@
|
||||
# Survey Module for meshbot 2025
|
||||
# Provides a survey function to collect responses and put into a CSV file
|
||||
# this module reads survey definitions from JSON files in the data/surveys directory
|
||||
# Each survey is defined in a separate JSON file named <survey_name>_survey.json
|
||||
# Example survey file: example_survey.json
|
||||
# Example survey response file: example_responses.csv
|
||||
# Each survey consists of multiple questions, which can be multiple choice, integer, or text
|
||||
# Users can start a survey, answer questions, and end the survey
|
||||
# Module acts like a game locking DM until the survey is complete or ended
|
||||
|
||||
import json
|
||||
import os # For file operations
|
||||
import csv
|
||||
from datetime import datetime
|
||||
from collections import Counter
|
||||
from modules.log import logger
|
||||
from modules.settings import surveyRecordLocation, surveyRecordID
|
||||
|
||||
allowedSurveys = [] # List of allowed survey names
|
||||
|
||||
trap_list_survey = ("survey",)
|
||||
|
||||
class SurveyModule:
|
||||
def __init__(self):
|
||||
self.base_dir = os.path.dirname(__file__)
|
||||
self.survey_dir = os.path.join(self.base_dir, '..', 'data', 'surveys') # Directory for survey JSON files
|
||||
self.response_dir = os.path.join(self.base_dir, '..', 'data', 'surveys') # Directory for survey response CSV files
|
||||
self.surveys = {}
|
||||
self.responses = {}
|
||||
self.load_surveys()
|
||||
|
||||
def load_surveys(self):
|
||||
"""Load all surveys from the surveys directory with _survey.json suffix."""
|
||||
global allowedSurveys
|
||||
allowedSurveys.clear()
|
||||
try:
|
||||
for filename in os.listdir(self.survey_dir):
|
||||
if filename.endswith('_survey.json'):
|
||||
survey_name = filename[:-12] # Remove '_survey.json'
|
||||
allowedSurveys.append(survey_name)
|
||||
path = os.path.join(self.survey_dir, filename)
|
||||
try:
|
||||
with open(path, encoding='utf-8') as f:
|
||||
self.surveys[survey_name] = json.load(f)
|
||||
except FileNotFoundError:
|
||||
logger.error(f"File not found: {path}")
|
||||
self.surveys[survey_name] = []
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Error decoding JSON from file: {path}")
|
||||
self.surveys[survey_name] = []
|
||||
except Exception as e:
|
||||
logger.error(f"Survey: Error loading surveys: {e}")
|
||||
|
||||
def start_survey(self, user_id, survey_name='example', location=None):
|
||||
try:
|
||||
"""Begin a new survey session for a user."""
|
||||
if not survey_name:
|
||||
survey_name = default_survey
|
||||
if survey_name not in allowedSurveys:
|
||||
return f"error: survey '{survey_name}' is not allowed."
|
||||
self.responses[user_id] = {
|
||||
'survey_name': survey_name,
|
||||
'current_question': 0,
|
||||
'answers': [],
|
||||
'location': location if surveyRecordLocation and location is not None else 'N/A'
|
||||
}
|
||||
msg = f"'{survey_name}'📝survey\n"
|
||||
msg += self.show_question(user_id)
|
||||
msg += f"\nSend answer' or 'end'"
|
||||
return msg
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting survey for user {user_id}: {e}")
|
||||
return "An error occurred while starting the survey. Please try again later."
|
||||
|
||||
def show_question(self, user_id):
|
||||
"""Show the current question for the user, or end the survey."""
|
||||
survey_name = self.responses[user_id]['survey_name']
|
||||
current = self.responses[user_id]['current_question']
|
||||
questions = self.surveys.get(survey_name, [])
|
||||
if current >= len(questions):
|
||||
return self.end_survey(user_id)
|
||||
question = questions[current]
|
||||
msg = f"{question['question']}\n"
|
||||
if question.get('type', 'multiple_choice') == 'multiple_choice':
|
||||
for i, option in enumerate(question['options']):
|
||||
msg += f"{chr(65+i)}. {option}\n"
|
||||
elif question['type'] == 'integer':
|
||||
msg += "(Please enter a number)\n"
|
||||
elif question['type'] == 'text':
|
||||
msg += "(Please enter your response)\n"
|
||||
msg = msg.rstrip('\n')
|
||||
return msg
|
||||
|
||||
def save_responses(self, user_id):
|
||||
"""Save user responses to a CSV file."""
|
||||
survey_name = self.responses[user_id]['survey_name']
|
||||
if survey_name not in self.surveys:
|
||||
logger.warning(f"Survey '{survey_name}' not loaded. Responses not saved.")
|
||||
return
|
||||
filename = os.path.join(self.response_dir, f'{survey_name}_responses.csv')
|
||||
try:
|
||||
# Check if file exists and if it has a header
|
||||
write_header = not os.path.isfile(filename) or os.path.getsize(filename) == 0
|
||||
with open(filename, 'a', encoding='utf-8') as f:
|
||||
# Write header if needed
|
||||
if write_header:
|
||||
header = ['timestamp', 'user_id', 'location'] + [f'Q{i+1}' for i in range(len(self.responses[user_id]['answers']))]
|
||||
f.write(','.join(header) + '\n')
|
||||
# Always write: timestamp, userID, position, answers...
|
||||
timestamp = datetime.now().strftime('%d%m%Y%H%M%S')
|
||||
user_id_str = str(user_id)
|
||||
location = self.responses[user_id].get('location', "N/A")
|
||||
answers = list(map(str, self.responses[user_id]['answers']))
|
||||
row = [timestamp, user_id_str, str(location)] + answers
|
||||
f.write(','.join(row) + '\n')
|
||||
logger.info(f"Survey: Responses for user {user_id} saved for survey '{survey_name}' to {filename}.")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving responses to {filename}: {e}")
|
||||
|
||||
def format_survey_results(self, results):
|
||||
if isinstance(results, dict) and "error" in results:
|
||||
return results["error"]
|
||||
if not results:
|
||||
return "No results found."
|
||||
msg = "📊Survey Results:\n"
|
||||
for idx, q in enumerate(results):
|
||||
msg += f"\nQ{idx+1}: {q['question']}\n"
|
||||
if q['type'] == 'multiple_choice':
|
||||
for opt, count in q['summary'].items():
|
||||
msg += f" {opt}: {count}\n"
|
||||
elif q['type'] == 'integer':
|
||||
s = q['summary']
|
||||
msg += f" Count: {s['count']}, Avg: {s['average']:.2f}, Min: {s['min']}, Max: {s['max']}\n"
|
||||
elif q['type'] == 'text':
|
||||
msg += f" Responses: {q['summary']['responses_count']}\n"
|
||||
return msg
|
||||
|
||||
def get_survey_results(self, survey_name='example'):
|
||||
if survey_name not in self.surveys:
|
||||
return {"error": f"Survey '{survey_name}' not found."}
|
||||
filename = os.path.join(self.response_dir, f'{survey_name}_responses.csv')
|
||||
questions = self.surveys[survey_name]
|
||||
results = []
|
||||
try:
|
||||
with open(filename, encoding='utf-8') as f:
|
||||
reader = csv.reader(f)
|
||||
lines = []
|
||||
for row in reader:
|
||||
if not row or len(row) < 4:
|
||||
continue
|
||||
# If location field is split due to comma, join columns 2 and 3
|
||||
if row[2].startswith('[') and not row[2].endswith(']') and len(row) > 4:
|
||||
location = row[2] + ',' + row[3]
|
||||
answers = row[4:]
|
||||
else:
|
||||
location = row[2]
|
||||
answers = row[3:]
|
||||
lines.append(answers)
|
||||
|
||||
for q_idx, question in enumerate(questions):
|
||||
qtype = question.get('type', 'multiple_choice')
|
||||
answers = [row[q_idx] for row in lines if len(row) > q_idx]
|
||||
|
||||
summary = {}
|
||||
if qtype == 'multiple_choice':
|
||||
counts = Counter(answers)
|
||||
summary = {chr(65+i): counts.get(chr(65+i), 0) for i in range(len(question.get('options', [])))}
|
||||
|
||||
elif qtype == 'integer':
|
||||
ints = [int(a) for a in answers if a.isdigit()]
|
||||
summary = {
|
||||
"count": len(ints),
|
||||
"average": sum(ints)/len(ints) if ints else 0,
|
||||
"min": min(ints) if ints else None,
|
||||
"max": max(ints) if ints else None
|
||||
}
|
||||
|
||||
elif qtype == 'text':
|
||||
summary = {"responses_count": len([a for a in answers if a.strip()])}
|
||||
|
||||
|
||||
results.append({
|
||||
"question": question['question'],
|
||||
"type": qtype,
|
||||
"summary": summary
|
||||
})
|
||||
|
||||
return results
|
||||
except FileNotFoundError:
|
||||
return {"error": f"No responses recorded yet for '{survey_name}'."}
|
||||
except Exception as e:
|
||||
logger.error(f"Error summarizing survey results: {e}")
|
||||
return NO_ALERTS
|
||||
|
||||
def answer(self, user_id, answer, location=None):
|
||||
try:
|
||||
"""Record an answer and return the next question or end message."""
|
||||
if user_id not in self.responses:
|
||||
return self.start_survey(user_id, location=location)
|
||||
question_index = self.responses[user_id]['current_question']
|
||||
survey_name = self.responses[user_id]['survey_name']
|
||||
questions = self.surveys.get(survey_name, [])
|
||||
if question_index < 0 or question_index >= len(questions):
|
||||
return "No current question to answer."
|
||||
question = questions[question_index]
|
||||
qtype = question.get('type', 'multiple_choice')
|
||||
if qtype == 'multiple_choice':
|
||||
answer_char = answer.strip().upper()[:1]
|
||||
if len(answer_char) != 1 or not answer_char.isalpha():
|
||||
return "Please answer with a letter (A, B, C, ...)."
|
||||
option_index = ord(answer_char) - 65
|
||||
if 0 <= option_index < len(question['options']):
|
||||
# Valid answer record letter, not index
|
||||
self.responses[user_id]['answers'].append(answer_char)
|
||||
self.responses[user_id]['current_question'] += 1
|
||||
return f"Recorded..\n" + self.show_question(user_id)
|
||||
else:
|
||||
print(f"Invalid option index {option_index} for question with {len(question['options'])} options. user entered '{answer}'")
|
||||
return "Invalid answer option. Please try again."
|
||||
elif qtype == 'integer':
|
||||
try:
|
||||
int_answer = int(answer)
|
||||
self.responses[user_id]['answers'].append(str(int_answer))
|
||||
self.responses[user_id]['current_question'] += 1
|
||||
return f"Recorded..\n" + self.show_question(user_id)
|
||||
except ValueError:
|
||||
return "Please enter a valid integer."
|
||||
elif qtype == 'text':
|
||||
self.responses[user_id]['answers'].append(answer.strip())
|
||||
self.responses[user_id]['current_question'] += 1
|
||||
return f"Recorded..\n" + self.show_question(user_id)
|
||||
else:
|
||||
return f"error: unknown question type '{qtype}' and cannot record answer '{answer}'"
|
||||
except Exception as e:
|
||||
logger.error(f"Error recording answer for user {user_id}: {e}")
|
||||
return "An error occurred while recording your answer. Please try again."
|
||||
|
||||
def end_survey(self, user_id):
|
||||
"""End the survey for the user and save responses."""
|
||||
if user_id not in self.responses:
|
||||
return "No active survey session to end."
|
||||
self.save_responses(user_id)
|
||||
self.responses.pop(user_id, None)
|
||||
return "✅ Survey complete. Thank you for your responses!"
|
||||
|
||||
def quiz_report(self, survey_name='example'):
|
||||
"""
|
||||
Generate a quick poll report: counts of each answer per question.
|
||||
Returns a string summary.
|
||||
"""
|
||||
filename = os.path.join(self.response_dir, f'{survey_name}_responses.csv')
|
||||
questions = self.surveys.get(survey_name, [])
|
||||
if not questions:
|
||||
logger.warning(f"No survey found for '{survey_name}'.")
|
||||
return f"No survey found for '{survey_name}'."
|
||||
all_answers = []
|
||||
try:
|
||||
with open(filename, encoding='utf-8') as f:
|
||||
for line in f:
|
||||
parts = line.strip().split(',')
|
||||
if surveyRecordID:
|
||||
answers = [int(x) for x in parts[1:] if x.strip().isdigit()]
|
||||
else:
|
||||
answers = [int(x) for x in parts if x.strip().isdigit()]
|
||||
all_answers.append(answers)
|
||||
except FileNotFoundError:
|
||||
logger.info(f"No responses recorded yet for '{survey_name}'.")
|
||||
return "No responses recorded yet."
|
||||
report = f"📊 Poll Report for '{survey_name}':\n"
|
||||
for q_idx, question in enumerate(questions):
|
||||
counts = Counter(ans[q_idx] for ans in all_answers if len(ans) > q_idx)
|
||||
report += f"\nQ{q_idx+1}: {question['question']}\n"
|
||||
for opt_idx, option in enumerate(question.get('options', [])):
|
||||
count = counts.get(opt_idx, 0)
|
||||
report += f" {chr(65+opt_idx)}. {option}: {count}\n"
|
||||
return report
|
||||
|
||||
# Initialize the survey module
|
||||
survey_module = SurveyModule()
|
||||
|
||||
2744
modules/system.py
2744
modules/system.py
File diff suppressed because it is too large
Load Diff
466
modules/test_bot.py
Normal file
466
modules/test_bot.py
Normal file
@@ -0,0 +1,466 @@
|
||||
# test_bot.py
|
||||
# Unit tests for various modules in the meshing-around project
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the parent directory to sys.path to allow module imports
|
||||
parent_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
sys.path.insert(0, parent_path)
|
||||
|
||||
import unittest
|
||||
import importlib
|
||||
import pkgutil
|
||||
import warnings
|
||||
from modules.log import logger
|
||||
from modules.settings import latitudeValue, longitudeValue
|
||||
# Suppress ResourceWarning warnings for asyncio unclosed event here
|
||||
warnings.filterwarnings("ignore", category=ResourceWarning)
|
||||
|
||||
|
||||
modules_path = os.path.join(parent_path, 'modules')
|
||||
|
||||
# Limits API calls during testing
|
||||
CHECKALL = False
|
||||
# Check for a file named .checkall in the parent directory
|
||||
checkall_path = os.path.join(parent_path, '.checkall')
|
||||
if os.path.isfile(checkall_path):
|
||||
CHECKALL = True
|
||||
|
||||
|
||||
# List of module names to exclude
|
||||
exclude = ['test_bot','udp', 'system', 'log', 'gpio', 'web',]
|
||||
available_modules = [
|
||||
m.name for m in pkgutil.iter_modules([modules_path])
|
||||
if m.name not in exclude]
|
||||
|
||||
try:
|
||||
print("\nImporting Core Modules:")
|
||||
from modules.log import logger, getPrettyTime
|
||||
print(" ✔ Imported 'log'")
|
||||
# Set location default
|
||||
lat = latitudeValue
|
||||
lon = longitudeValue
|
||||
print(f" ✔ Location set to Latitude: {lat}, Longitude: {lon}")
|
||||
from modules.system import *
|
||||
print(" ✔ Imported 'system'")
|
||||
|
||||
print("\nImporting non-excluded modules:")
|
||||
for module_name in [m.name for m in pkgutil.iter_modules([modules_path])]:
|
||||
if module_name not in exclude:
|
||||
importlib.import_module(module_name)
|
||||
print(f" ✔ Imported '{module_name}'")
|
||||
except Exception as e:
|
||||
print(f"\nError importing modules: {e}")
|
||||
print("Run this program from the main program directory: python3 script/test_bot.py")
|
||||
exit(1)
|
||||
|
||||
class TestBot(unittest.TestCase):
|
||||
def test_example(self):
|
||||
# Example test case
|
||||
self.assertEqual(1 + 1, 2)
|
||||
|
||||
def test_load_bbsdb(self):
|
||||
from bbstools import load_bbsdb
|
||||
test_load = load_bbsdb()
|
||||
self.assertTrue(test_load)
|
||||
|
||||
def test_bbs_list_messages(self):
|
||||
from bbstools import bbs_list_messages
|
||||
messages = bbs_list_messages()
|
||||
print("list_messages() returned:", messages)
|
||||
self.assertIsInstance(messages, str)
|
||||
|
||||
def test_initialize_checklist_database(self):
|
||||
from checklist import initialize_checklist_database, process_checklist_command
|
||||
result = initialize_checklist_database()
|
||||
result1 = process_checklist_command(0, 'checklist', name="none", location="none")
|
||||
self.assertTrue(result)
|
||||
self.assertIsInstance(result1, str)
|
||||
|
||||
def test_initialize_inventory_database(self):
|
||||
from inventory import initialize_inventory_database, process_inventory_command
|
||||
result = initialize_inventory_database()
|
||||
result1 = process_inventory_command(0, 'inventory', name="none")
|
||||
self.assertTrue(result)
|
||||
self.assertIsInstance(result1, str)
|
||||
|
||||
def test_init_news_sources(self):
|
||||
from filemon import initNewsSources
|
||||
result = initNewsSources()
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_get_nina_alerts(self):
|
||||
from globalalert import get_nina_alerts
|
||||
alerts = get_nina_alerts()
|
||||
self.assertIsInstance(alerts, str)
|
||||
|
||||
def test_send_ollama_query(self):
|
||||
from llm import send_ollama_query
|
||||
response = send_ollama_query("Hello, Ollama!")
|
||||
self.assertIsInstance(response, str)
|
||||
|
||||
def test_extract_search_terms(self):
|
||||
from llm import extract_search_terms
|
||||
# Test with capitalized terms
|
||||
terms = extract_search_terms("What is Python programming?")
|
||||
self.assertIsInstance(terms, list)
|
||||
self.assertTrue(len(terms) > 0)
|
||||
# Test with multiple capitalized words
|
||||
terms2 = extract_search_terms("Tell me about Albert Einstein and Marie Curie")
|
||||
self.assertIsInstance(terms2, list)
|
||||
self.assertTrue(len(terms2) > 0)
|
||||
|
||||
def test_get_wiki_context(self):
|
||||
from llm import get_wiki_context
|
||||
# Test with a well-known topic
|
||||
context = get_wiki_context("Python programming language")
|
||||
self.assertIsInstance(context, str)
|
||||
# Context might be empty if wiki is disabled or fails, that's ok
|
||||
|
||||
def test_get_moon_phase(self):
|
||||
from space import get_moon
|
||||
phase = get_moon(lat, lon)
|
||||
self.assertIsInstance(phase, str)
|
||||
|
||||
def test_get_sun_times(self):
|
||||
from space import get_sun
|
||||
sun_times = get_sun(lat, lon)
|
||||
self.assertIsInstance(sun_times, str)
|
||||
|
||||
def test_hf_band_conditions(self):
|
||||
from space import hf_band_conditions
|
||||
conditions = hf_band_conditions()
|
||||
self.assertIsInstance(conditions, str)
|
||||
|
||||
def test_get_wikipedia_summary(self):
|
||||
from wiki import get_wikipedia_summary
|
||||
summary = get_wikipedia_summary("Python", location=(lat, lon))
|
||||
self.assertIsInstance(summary, str)
|
||||
|
||||
def test_get_kiwix_summary(self):
|
||||
from wiki import get_kiwix_summary
|
||||
summary = get_kiwix_summary("Python")
|
||||
self.assertIsInstance(summary, str)
|
||||
|
||||
def get_openskynetwork(self):
|
||||
from locationdata import get_openskynetwork
|
||||
flights = get_openskynetwork(lat, lon)
|
||||
self.assertIsInstance(flights, str)
|
||||
|
||||
def test_initalize_qrz_database(self):
|
||||
from qrz import initalize_qrz_database
|
||||
result = initalize_qrz_database()
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_import_radio_module(self):
|
||||
try:
|
||||
import radio
|
||||
#frequency = get_hamlib('f')
|
||||
#self.assertIsInstance(frequency, str)
|
||||
except Exception as e:
|
||||
self.fail(f"Importing radio module failed: {e}")
|
||||
|
||||
def test_get_rss_feed(self):
|
||||
from rss import get_rss_feed
|
||||
result = get_rss_feed('')
|
||||
self.assertIsInstance(result, str)
|
||||
|
||||
|
||||
##### GAMES Tests #####
|
||||
def test_jokes(self):
|
||||
from modules.games.joke import tell_joke
|
||||
haha = tell_joke(nodeID=0, test=True)
|
||||
print("Joke response:", haha)
|
||||
self.assertIsInstance(haha, str)
|
||||
|
||||
def test_tictactoe_initial_and_move(self):
|
||||
from games.tictactoe import TicTacToe
|
||||
# Create an instance (no display module required for tests)
|
||||
tictactoe = TicTacToe(display_module=None)
|
||||
user_id = "testuser"
|
||||
# Start a new game (no move yet)
|
||||
initial = tictactoe.play(user_id, "")
|
||||
print("Initial response:", initial)
|
||||
# Make a move, e.g., '1'
|
||||
second = tictactoe.play(user_id, "1")
|
||||
print("After move '1':", second)
|
||||
self.assertIsInstance(initial, str)
|
||||
self.assertIsInstance(second, str)
|
||||
|
||||
def test_playVideoPoker(self):
|
||||
from games.videopoker import playVideoPoker
|
||||
user_id = "testuser"
|
||||
# Start a new game/session
|
||||
initial = playVideoPoker(user_id, 'deal')
|
||||
print("Initial response:", initial)
|
||||
# Place a 5-coin bet
|
||||
after_bet = playVideoPoker(user_id, '5')
|
||||
print("After placing 5-coin bet:", after_bet)
|
||||
self.assertIsInstance(initial, str)
|
||||
self.assertIsInstance(after_bet, str)
|
||||
|
||||
def test_play_blackjack(self):
|
||||
from games.blackjack import playBlackJack
|
||||
user_id = "testuser"
|
||||
# Start a new game/session
|
||||
initial = playBlackJack(user_id, 'deal')
|
||||
print("Initial response:", initial)
|
||||
# Place a 5-chip bet
|
||||
after_bet = playBlackJack(user_id, '5')
|
||||
print("After placing 5-chip bet:", after_bet)
|
||||
self.assertIsInstance(initial, str)
|
||||
self.assertIsInstance(after_bet, str)
|
||||
|
||||
|
||||
def test_hangman_initial_and_guess(self):
|
||||
from games.hangman import hangman
|
||||
user_id = "testuser"
|
||||
# Start a new game (no guess yet)
|
||||
initial = hangman.play(user_id, "")
|
||||
print("Initial response:", initial)
|
||||
# Guess a letter, e.g., 'e'
|
||||
second = hangman.play(user_id, "e")
|
||||
print("After guessing 'e':", second)
|
||||
self.assertIsInstance(initial, str)
|
||||
self.assertIsInstance(second, str)
|
||||
|
||||
|
||||
def test_play_lemonade_stand(self):
|
||||
from games.lemonade import playLemonstand, lemonadeTracker
|
||||
user_id = "testuser"
|
||||
# Ensure user is in tracker
|
||||
if not any(u['nodeID'] == user_id for u in lemonadeTracker):
|
||||
lemonadeTracker.append({'nodeID': user_id, 'cups': 0, 'lemons': 0, 'sugar': 0, 'cash': 30.0, 'start': 30.0, 'cmd': 'new', 'last_played': 0})
|
||||
# Start a new game
|
||||
initial = playLemonstand(user_id, "", newgame=True)
|
||||
print("Initial response:", initial)
|
||||
# Buy 1 box of cups
|
||||
after_cups = playLemonstand(user_id, "1")
|
||||
print("After buying 1 box of cups:", after_cups)
|
||||
self.assertIsInstance(initial, str)
|
||||
self.assertIsInstance(after_cups, str)
|
||||
|
||||
|
||||
def test_play_golfsim_one_hole(self):
|
||||
from games.golfsim import playGolf
|
||||
user_id = "testuser"
|
||||
# Start a new game/hole
|
||||
initial = playGolf(user_id, "", last_cmd="new")
|
||||
print("Initial hole info:", initial)
|
||||
# Take first shot with driver
|
||||
after_shot = playGolf(user_id, "driver")
|
||||
print("After hitting driver:", after_shot)
|
||||
self.assertIsInstance(initial, str)
|
||||
self.assertIsInstance(after_shot, str)
|
||||
|
||||
|
||||
def test_play_dopewar_choose_city_and_list(self):
|
||||
from games.dopewar import playDopeWars
|
||||
user_id = 1234567899 # Use a unique test user ID
|
||||
# Start a new game, get city selection prompt
|
||||
initial = playDopeWars(user_id, "")
|
||||
print("Initial city selection:", initial)
|
||||
# Choose city 1
|
||||
after_city = playDopeWars(user_id, "1")
|
||||
print("After choosing city 1 (main game list):", after_city)
|
||||
self.assertIsInstance(initial, str)
|
||||
self.assertIsInstance(after_city, str)
|
||||
|
||||
|
||||
def test_play_mastermind_one_guess(self):
|
||||
from games.mmind import start_mMind
|
||||
user_id = 1234567899 # Use a unique test user ID
|
||||
# Start a new game (should prompt for difficulty/colors)
|
||||
initial = start_mMind(user_id, "n")
|
||||
print("Initial response (difficulty/colors):", initial)
|
||||
# Make a guess (e.g., "RGBY" - valid for normal)
|
||||
after_guess = start_mMind(user_id, "RGBY")
|
||||
print("After guessing RGBY:", after_guess)
|
||||
self.assertIsInstance(initial, str)
|
||||
self.assertIsInstance(after_guess, str)
|
||||
|
||||
|
||||
def test_quiz_game_answer_one_and_end(self):
|
||||
from games.quiz import quizGamePlayer
|
||||
quizmaster_id = "admin" # Use a valid quizmaster ID from bbs_admin_list
|
||||
user_id = "testuser"
|
||||
# Start the quiz as quizmaster
|
||||
start_msg = quizGamePlayer.start_game(quizmaster_id)
|
||||
print("Quiz start:", start_msg)
|
||||
# User joins the quiz
|
||||
join_msg = quizGamePlayer.join(user_id)
|
||||
print("User joined:", join_msg)
|
||||
# Get the first question (should be included in join_msg, but call explicitly for clarity)
|
||||
question_msg = quizGamePlayer.next_question(user_id)
|
||||
print("First question:", question_msg)
|
||||
# Simulate answering with 'A' (adjust if your first question expects a different answer)
|
||||
answer_msg = quizGamePlayer.answer(user_id, "A")
|
||||
print("Answer response:", answer_msg)
|
||||
# End the quiz as quizmaster
|
||||
end_msg = quizGamePlayer.stop_game(quizmaster_id)
|
||||
print("Quiz end:", end_msg)
|
||||
self.assertIsInstance(start_msg, str)
|
||||
self.assertIsInstance(join_msg, str)
|
||||
self.assertIsInstance(question_msg, str)
|
||||
self.assertIsInstance(answer_msg, str)
|
||||
self.assertIsInstance(end_msg, str)
|
||||
|
||||
def test_survey_answer_one_and_end(self):
|
||||
from survey import survey_module
|
||||
user_id = "testuser"
|
||||
survey_name = "example" # Make sure this survey exists in your data/surveys directory
|
||||
|
||||
# Start the survey
|
||||
start_msg = survey_module.start_survey(user_id, survey_name)
|
||||
print("Survey start:", start_msg)
|
||||
# Answer the first question with 'A' (adjust if your survey expects a different type)
|
||||
answer_msg = survey_module.answer(user_id, "A")
|
||||
print("Answer response:", answer_msg)
|
||||
# End the survey
|
||||
end_msg = survey_module.end_survey(user_id)
|
||||
print("Survey end:", end_msg)
|
||||
|
||||
self.assertIsInstance(start_msg, str)
|
||||
self.assertIsInstance(answer_msg, str)
|
||||
self.assertIsInstance(end_msg, str)
|
||||
|
||||
|
||||
def test_hamtest_answer_one(self):
|
||||
from games.hamtest import hamtest
|
||||
user_id = "testuser"
|
||||
# Start a new ham test game (default level: technician)
|
||||
initial = hamtest.newGame(user_id)
|
||||
print("Initial question:", initial)
|
||||
# Answer the first question with 'A'
|
||||
answer_msg = hamtest.answer(user_id, "A")
|
||||
print("Answer response:", answer_msg)
|
||||
self.assertIsInstance(initial, str)
|
||||
self.assertIsInstance(answer_msg, str)
|
||||
|
||||
|
||||
##### API Tests - Extended tests run only if CHECKALL is True #####
|
||||
|
||||
|
||||
if CHECKALL:
|
||||
logger.info("Running extended API tests as CHECKALL is enabled.")
|
||||
def test_handledxcluster(self):
|
||||
from modules.dxspot import handledxcluster
|
||||
test_message = "DX band=20m mode=SSB of=K7MHI"
|
||||
response = handledxcluster(test_message, nodeID=0, deviceID='testdevice')
|
||||
print("DX Spotter response:", response)
|
||||
self.assertIsInstance(response, str)
|
||||
|
||||
def test_getRepeaterBook(self):
|
||||
from locationdata import getRepeaterBook
|
||||
repeaters = getRepeaterBook(lat, lon)
|
||||
self.assertIsInstance(repeaters, str)
|
||||
|
||||
def test_getArtSciRepeaters(self):
|
||||
from locationdata import getArtSciRepeaters
|
||||
repeaters = getArtSciRepeaters(lat, lon)
|
||||
self.assertIsInstance(repeaters, str)
|
||||
|
||||
def test_get_NOAAtides(self):
|
||||
from locationdata import get_NOAAtide
|
||||
tides = get_NOAAtide(lat, lon)
|
||||
self.assertIsInstance(tides, str)
|
||||
|
||||
def test_get_NOAAweather(self):
|
||||
from locationdata import get_NOAAweather
|
||||
weather = get_NOAAweather(lat, lon)
|
||||
self.assertIsInstance(weather, str)
|
||||
|
||||
def test_where_am_i(self):
|
||||
from locationdata import where_am_i
|
||||
location = where_am_i(lat, lon)
|
||||
self.assertIsInstance(location, str)
|
||||
|
||||
def test_getWeatherAlertsNOAA(self):
|
||||
from locationdata import getWeatherAlertsNOAA
|
||||
alerts = getWeatherAlertsNOAA(lat, lon)
|
||||
if isinstance(alerts, tuple):
|
||||
self.assertIsInstance(alerts[0], str)
|
||||
else:
|
||||
self.assertIsInstance(alerts, str)
|
||||
|
||||
def test_getActiveWeatherAlertsDetailNOAA(self):
|
||||
from locationdata import getActiveWeatherAlertsDetailNOAA
|
||||
alerts_detail = getActiveWeatherAlertsDetailNOAA(lat, lon)
|
||||
self.assertIsInstance(alerts_detail, str)
|
||||
|
||||
def test_getIpawsAlerts(self):
|
||||
from locationdata import getIpawsAlert
|
||||
alerts = getIpawsAlert(lat, lon)
|
||||
self.assertIsInstance(alerts, str)
|
||||
|
||||
def test_get_flood_noaa(self):
|
||||
from locationdata import get_flood_noaa
|
||||
flood_info = get_flood_noaa(lat, lon, 12484500) # Example gauge UID
|
||||
self.assertIsInstance(flood_info, str)
|
||||
|
||||
def test_get_volcano_usgs(self):
|
||||
from locationdata import get_volcano_usgs
|
||||
volcano_info = get_volcano_usgs(lat, lon)
|
||||
self.assertIsInstance(volcano_info, str)
|
||||
|
||||
def test_get_nws_marine_alerts(self):
|
||||
from locationdata import get_nws_marine
|
||||
marine_alerts = get_nws_marine('https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/pz/pzz135.txt',1) # Example zone
|
||||
self.assertIsInstance(marine_alerts, str)
|
||||
|
||||
def test_checkUSGSEarthQuakes(self):
|
||||
from locationdata import checkUSGSEarthQuake
|
||||
earthquakes = checkUSGSEarthQuake(lat, lon)
|
||||
self.assertIsInstance(earthquakes, str)
|
||||
|
||||
def test_getNextSatellitePass(self):
|
||||
from space import getNextSatellitePass
|
||||
pass_info = getNextSatellitePass('25544', lat, lon)
|
||||
self.assertIsInstance(pass_info, str)
|
||||
|
||||
def test_get_wx_meteo(self):
|
||||
from wx_meteo import get_wx_meteo
|
||||
weather_report = get_wx_meteo(lat, lon)
|
||||
self.assertIsInstance(weather_report, str)
|
||||
|
||||
def test_get_flood_openmeteo(self):
|
||||
from wx_meteo import get_flood_openmeteo
|
||||
flood_report = get_flood_openmeteo(lat, lon)
|
||||
self.assertIsInstance(flood_report, str)
|
||||
|
||||
def test_check_callsign_match(self):
|
||||
# Test the callsign filtering function for WSJT-X/JS8Call
|
||||
from radio import check_callsign_match
|
||||
|
||||
# Test with empty filter (should match all)
|
||||
self.assertTrue(check_callsign_match("CQ K7MHI CN87", []))
|
||||
|
||||
# Test exact match
|
||||
self.assertTrue(check_callsign_match("CQ K7MHI CN87", ["K7MHI"]))
|
||||
|
||||
# Test case insensitive match
|
||||
self.assertTrue(check_callsign_match("CQ k7mhi CN87", ["K7MHI"]))
|
||||
self.assertTrue(check_callsign_match("CQ K7MHI CN87", ["k7mhi"]))
|
||||
|
||||
# Test no match
|
||||
self.assertFalse(check_callsign_match("CQ W1AW FN31", ["K7MHI"]))
|
||||
|
||||
# Test multiple callsigns
|
||||
self.assertTrue(check_callsign_match("CQ W1AW FN31", ["K7MHI", "W1AW"]))
|
||||
self.assertTrue(check_callsign_match("K7MHI DE W1AW", ["K7MHI", "W1AW"]))
|
||||
|
||||
# Test portable/mobile suffixes
|
||||
self.assertTrue(check_callsign_match("CQ K7MHI/P CN87", ["K7MHI"]))
|
||||
self.assertTrue(check_callsign_match("W1AW-7", ["W1AW"]))
|
||||
|
||||
# Test no false positives with partial matches
|
||||
self.assertFalse(check_callsign_match("CQ K7MHIX CN87", ["K7MHI"]))
|
||||
self.assertFalse(check_callsign_match("K7 TEST", ["K7MHI"]))
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if not CHECKALL:
|
||||
print("\nNote: Extended API tests are skipped. To enable them, create a file named '.checkall' in the parent directory.\n")
|
||||
unittest.main()
|
||||
126
modules/udp.py
Normal file
126
modules/udp.py
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# UDP Interface Listener
|
||||
# credit to pdxlocations for all of this core work https://github.com/pdxlocations/
|
||||
# depends on: pip install meshtastic protobuf zeroconf pubsub
|
||||
# 2025 Kelly Keeton K7MHI
|
||||
from pubsub import pub
|
||||
from meshtastic.protobuf import mesh_pb2, portnums_pb2
|
||||
from mudp import UDPPacketStream, node, conn, send_text_message, send_nodeinfo, send_device_telemetry, send_position, send_environment_metrics, send_power_metrics, send_waypoint, send_data
|
||||
from mudp.encryption import generate_hash
|
||||
import time
|
||||
from zeroconf import Zeroconf, ServiceBrowser
|
||||
import socket
|
||||
|
||||
MCAST_GRP, MCAST_PORT, CHANNEL_ID, KEY = "224.0.0.69", 4403, "LongFast", "1PG7OiApB1nwvP+rz05pAQ=="
|
||||
PUBLIC_CHANNEL_IDS = ["LongFast", "ShortSlow", "MediumFast", "MediumSlow", "ShortFast", "ShortTurbo"]
|
||||
mudpEnabled, mudpInterface = True, None
|
||||
messages = []
|
||||
|
||||
class ZeroconfListner:
|
||||
def add_service(self, zeroconf, type, name):
|
||||
info = zeroconf.get_service_info(type, name)
|
||||
if info:
|
||||
txt = info.properties
|
||||
ip = None
|
||||
if info.addresses:
|
||||
ip = socket.inet_ntoa(info.addresses[0])
|
||||
print(f"Found Meshtastic node: id={txt.get(b'id', b'').decode()} shortname={txt.get(b'shortname', b'').decode()} longname={txt.get(b'longname', b'').decode()} ip={ip}")
|
||||
|
||||
def update_service(self, zeroconf, type, name):
|
||||
# This method is required by zeroconf, but you can leave it empty if you don't need updates.
|
||||
pass
|
||||
|
||||
def initalize_mudp():
|
||||
global mudpInterface
|
||||
if mudpEnabled and mudpInterface is None:
|
||||
mudpInterface = UDPPacketStream(MCAST_GRP, MCAST_PORT, key=KEY)
|
||||
print(f"MUDP Interface initialized with multicast group", MCAST_GRP, "port", MCAST_PORT)
|
||||
node.node_id, node.long_name, node.short_name = "!deadbeef", "UDP Test", "UDP"
|
||||
node.channel, node.key = "LongFast", KEY
|
||||
conn.setup_multicast(MCAST_GRP, MCAST_PORT)
|
||||
|
||||
def on_recieve(packet: mesh_pb2.MeshPacket, addr=None):
|
||||
print(f"\n[RECV] Packet received from {addr}")
|
||||
print("from:", getattr(packet, "from", None))
|
||||
print("to:", packet.to)
|
||||
|
||||
# Check against all public channels
|
||||
matched_channel = None
|
||||
for channel_name in PUBLIC_CHANNEL_IDS:
|
||||
channel_hash = generate_hash(channel_name, KEY)
|
||||
if packet.channel == channel_hash:
|
||||
matched_channel = channel_name
|
||||
break
|
||||
|
||||
if matched_channel:
|
||||
channel_status = f"Match ({matched_channel})"
|
||||
else:
|
||||
channel_status = f"Hash: {packet.channel}"
|
||||
|
||||
print("channel:", channel_status)
|
||||
|
||||
if packet.HasField("decoded"):
|
||||
port_name = portnums_pb2.PortNum.Name(packet.decoded.portnum) if packet.decoded.portnum else "N/A"
|
||||
try:
|
||||
payload_decoded = True
|
||||
packet_payload = packet.decoded.payload.decode("utf-8", "ignore")
|
||||
except Exception:
|
||||
print(" payload (raw bytes):", packet.decoded.payload)
|
||||
else:
|
||||
print(f"encrypted: { {packet.encrypted} }")
|
||||
|
||||
|
||||
print("id:", packet.id or None)
|
||||
print("rx_time:", packet.rx_time or None)
|
||||
print("rx_snr:", packet.rx_snr or None)
|
||||
print("hop_limit:", packet.hop_limit or None)
|
||||
priority_name = mesh_pb2.MeshPacket.Priority.Name(packet.priority) if packet.priority else "N/A"
|
||||
print("priority:", priority_name or None)
|
||||
print("rx_rssi:", packet.rx_rssi or None)
|
||||
print("hop_start:", packet.hop_start or None)
|
||||
print("next_hop:", packet.next_hop or None)
|
||||
print("relay_node:", packet.relay_node or None)
|
||||
|
||||
print(f"decoded {{portnum: {port_name}, payload: {packet_payload if payload_decoded else 'N/A'}, bitfield: {packet.decoded.bitfield or None}}}" if packet.HasField("decoded") else "No decoded field")
|
||||
|
||||
pub.subscribe(on_recieve, "mesh.rx.packet")
|
||||
# pub.subscribe(on_text_message, "mesh.rx.port.1")
|
||||
# pub.subscribe(on_nodeinfo, "mesh.rx.port.4") # NODEINFO_APP
|
||||
|
||||
zeroconf = Zeroconf()
|
||||
listener = ZeroconfListner()
|
||||
browser = ServiceBrowser(zeroconf, "_meshtastic._tcp.local.", listener)
|
||||
|
||||
def main():
|
||||
initalize_mudp()
|
||||
mudpInterface.start()
|
||||
try:
|
||||
while True: time.sleep(0.05)
|
||||
except KeyboardInterrupt: pass
|
||||
finally: mudpInterface.stop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
# Meshtastic Port Numbers Reference:
|
||||
# | Port Number | Name | Purpose |
|
||||
# |-------------|------------------------|--------------------------------|
|
||||
# | 1 | TEXT_MESSAGE_APP | Text messages |
|
||||
# | 2 | POSITION_APP | Position updates (GPS) |
|
||||
# | 3 | ROUTING_APP | Routing info |
|
||||
# | 4 | NODEINFO_APP | Node info (name, id, etc) |
|
||||
# | 5 | TELEMETRY_APP | Telemetry (battery, sensors) |
|
||||
# | 6 | SERIAL_APP | Serial data |
|
||||
# | 7 | ENVIRONMENTAL_APP | Environmental sensors |
|
||||
# | 8 | REMOTE_HARDWARE_APP | Remote hardware control |
|
||||
# | 9 | STORE_FORWARD_APP | Store and forward |
|
||||
# | 10 | RANGE_TEST_APP | Range test |
|
||||
# | 11 | ADMIN_APP | Admin/config |
|
||||
# | 12 | WAYPOINT_APP | Waypoints |
|
||||
# | 13 | CHANNEL_NODEINFO_APP | Channel node info |
|
||||
# | 256 | PRIVATE_APP | Private app (custom use) |
|
||||
# See: https://github.com/meshtastic/protobufs/blob/main/meshtastic/protobuf/portnums.proto
|
||||
|
||||
|
||||
62
modules/web.py
Normal file
62
modules/web.py
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
# This is a simple web server that serves up the content of the webRoot directory
|
||||
# The reporting data is all that is currently being served up
|
||||
# TODO - add interaction to mesh?
|
||||
# to use this today run it seperately and open a browser to http://localhost:8420
|
||||
|
||||
import os
|
||||
import http.server
|
||||
|
||||
# Set the desired IP address
|
||||
server_ip = '127.0.0.1'
|
||||
|
||||
# Set the port for the server
|
||||
PORT = 8420
|
||||
|
||||
# Generate with: openssl req -new -x509 -keyout server.pem -out server.pem -days 365 -nodes
|
||||
SSL = False
|
||||
|
||||
# Set to True to enable logging sdtout
|
||||
webServerLogs = False
|
||||
|
||||
# Determine the directory where this script is located.
|
||||
script_dir = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
# Go up one level from the modules directory to the project root.
|
||||
project_root = os.path.abspath(os.path.join(script_dir, ".."))
|
||||
|
||||
# Build the absolute path to the webRoot folder; to where index.html is located.
|
||||
webRoot = os.path.join(project_root, "etc", "www")
|
||||
|
||||
if SSL:
|
||||
import ssl
|
||||
|
||||
# disable logging
|
||||
class QuietHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
if webServerLogs:
|
||||
super().log_message(format, *args)
|
||||
|
||||
# Change the current working directory to webRoot
|
||||
os.chdir(webRoot)
|
||||
|
||||
# Create the HTTP server instance with the desired IP address
|
||||
httpd = http.server.HTTPServer((server_ip, PORT), QuietHandler)
|
||||
|
||||
if SSL:
|
||||
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||
try:
|
||||
ctx.load_cert_chain(certfile='./server.pem')
|
||||
except FileNotFoundError:
|
||||
print("SSL certificate file not found. Please generate it using the command provided in the comments.")
|
||||
exit(1)
|
||||
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
|
||||
print(f"Serving reports at https://{server_ip}:{PORT} Press ^C to quit.\n\n")
|
||||
else:
|
||||
print(f"Serving reports at http://{server_ip}:{PORT} Press ^C to quit.\n\n")
|
||||
|
||||
if not webServerLogs:
|
||||
print("Server Logs are disabled")
|
||||
# Serve forever, that is until the user interrupts the process
|
||||
httpd.serve_forever()
|
||||
exit(0)
|
||||
135
modules/wiki.py
Normal file
135
modules/wiki.py
Normal file
@@ -0,0 +1,135 @@
|
||||
# meshbot wiki module
|
||||
|
||||
from modules.log import logger
|
||||
from modules.settings import (use_kiwix_server, kiwix_url, kiwix_library_name,
|
||||
urlTimeoutSeconds, wiki_return_limit, ERROR_FETCHING_DATA, wikipedia_enabled)
|
||||
#import wikipedia # pip install wikipedia
|
||||
import requests
|
||||
import bs4 as bs
|
||||
from urllib.parse import quote
|
||||
|
||||
def tag_visible(element):
|
||||
"""Filter visible text from HTML elements for Kiwix"""
|
||||
if element.parent.name in ['style', 'script', 'head', 'title', 'meta', '[document]']:
|
||||
return False
|
||||
if isinstance(element, bs.element.Comment):
|
||||
return False
|
||||
return True
|
||||
|
||||
def text_from_html(body):
|
||||
"""Extract main article text from HTML content"""
|
||||
soup = bs.BeautifulSoup(body, 'html.parser')
|
||||
# Try to find the main content div (works for both Kiwix and Wikipedia HTML)
|
||||
main = soup.find('div', class_='mw-parser-output')
|
||||
if not main:
|
||||
# Fallback: just use the body if main content div not found
|
||||
main = soup.body
|
||||
if not main:
|
||||
return ""
|
||||
texts = main.find_all(string=True)
|
||||
visible_texts = filter(tag_visible, texts)
|
||||
return " ".join(t.strip() for t in visible_texts if t.strip())
|
||||
|
||||
def get_kiwix_summary(search_term, truncate=True):
|
||||
"""Query local Kiwix server for Wikipedia article using only search results."""
|
||||
if search_term is None or search_term.strip() == "":
|
||||
return ERROR_FETCHING_DATA
|
||||
try:
|
||||
search_encoded = quote(search_term)
|
||||
search_url = f"{kiwix_url}/search?content={kiwix_library_name}&pattern={search_encoded}"
|
||||
response = requests.get(search_url, timeout=urlTimeoutSeconds)
|
||||
|
||||
if response.status_code == 200 and "No results were found" not in response.text:
|
||||
soup = bs.BeautifulSoup(response.text, 'html.parser')
|
||||
results = soup.select('div.results ul li')
|
||||
logger.debug(f"Kiwix: Found {len(results)} results in search results for:{search_term}")
|
||||
for li in results[:3]:
|
||||
a = li.find('a', href=True)
|
||||
if not a:
|
||||
continue
|
||||
article_url = f"{kiwix_url}{a['href']}"
|
||||
article_response = requests.get(article_url, timeout=urlTimeoutSeconds)
|
||||
if article_response.status_code == 200:
|
||||
text = text_from_html(article_response.text)
|
||||
# Remove navigation and search jump text
|
||||
# text = text.split("Jump to navigation", 1)[-1]
|
||||
# text = text.split("Jump to search", 1)[-1]
|
||||
sentences = text.split('. ')
|
||||
summary = '. '.join(sentences[:wiki_return_limit])
|
||||
if summary and not summary.endswith('.'):
|
||||
summary += '.'
|
||||
if truncate:
|
||||
return summary.strip()[:500]
|
||||
else:
|
||||
return summary.strip()
|
||||
|
||||
logger.debug(f"System: No Kiwix Results for:{search_term}")
|
||||
if wikipedia_enabled:
|
||||
logger.debug("Kiwix: Falling back to Wikipedia API.")
|
||||
return get_wikipedia_summary(search_term, force=True)
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"System: Error with Kiwix for:{search_term} URL:{search_url} {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
def get_wikipedia_summary(search_term, location=None, force=False, truncate=True):
|
||||
if use_kiwix_server and not force:
|
||||
return get_kiwix_summary(search_term)
|
||||
|
||||
if not search_term or not search_term.strip():
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
api_url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{requests.utils.quote(search_term)}"
|
||||
headers = {
|
||||
"User-Agent": "MeshBot/1.0 (https://github.com/kkeeton/meshing-around; contact: youremail@example.com)"
|
||||
}
|
||||
try:
|
||||
response = requests.get(api_url, timeout=5, headers=headers)
|
||||
if response.status_code == 404:
|
||||
logger.warning(f"System: No Wikipedia Results for:{search_term}")
|
||||
return ERROR_FETCHING_DATA
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
logger.debug(f"Wikipedia API response for '{search_term}': {len(data)} keys")
|
||||
if "extract" not in data or not data.get("extract"):
|
||||
#logger.debug(f"System: Wikipedia API returned no extract for:{search_term} (data: {data})")
|
||||
return ERROR_FETCHING_DATA
|
||||
if data.get("type") == "disambiguation" or "may refer to:" in data.get("extract", ""):
|
||||
#logger.warning(f"System: Disambiguation page for:{search_term} (data: {data})")
|
||||
# Fetch and parse the HTML disambiguation page
|
||||
html_url = f"https://en.wikipedia.org/wiki/{requests.utils.quote(search_term)}"
|
||||
html_resp = requests.get(html_url, timeout=5, headers=headers)
|
||||
if html_resp.status_code == 200:
|
||||
soup = bs.BeautifulSoup(html_resp.text, 'html.parser')
|
||||
items = soup.select('div.mw-parser-output ul li a[href^="/wiki/"]')
|
||||
choices = []
|
||||
for a in items:
|
||||
title = a.get('title')
|
||||
href = a.get('href')
|
||||
# Filter out non-article links
|
||||
if title and href and ':' not in href:
|
||||
choices.append(f"{title} (https://en.wikipedia.org{href})")
|
||||
if len(choices) >= 5:
|
||||
break
|
||||
if choices:
|
||||
return f"'{search_term}' is ambiguous. Did you mean:\n- " + "\n- ".join(choices)
|
||||
return f"'{search_term}' is ambiguous. Please be more specific. See: {html_url}"
|
||||
summary = data.get("extract")
|
||||
if not summary or not isinstance(summary, str) or not summary.strip():
|
||||
#logger.debug(f"System: No summary found for:{search_term} (data: {data})")
|
||||
return ERROR_FETCHING_DATA
|
||||
sentences = [s for s in summary.split('. ') if s.strip()]
|
||||
if not sentences:
|
||||
return ERROR_FETCHING_DATA
|
||||
summary = '. '.join(sentences[:wiki_return_limit])
|
||||
if summary and not summary.endswith('.'):
|
||||
summary += '.'
|
||||
if truncate:
|
||||
# Truncate to 500 characters
|
||||
return summary.strip()[:500]
|
||||
else:
|
||||
return summary.strip()
|
||||
except Exception as e:
|
||||
logger.warning(f"System: Wikipedia API error for:{search_term} {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
240
modules/wx_meteo.py
Normal file
240
modules/wx_meteo.py
Normal file
@@ -0,0 +1,240 @@
|
||||
#import openmeteo_requests # pip install openmeteo-requests
|
||||
#from retry_requests import retry # pip install retry_requests
|
||||
|
||||
import requests
|
||||
import json
|
||||
from modules.log import logger
|
||||
from modules.settings import ERROR_FETCHING_DATA
|
||||
|
||||
def get_weather_data(api_url, params):
|
||||
response = requests.get(api_url, params=params)
|
||||
response.raise_for_status() # Raise an error for bad status codes
|
||||
return response.json()
|
||||
|
||||
def get_wx_meteo(lat=0, lon=0, unit=0):
|
||||
# set forcast days 1 or 3
|
||||
forecastDays = 3
|
||||
|
||||
# Make sure all required weather variables are listed here
|
||||
# The order of variables in hourly or daily is important to assign them correctly below
|
||||
url = "https://api.open-meteo.com/v1/forecast"
|
||||
params = {
|
||||
"latitude": {lat},
|
||||
"longitude": {lon},
|
||||
"daily": ["weather_code", "temperature_2m_max", "temperature_2m_min", "precipitation_hours", "precipitation_probability_max", "wind_speed_10m_max", "wind_gusts_10m_max", "wind_direction_10m_dominant"],
|
||||
"timezone": "auto",
|
||||
"forecast_days": {forecastDays}
|
||||
}
|
||||
|
||||
# Unit 0 is imperial, 1 is metric
|
||||
if unit == 0:
|
||||
params["temperature_unit"] = "fahrenheit"
|
||||
params["wind_speed_unit"] = "mph"
|
||||
params["precipitation_unit"] = "inch"
|
||||
params["distance_unit"] = "mile"
|
||||
params["pressure_unit"] = "inHg"
|
||||
|
||||
try:
|
||||
# Fetch the weather data
|
||||
weather_data = get_weather_data(url, params)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching meteo weather data: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
# Check if we got a response
|
||||
try:
|
||||
# Process location
|
||||
logger.debug(f"System: Pulled from Open-Meteo in {weather_data['timezone']} {weather_data['timezone_abbreviation']}")
|
||||
|
||||
# Ensure response is defined
|
||||
response = weather_data
|
||||
|
||||
# Process daily data. The order of variables needs to be the same as requested.
|
||||
daily = response['daily']
|
||||
daily_weather_code = daily['weather_code']
|
||||
daily_temperature_2m_max = daily['temperature_2m_max']
|
||||
daily_temperature_2m_min = daily['temperature_2m_min']
|
||||
daily_precipitation_hours = daily['precipitation_hours']
|
||||
daily_precipitation_probability_max = daily['precipitation_probability_max']
|
||||
daily_wind_speed_10m_max = daily['wind_speed_10m_max']
|
||||
daily_wind_gusts_10m_max = daily['wind_gusts_10m_max']
|
||||
daily_wind_direction_10m_dominant = daily['wind_direction_10m_dominant']
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing meteo weather data: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
# convert wind value to cardinal directions
|
||||
for value in daily_wind_direction_10m_dominant:
|
||||
if value < 22.5:
|
||||
wind_direction = "N"
|
||||
elif value < 67.5:
|
||||
wind_direction = "NE"
|
||||
elif value < 112.5:
|
||||
wind_direction = "E"
|
||||
elif value < 157.5:
|
||||
wind_direction = "SE"
|
||||
elif value < 202.5:
|
||||
wind_direction = "S"
|
||||
elif value < 247.5:
|
||||
wind_direction = "SW"
|
||||
elif value < 292.5:
|
||||
wind_direction = "W"
|
||||
elif value < 337.5:
|
||||
wind_direction = "NW"
|
||||
else:
|
||||
wind_direction = "N"
|
||||
|
||||
# create a weather report
|
||||
weather_report = ""
|
||||
for i in range(forecastDays):
|
||||
if str(i + 1) == "1":
|
||||
weather_report += "Today, "
|
||||
elif str(i + 1) == "2":
|
||||
weather_report += "Tomorrow, "
|
||||
else:
|
||||
weather_report += "Futurecast: "
|
||||
|
||||
# report weather from WMO Weather interpretation codes (WW)
|
||||
code_string = ""
|
||||
if daily_weather_code[i] == 0:
|
||||
code_string = "Clear sky"
|
||||
elif daily_weather_code[i] == 1:
|
||||
code_string = "Mostly Cloudy"
|
||||
elif daily_weather_code[i] == 2:
|
||||
code_string = "Partly Cloudy"
|
||||
elif daily_weather_code[i] == 3:
|
||||
code_string = "Overcast"
|
||||
elif daily_weather_code[i] == 5:
|
||||
code_string = "Haze"
|
||||
elif daily_weather_code[i] == 10:
|
||||
code_string = "Mist"
|
||||
elif daily_weather_code[i] == 45:
|
||||
code_string = "Fog"
|
||||
elif daily_weather_code[i] == 48:
|
||||
code_string = "Freezing Fog"
|
||||
elif daily_weather_code[i] == 51:
|
||||
code_string = "Drizzle: Light"
|
||||
elif daily_weather_code[i] == 53:
|
||||
code_string = "Drizzle: Moderate"
|
||||
elif daily_weather_code[i] == 55:
|
||||
code_string = "Drizzle: Heavy"
|
||||
elif daily_weather_code[i] == 56:
|
||||
code_string = "Freezing Drizzle: Light"
|
||||
elif daily_weather_code[i] == 57:
|
||||
code_string = "Freezing Drizzle: Moderate"
|
||||
elif daily_weather_code[i] == 61:
|
||||
code_string = "Rain: Slight"
|
||||
elif daily_weather_code[i] == 63:
|
||||
code_string = "Rain: Moderate"
|
||||
elif daily_weather_code[i] == 65:
|
||||
code_string = "Rain: Heavy"
|
||||
elif daily_weather_code[i] == 66:
|
||||
code_string = "Freezing Rain: Light"
|
||||
elif daily_weather_code[i] == 67:
|
||||
code_string = "Freezing Rain: Dense"
|
||||
elif daily_weather_code[i] == 71:
|
||||
code_string = "Snow: Light"
|
||||
elif daily_weather_code[i] == 73:
|
||||
code_string = "Snow: Moderate"
|
||||
elif daily_weather_code[i] == 75:
|
||||
code_string = "Snow: Heavy"
|
||||
elif daily_weather_code[i] == 77:
|
||||
code_string = "Snow Grains"
|
||||
elif daily_weather_code[i] == 78:
|
||||
code_string = "Ice Crystals"
|
||||
elif daily_weather_code[i] == 79:
|
||||
code_string = "Ice Pellets"
|
||||
elif daily_weather_code[i] == 80:
|
||||
code_string = "Rain showers: Slight"
|
||||
elif daily_weather_code[i] == 81:
|
||||
code_string = "Rain showers: Moderate"
|
||||
elif daily_weather_code[i] == 82:
|
||||
code_string = "Rain showers: Heavy"
|
||||
elif daily_weather_code[i] == 85:
|
||||
code_string = "Snow showers"
|
||||
elif daily_weather_code[i] == 86:
|
||||
code_string = "Snow showers: Heavy"
|
||||
elif daily_weather_code[i] == 95:
|
||||
code_string = "Thunderstorm"
|
||||
elif daily_weather_code[i] == 96:
|
||||
code_string = "Hailstorm"
|
||||
elif daily_weather_code[i] == 97:
|
||||
code_string = "Thunderstorm Heavy"
|
||||
elif daily_weather_code[i] == 99:
|
||||
code_string = "Hailstorm Heavy"
|
||||
|
||||
weather_report += "Cond: " + code_string + ". "
|
||||
|
||||
# report temperature
|
||||
if unit == 0:
|
||||
weather_report += "High: " + str(int(round(daily_temperature_2m_max[i]))) + "F, with a low of " + str(int(round(daily_temperature_2m_min[i]))) + "F. "
|
||||
else:
|
||||
weather_report += "High: " + str(int(round(daily_temperature_2m_max[i]))) + "C, with a low of " + str(int(round(daily_temperature_2m_min[i]))) + "C. "
|
||||
|
||||
# check for precipitation
|
||||
if daily_precipitation_hours[i] > 0:
|
||||
if unit == 0:
|
||||
weather_report += "Precip: " + str(round(daily_precipitation_probability_max[i],2)) + "in, in " + str(round(daily_precipitation_hours[i],2)) + " hours. "
|
||||
else:
|
||||
weather_report += "Precip: " + str(round(daily_precipitation_probability_max[i],2)) + "mm, in " + str(round(daily_precipitation_hours[i],2)) + " hours. "
|
||||
else:
|
||||
weather_report += "No Precip. "
|
||||
|
||||
# check for wind
|
||||
if daily_wind_speed_10m_max[i] > 0:
|
||||
if unit == 0:
|
||||
weather_report += "Wind: " + str(int(round(daily_wind_speed_10m_max[i]))) + "mph, gusts up to " + str(int(round(daily_wind_gusts_10m_max[i]))) + "mph from:" + wind_direction + "."
|
||||
else:
|
||||
weather_report += "Wind: " + str(int(round(daily_wind_speed_10m_max[i]))) + "kph, gusts up to " + str(int(round(daily_wind_gusts_10m_max[i]))) + "kph from:" + wind_direction + "."
|
||||
else:
|
||||
weather_report += "No Wind\n"
|
||||
|
||||
# add a new line for the next day
|
||||
if i < forecastDays - 1:
|
||||
weather_report += "\n"
|
||||
|
||||
return weather_report
|
||||
|
||||
def get_flood_openmeteo(lat=0, lon=0):
|
||||
# set forcast days 1 or 3
|
||||
forecastDays = 3
|
||||
|
||||
# Flood data
|
||||
url = "https://flood-api.open-meteo.com/v1/flood"
|
||||
params = {
|
||||
"latitude": {lat},
|
||||
"longitude": {lon},
|
||||
"timezone": "auto",
|
||||
"daily": "river_discharge",
|
||||
"forecast_days": forecastDays
|
||||
}
|
||||
|
||||
try:
|
||||
# Fetch the flood data
|
||||
flood_data = get_weather_data(url, params)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching meteo flood data: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
# Check if we got a response
|
||||
try:
|
||||
# Process location
|
||||
logger.debug(f"System: Pulled River FLow Data from Open-Meteo {flood_data['timezone_abbreviation']}")
|
||||
|
||||
# Ensure response is defined
|
||||
response = flood_data
|
||||
|
||||
# Process daily data. The order of variables needs to be the same as requested.
|
||||
daily = response['daily']
|
||||
daily_river_discharge = daily['river_discharge']
|
||||
# check if none
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing meteo flood data: {e}")
|
||||
return ERROR_FETCHING_DATA
|
||||
|
||||
# create a flood report
|
||||
flood_report = ""
|
||||
flood_report += "River Discharge: " + str(daily_river_discharge) + "m3/s"
|
||||
|
||||
return flood_report
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user