Compare commits

..

765 Commits
v1.9.8 ... main

Author SHA1 Message Date
Kelly
4dc6befeab Update requirements.txt
cleanup
2026-03-25 15:18:29 -07:00
Kelly
219eea5399 Update joke.py 2026-03-24 12:48:56 -07:00
Kelly
c987c1286e Update mesh_bot.py 2026-03-24 11:05:26 -07:00
Kelly
2ebf721bc9 dopewars fix
end of game
2026-03-22 17:24:35 -07:00
Kelly
bdef9a1f08 Update install_service.sh 2026-03-22 15:39:37 -07:00
Kelly
2da56bc31f Update install_service.sh 2026-03-22 15:37:34 -07:00
Kelly
1e3c3b9ea0 Update install_service.sh 2026-03-22 15:37:05 -07:00
Kelly
d01d7ae668 Update install_service.sh 2026-03-22 14:07:03 -07:00
Kelly
b875eed9fd Update install_service.sh 2026-03-22 13:54:40 -07:00
Kelly
e8cd85700c Create install_service.sh 2026-03-20 19:49:22 -07:00
Kelly
91b02fead4 Update README.md 2026-03-20 18:37:40 -07:00
Kelly
cba6fe3ba2 Update bootstrap.sh 2026-03-17 17:18:50 -07:00
Kelly
021efc8c63 Update bootstrap.sh 2026-03-17 17:02:26 -07:00
Kelly
a4b67072cb Update bootstrap.sh 2026-03-17 16:40:46 -07:00
Kelly
f1e1516919 Update bootstrap.sh 2026-03-17 16:18:46 -07:00
Kelly
e675134d08 Create bootstrap.sh 2026-03-17 16:17:15 -07:00
Kelly
655f2bf7e5 Update requirements.txt 2026-03-17 12:19:21 -07:00
Kelly
46cd2a8051 Update README.md 2026-03-16 21:37:10 -07:00
Kelly
fcc4f24ea5 Merge pull request #300 from SpudGunMan/dependabot/github_actions/docker/login-action-9fe7774c8f8ebfade96f0a62aa10f3882309d517
Bump docker/login-action from db14339dbc0a1f0b184157be94b23a2138122354 to 9fe7774c8f8ebfade96f0a62aa10f3882309d517
2026-03-16 19:49:15 -07:00
Kelly
7ddf29ca06 Update requirements.txt 2026-03-16 19:41:26 -07:00
dependabot[bot]
372bc0c5a7 Bump docker/login-action
Bumps [docker/login-action](https://github.com/docker/login-action) from db14339dbc0a1f0b184157be94b23a2138122354 to 9fe7774c8f8ebfade96f0a62aa10f3882309d517.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](db14339dbc...9fe7774c8f)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 9fe7774c8f8ebfade96f0a62aa10f3882309d517
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-16 09:54:07 +00:00
Kelly
b3bcb62f6c Merge pull request #299 from SpudGunMan/dependabot/github_actions/docker/login-action-db14339dbc0a1f0b184157be94b23a2138122354 2026-03-09 06:11:32 -07:00
dependabot[bot]
6fb33dde10 Bump docker/build-push-action from 6.19.2 to 7.0.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.0.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](10e90e3645...d08e5c354a)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 06:11:14 -07:00
dependabot[bot]
744ca772f2 Bump docker/metadata-action from 5.10.0 to 6.0.0
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.10.0 to 6.0.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](c299e40c65...030e881283)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 06:10:50 -07:00
dependabot[bot]
b5e0653839 Bump docker/login-action
Bumps [docker/login-action](https://github.com/docker/login-action) from 3227f5311cb93ffd14d13e65d8cc400d30f4dd8a to db14339dbc0a1f0b184157be94b23a2138122354.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](3227f5311c...db14339dbc)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: db14339dbc0a1f0b184157be94b23a2138122354
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 10:02:49 +00:00
Kelly
f7462a498e Update dxspot.py 2026-03-07 18:19:54 -08:00
Kelly
30609c822d Update game_serve.py 2026-03-07 17:40:46 -08:00
Kelly
bbfce73aaa Update game_serve.py 2026-03-07 17:26:10 -08:00
Kelly
4f2cd2caef Update game_serve.py
doh
2026-03-07 17:25:09 -08:00
Kelly
294c09754f Update game_serve.py 2026-03-07 17:24:03 -08:00
Kelly
9b69ca69c4 fix link parsing
This fixes an issue with how the bbslink sync process was handling incoming posts. It was not removing the @fromNode from the end of the body, which was causing it to get appended again during the push process. This would compound with more nodes involved in the sync process

alt to https://github.com/SpudGunMan/meshing-around/pull/296

Co-Authored-By: Amy Nagle <1270500+kabili207@users.noreply.github.com>
2026-03-05 21:26:42 -08:00
Kelly
290c366cee Merge pull request #295 from SpudGunMan/dependabot/github_actions/actions/attest-build-provenance-4
Bump actions/attest-build-provenance from 3 to 4
2026-03-05 12:51:20 -08:00
dependabot[bot]
a7f0561f09 Bump actions/attest-build-provenance from 3 to 4
Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 3 to 4.
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest-build-provenance/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/attest-build-provenance
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 10:10:58 +00:00
Kelly
4496f19605 Update locationdata.py 2026-02-27 14:37:32 -08:00
Kelly
6499a6e619 Merge pull request #278 from peanutyost/main
Added Features to Location module
2026-02-27 14:31:02 -08:00
Kelly
fe1444b025 cleanup Help2 2026-02-27 14:28:17 -08:00
Kelly
7bfbae503a clean help 2026-02-27 14:26:54 -08:00
Kelly
7cfb45d2b1 Update locationdata.py
hope to have resolved this https://github.com/SpudGunMan/meshing-around/issues/285
2026-02-27 12:56:22 -08:00
Kelly
0fb351ef4d fix end
thanks for issue https://github.com/SpudGunMan/meshing-around/issues/291
2026-02-26 20:22:53 -08:00
Kelly
2f6abade80 ARRLFixz
https://www.arrl.org/withdrawn-questions
2026-02-26 20:21:31 -08:00
Kelly
5247f8d9d3 Update locationdata.py 2026-02-26 15:20:18 -08:00
Kelly
b36059183c Update locationdata.py 2026-02-26 15:18:49 -08:00
Kelly
f737e401a5 Update locationdata.py 2026-02-26 15:15:10 -08:00
Kelly
98b5f4fb7f Update scheduler.py 2026-02-26 14:43:55 -08:00
Kelly
17fa03ff9d Merge branch 'main' of https://github.com/SpudGunMan/meshing-around 2026-02-26 14:41:32 -08:00
Kelly
40aaa7202c Update scheduler.py 2026-02-26 14:41:02 -08:00
Kelly
5088397856 Merge pull request #292 from SpudGunMan/dependabot/github_actions/docker/build-push-action-10e90e3645eae34f1e60eeb005ba3a3d33f178e8
Bump docker/build-push-action from 8c1e8f8e5bf845ba3773a14f3967965548a2341e to 10e90e3645eae34f1e60eeb005ba3a3d33f178e8
2026-02-26 14:20:07 -08:00
Kelly
db1c31579c Update install.sh 2026-02-26 14:18:38 -08:00
dependabot[bot]
dcf1b8f3cc Bump docker/build-push-action
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 8c1e8f8e5bf845ba3773a14f3967965548a2341e to 10e90e3645eae34f1e60eeb005ba3a3d33f178e8.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](8c1e8f8e5b...10e90e3645)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 10e90e3645eae34f1e60eeb005ba3a3d33f178e8
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-16 10:09:14 +00:00
Kelly
2a7000a2e6 Update hamtest.py 2026-02-14 16:49:59 -08:00
Kelly
aa0aaed0b5 Merge pull request #288 from SpudGunMan/dependabot/github_actions/docker/build-push-action-8c1e8f8e5bf845ba3773a14f3967965548a2341e 2026-02-04 08:27:55 -08:00
Kelly
9db4dc8ab9 Merge pull request #289 from SpudGunMan/dependabot/github_actions/docker/login-action-3227f5311cb93ffd14d13e65d8cc400d30f4dd8a 2026-02-04 08:27:37 -08:00
dependabot[bot]
85e8f41dca Bump docker/login-action
Bumps [docker/login-action](https://github.com/docker/login-action) from 0567fa5ae8c9a197cb207537dc5cbb43ca3d803f to 3227f5311cb93ffd14d13e65d8cc400d30f4dd8a.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](0567fa5ae8...3227f5311c)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 3227f5311cb93ffd14d13e65d8cc400d30f4dd8a
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-02 10:27:43 +00:00
dependabot[bot]
ddb123b759 Bump docker/build-push-action
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 64c9b141502b80dbbd71e008a0130ad330f480f8 to 8c1e8f8e5bf845ba3773a14f3967965548a2341e.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](64c9b14150...8c1e8f8e5b)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 8c1e8f8e5bf845ba3773a14f3967965548a2341e
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-02 10:27:30 +00:00
dependabot[bot]
10afde663e Bump docker/login-action (#286) 2026-01-19 09:09:51 -08:00
dependabot[bot]
c931d13e6e Bump docker/login-action
Bumps [docker/login-action](https://github.com/docker/login-action) from 6862ffc5ab2cdb4405cf318a62a6f4c066e2298b to 916386b00027d425839f8da46d302dab33f5875b.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](6862ffc5ab...916386b000)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 916386b00027d425839f8da46d302dab33f5875b
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-16 16:46:29 -08:00
dependabot[bot]
ba6075b616 Bump docker/build-push-action
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 9e436ba9f2d7bcd1d038c8e55d039d37896ddc5d to 64c9b141502b80dbbd71e008a0130ad330f480f8.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](9e436ba9f2...64c9b14150)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 64c9b141502b80dbbd71e008a0130ad330f480f8
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-16 16:46:09 -08:00
Jacob Morris
68c065825b Adds data persistence loop and shared method to handle all persistence operations 2026-01-16 16:45:46 -08:00
Kelly
213f121807 Update battleship.py 2026-01-13 17:37:26 -08:00
SpudGunMan
530d78482a Update locationdata.py 2026-01-02 16:00:17 -08:00
peanutyost
6c459cd317 added store and return Altitude to location. 2025-12-30 16:47:46 -06:00
peanutyost
626f0dddf7 reverted .gitignore 2025-12-30 08:03:00 -06:00
peanutyost
bb57301b20 reverse some changes that were not needed. 2025-12-30 07:57:46 -06:00
peanutyost
d3adf77896 Cleanup readme 2025-12-30 07:47:37 -06:00
peanutyost
157176acf7 updated the map help command. 2025-12-30 07:42:10 -06:00
peanutyost
4fd35dc004 updated the readme for the map module. 2025-12-30 07:40:23 -06:00
peanutyost
955f7350e9 Refactor location management
- Cleaned up .gitignore to only include necessary entries.
- Updated config.template to include new settings for location management.
- Added SQLite database initialization and management functions in locationdata.py for saving, retrieving, and deleting locations.
- Enhanced map command handling to support saving and listing locations, including public/private visibility controls.
2025-12-29 22:40:15 -06:00
SpudGunMan
09515b9bc0 Update tictactoe.py 2025-12-27 19:40:05 -08:00
SpudGunMan
9b8c9d80c8 Update hamtest.py 2025-12-27 19:40:02 -08:00
SpudGunMan
8ee838f5c6 Update hangman.py 2025-12-27 19:39:34 -08:00
SpudGunMan
757d6d30b8 fix 2025-12-27 19:38:06 -08:00
SpudGunMan
1ee785d388 fix 2025-12-27 19:32:58 -08:00
SpudGunMan
c3284f0a0f fix Turn Counter 2025-12-27 16:21:17 -08:00
SpudGunMan
bdcc479360 enhance
fix turn count at 0
2025-12-27 15:39:27 -08:00
SpudGunMan
b1444b24e4 Update mmind.py 2025-12-27 15:26:21 -08:00
SpudGunMan
aef67da492 alertDuration
customize the check for API alerts
2025-12-27 15:06:56 -08:00
SpudGunMan
b8b8145447 enhance
@SnyderMesh Thanks for Idea
2025-12-26 15:33:11 -08:00
dependabot[bot]
42a4842a5b Bump docker/login-action
Bumps [docker/login-action](https://github.com/docker/login-action) from 28fdb31ff34708d19615a74d67103ddc2ea9725c to 6862ffc5ab2cdb4405cf318a62a6f4c066e2298b.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](28fdb31ff3...6862ffc5ab)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 6862ffc5ab2cdb4405cf318a62a6f4c066e2298b
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-22 11:35:11 -08:00
SpudGunMan
201591d469 fix dopewar replay bug
reference https://github.com/SpudGunMan/meshing-around/issues/274 thanks MJTheis

Co-Authored-By: MJTheis <232630404+mjtheis@users.noreply.github.com>
2025-12-21 18:27:26 -08:00
dependabot[bot]
4ecdc7b108 Bump docker/metadata-action (#273) 2025-12-09 09:16:24 -08:00
Kelly
3f78bf7a67 Merge pull request #272 from SpudGunMan/dependabot/github_actions/actions/checkout-6 2025-12-09 09:15:48 -08:00
dependabot[bot]
8af21b760c Bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-24 11:21:13 +00:00
SpudGunMan
ea3ed46e86 Update locationdata.py 2025-11-18 13:08:52 -08:00
SpudGunMan
d78d6acd1e Update locationdata.py 2025-11-17 14:33:55 -08:00
SpudGunMan
e9b483f4e8 Update locationdata.py 2025-11-16 19:30:30 -08:00
SpudGunMan
94660e7993 Update space.py 2025-11-15 06:27:17 -08:00
SpudGunMan
12aeaef250 fix hop doh hops! 2025-11-12 23:01:19 -08:00
Kelly
2a6f76ab5b Merge pull request #268 from SpudGunMan/lab
Lab
2025-11-12 21:58:31 -08:00
SpudGunMan
05df1e1a3c logsoff 2025-11-12 21:58:00 -08:00
SpudGunMan
38131b4180 cleanup 2025-11-12 21:53:27 -08:00
SpudGunMan
397c39b13d relay node 2025-11-12 21:33:36 -08:00
SpudGunMan
af7dfe8a51 Update README.md 2025-11-12 19:53:20 -08:00
SpudGunMan
d5d163aab9 logs on 2025-11-12 19:48:12 -08:00
Kelly
58cc3e4314 Merge pull request #267 from SpudGunMan/lab
Lab
2025-11-12 19:47:11 -08:00
SpudGunMan
3274dfdbc0 logs off 2025-11-12 19:45:27 -08:00
SpudGunMan
84a1a163d3 Update system.py 2025-11-12 19:34:46 -08:00
SpudGunMan
289eb70738 cleanup 2025-11-12 19:17:49 -08:00
SpudGunMan
a6d51e41bf clean 2025-11-12 19:08:41 -08:00
SpudGunMan
a63020bbb7 space
space
2025-11-12 19:02:46 -08:00
SpudGunMan
2416e73fbf hop refactor for new proto 2025-11-12 17:27:09 -08:00
SpudGunMan
f87f34f8bf Update icad_tone.py 2025-11-12 17:16:39 -08:00
SpudGunMan
eaed034d20 Revert "Update icad_tone.py"
This reverts commit 14b876b989.
2025-11-12 17:13:03 -08:00
SpudGunMan
ec9ac1b1fe Revert "Update icad_tone.py"
This reverts commit c79f3cdfbc.
2025-11-12 17:12:59 -08:00
SpudGunMan
e84ce13878 Revert "Update icad_tone.py"
This reverts commit c31947194e.
2025-11-12 17:12:49 -08:00
SpudGunMan
a5fc8aca82 fix hops 2025-11-12 17:11:14 -08:00
SpudGunMan
c31947194e Update icad_tone.py 2025-11-12 16:21:20 -08:00
SpudGunMan
c79f3cdfbc Update icad_tone.py 2025-11-12 16:18:26 -08:00
SpudGunMan
14b876b989 Update icad_tone.py 2025-11-12 16:09:31 -08:00
SpudGunMan
2cc5b23753 Update icad_tone.py 2025-11-12 15:48:19 -08:00
SpudGunMan
a5b0fda3ac Update icad_tone.py 2025-11-12 13:08:30 -08:00
SpudGunMan
9c5c332e01 Update icad_tone.py 2025-11-12 12:27:45 -08:00
SpudGunMan
ac5e96e463 Update icad_tone.py 2025-11-12 12:26:33 -08:00
SpudGunMan
0ce7deb740 Update icad_tone.py 2025-11-12 12:23:04 -08:00
SpudGunMan
a60333318b Update icad_tone.py 2025-11-12 12:19:38 -08:00
SpudGunMan
665acaa904 Update icad_tone.py 2025-11-12 12:10:18 -08:00
SpudGunMan
0aa8bccd04 Update README.md 2025-11-12 12:07:33 -08:00
SpudGunMan
2e5e8a7589 Update README.md 2025-11-12 11:52:48 -08:00
SpudGunMan
e3e6393bad icad_tone_alerts
icad_tone_alerts
2025-11-12 11:50:40 -08:00
SpudGunMan
be38588292 Update system.py 2025-11-12 10:17:32 -08:00
SpudGunMan
14fb3f9cb6 lab logging 2025-11-11 23:58:12 -08:00
Kelly
c40cd86592 Merge pull request #265 from SpudGunMan/lab
Lab
2025-11-11 23:56:01 -08:00
SpudGunMan
69df48957e clear Logs 2025-11-11 23:54:17 -08:00
SpudGunMan
e29573ebc0 Update system.py 2025-11-11 22:24:52 -08:00
SpudGunMan
13b9b75f86 Update system.py 2025-11-11 22:24:44 -08:00
SpudGunMan
0bfe908391 Update system.py 2025-11-11 22:21:14 -08:00
SpudGunMan
5baee422c2 Update system.py 2025-11-11 22:18:22 -08:00
SpudGunMan
38ff05fd40 logs 2025-11-11 21:22:28 -08:00
SpudGunMan
e1def5422a cleanup 2025-11-11 21:13:41 -08:00
SpudGunMan
93031010cb enhance output data for solar report 2025-11-11 21:05:58 -08:00
SpudGunMan
21e614ab8e enhance solar with NOAA radio weather 2025-11-11 19:39:04 -08:00
SpudGunMan
a5322867e3 Update pong_bot.py 2025-11-11 16:58:19 -08:00
SpudGunMan
2863a64ec8 Update mesh_bot.py 2025-11-11 16:57:30 -08:00
SpudGunMan
678fde7b2c logs 2025-11-11 16:45:29 -08:00
Kelly
ec0f9f966c Merge pull request #264 from SpudGunMan/lab
Lab
2025-11-11 16:44:28 -08:00
SpudGunMan
fd114301f6 logs 2025-11-11 16:43:52 -08:00
SpudGunMan
1778cb6feb Update system.py 2025-11-11 16:38:13 -08:00
SpudGunMan
fc7ca37184 Update system.py 2025-11-11 16:35:16 -08:00
SpudGunMan
fe2110ca2b Update system.py 2025-11-11 16:30:56 -08:00
SpudGunMan
179113e83a Update system.py 2025-11-11 14:46:51 -08:00
SpudGunMan
79348be644 debug 2025-11-11 14:12:55 -08:00
SpudGunMan
35c6232b0c enhance 2025-11-11 13:40:48 -08:00
SpudGunMan
2aa7ffb0e8 packet debugging 2025-11-11 13:35:16 -08:00
SpudGunMan
a7060bc516 cleanup 2025-11-11 13:23:55 -08:00
SpudGunMan
998d979d71 batter channel assign 2025-11-11 13:13:06 -08:00
Kelly
cdfb451d67 Merge pull request #262 from SpudGunMan/dependabot/github_actions/docker/metadata-action-8d8c7c12f7b958582a5cb82ba16d5903cb27976a
Bump docker/metadata-action from 032a4b3bda1b716928481836ac5bfe36e1feaad6 to 8d8c7c12f7b958582a5cb82ba16d5903cb27976a
2025-11-11 13:05:16 -08:00
Kelly
994405955a Merge pull request #263 from SpudGunMan/lab
Lab
2025-11-11 13:03:39 -08:00
SpudGunMan
17d92dc78d Update system.py 2025-11-11 13:02:03 -08:00
SpudGunMan
d0e33f943f logs 2025-11-11 12:53:24 -08:00
dependabot[bot]
f55c7311fa Bump docker/metadata-action
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 032a4b3bda1b716928481836ac5bfe36e1feaad6 to 8d8c7c12f7b958582a5cb82ba16d5903cb27976a.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](032a4b3bda...8d8c7c12f7)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: 8d8c7c12f7b958582a5cb82ba16d5903cb27976a
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 12:24:13 +00:00
SpudGunMan
5f4f832af6 Update install.sh 2025-11-09 17:00:26 -08:00
SpudGunMan
c3e8f4a93e fix install embedded 2025-11-09 16:59:52 -08:00
SpudGunMan
e72b3c191e Update system.py 2025-11-09 16:43:22 -08:00
SpudGunMan
3774b8407b refactor with new API 2025-11-09 16:29:36 -08:00
SpudGunMan
0e074a6885 Update custom_scheduler.template 2025-11-09 15:54:00 -08:00
SpudGunMan
c8800d837f Update config.template 2025-11-09 15:51:52 -08:00
SpudGunMan
289ada1fc0 refactor 2025-11-09 15:45:09 -08:00
SpudGunMan
e64e60358d enhance 2025-11-09 15:33:32 -08:00
SpudGunMan
658fb33b69 enhance log 2025-11-09 15:29:52 -08:00
SpudGunMan
1568d026f2 flood packet work
debug on
2025-11-09 15:23:26 -08:00
SpudGunMan
232bf98efd bug highfly
Highfly: error: unsupported operand type(s) for -: 'str' and 'int'
2025-11-09 15:17:02 -08:00
SpudGunMan
3cce938334 ChannelAwarness 2025-11-09 15:00:21 -08:00
SpudGunMan
e6a17d9258 patch DM for welcome and LLM 2025-11-09 13:19:36 -08:00
SpudGunMan
d403e4c8c0 Update game_serve.py 2025-11-09 13:18:36 -08:00
SpudGunMan
ae19c5b83f Update battleship_vid.py 2025-11-09 13:18:17 -08:00
SpudGunMan
532efda9e8 Update config.template 2025-11-08 19:48:23 -08:00
SpudGunMan
d20eab03e9 Update README.md 2025-11-08 18:52:08 -08:00
SpudGunMan
df43b61a0c Update install.sh 2025-11-08 12:13:15 -08:00
SpudGunMan
862347cbec refactor leader fly and speed 2025-11-08 11:37:01 -08:00
SpudGunMan
9c412b8328 leaderboard airspeed 2025-11-08 11:24:00 -08:00
SpudGunMan
c3fcacd64b Update locationdata.py 2025-11-08 11:08:16 -08:00
SpudGunMan
68b5de2950 one is a lonely number 2025-11-08 11:04:16 -08:00
SpudGunMan
f578ba6084 Update yolo_vision.py 2025-11-07 12:44:56 -08:00
SpudGunMan
961bb3abba Tesseract 2025-11-07 12:34:17 -08:00
SpudGunMan
1f85fe7842 Update config.template 2025-11-06 22:52:32 -08:00
SpudGunMan
6808ef2e68 fix 2025-11-06 22:16:51 -08:00
SpudGunMan
5efac1d8b6 Update README.md 2025-11-06 22:02:47 -08:00
SpudGunMan
12b2fe789d ssl 2025-11-06 21:37:21 -08:00
SpudGunMan
8f48442f60 Update yolo_vision.py 2025-11-06 21:35:40 -08:00
SpudGunMan
180e9368e9 "" 2025-11-06 21:35:32 -08:00
SpudGunMan
2c7a753cb5 Update install.sh 2025-11-06 21:34:57 -08:00
SpudGunMan
c5ef0b4145 OMG 2025-11-06 21:03:11 -08:00
SpudGunMan
ffecd2a44f doh 2025-11-06 20:54:07 -08:00
SpudGunMan
d1d5d6ba30 Update set-permissions.sh 2025-11-06 20:41:50 -08:00
SpudGunMan
2fe1196a90 Update set-permissions.sh 2025-11-06 20:38:09 -08:00
SpudGunMan
5c72dd7aa5 Update set-permissions.sh 2025-11-06 20:35:33 -08:00
SpudGunMan
2834ac3d0d ffs 2025-11-06 20:33:04 -08:00
SpudGunMan
55c29c36ba patching 2025-11-06 20:28:13 -08:00
SpudGunMan
0cc4bbf3cd service account rework 2025-11-06 20:14:39 -08:00
SpudGunMan
b795268d99 whoami fix 2025-11-06 20:04:35 -08:00
SpudGunMan
e60593a3d9 Update yolo_vision.py 2025-11-06 17:42:19 -08:00
SpudGunMan
8b2449eded enhance 2025-11-06 17:34:16 -08:00
SpudGunMan
0c7e8b99a9 cv2 2025-11-06 16:50:14 -08:00
SpudGunMan
3931848bd9 Update install.sh 2025-11-06 15:41:56 -08:00
SpudGunMan
134ec9f7df Update update.sh 2025-11-06 15:30:46 -08:00
SpudGunMan
b8937c6abe Update update.sh 2025-11-06 15:30:20 -08:00
Kelly
0e4f0ee83a Merge pull request #257 from jschollenberger/update-executable
make update.sh executable
2025-11-06 15:20:14 -08:00
SpudGunMan
04560b0589 doc 2025-11-06 15:18:46 -08:00
SpudGunMan
78c0ab6bb6 YOLOv5 Object Detection
example for alert.txt
2025-11-06 15:08:25 -08:00
SpudGunMan
2d4f81e662 Revert "Update locationdata.py"
This reverts commit ec9fbc9bd1.
2025-11-06 13:34:39 -08:00
SpudGunMan
c6f9bc4a90 fix data/ 2025-11-06 13:05:46 -08:00
SpudGunMan
409ae34f93 only service if sevice 2025-11-06 13:01:23 -08:00
SpudGunMan
ec9fbc9bd1 Update locationdata.py 2025-11-06 12:16:06 -08:00
SpudGunMan
51602a7fbd Update greetings.yml 2025-11-06 07:00:54 -08:00
SpudGunMan
a49106500d DM example 2025-11-06 06:53:15 -08:00
Jason Schollenberger
ded62343fd make update.sh executable 2025-11-06 09:45:07 -05:00
SpudGunMan
d096433ab7 service refactor
https://github.com/SpudGunMan/meshing-around/issues/256
2025-11-06 06:44:11 -08:00
SpudGunMan
3273e57f0b Update README.md 2025-11-05 18:48:52 -08:00
SpudGunMan
7cc70dd555 Update locationdata.py 2025-11-05 13:25:43 -08:00
SpudGunMan
edb3208e2c maintains same as before only special changed 2025-11-05 13:21:22 -08:00
SpudGunMan
60a6244c69 Update locationdata.py 2025-11-05 13:18:50 -08:00
SpudGunMan
f06a27957f enhance EAS with more data 2025-11-05 13:08:29 -08:00
SpudGunMan
384f5a62f3 badmaths 2025-11-05 10:50:47 -08:00
SpudGunMan
dcaf9d7fb5 Enhance Battleship
Summary
This PR introduces several enhancements to the Battleship game module, focusing on player engagement, game feedback, and strategic depth. The changes include:

Ping Command:
Players can now use the hidden "p" command during their turn to scan a 3x3 area around their last move (or the board center if no moves have been made). The ping provides a hint about the presence of unsunk ships in the area, returning messages like "something lurking nearby" or "targets in the area!". To add suspense and realism, there is a 30% chance the ping will be disrupted, returning "Ping disrupted! No reading. Try again later." This feature does not consume a turn or affect game state, acting as a strategic hint tool.

Game End Statistics:
At the end of each game, players are now informed of the total number of shots fired and the elapsed time to victory. This provides a sense of accomplishment and encourages replayability as players try to improve their efficiency.

Ammo Commentary:
Every 25 and 50 shots, the game displays a humorous comment (e.g., "🥔25 fired!", "🥵50 rounds!") to keep players entertained and aware of their shot count.

How Gameplay Is Improved
Strategic Depth:
The ping feature gives players a way to gather information and adjust their tactics, making gameplay more interactive and less reliant on random guessing.

Player Engagement:
Humorous ammo comments and end-of-game statistics add personality and feedback, making the experience more memorable and fun.

Replay Value:
By surfacing stats like shots fired and time taken, players are motivated to replay and beat their previous records.

Fairness and Clarity:
The ping command does not advance the turn or affect the game state, ensuring fairness and preventing accidental misuse.

Testing & Compatibility
All new features are integrated with both AI and P2P modes.
Existing gameplay logic and win conditions remain unchanged.
The ping command is robust against edge cases (e.g., no moves made, board edges).
In summary:
This PR makes Battleship more engaging, strategic, and fun, while preserving the integrity of the original game mechanics.

Co-Authored-By: NillRudd <102033730+nillrudd@users.noreply.github.com>
2025-11-04 11:10:38 -08:00
SpudGunMan
99faf72408 Update battleship.py 2025-11-04 04:40:34 -08:00
SpudGunMan
a5a7e19ddc Update battleship.py 2025-11-04 04:23:08 -08:00
SpudGunMan
912617dc34 Update battleship.py 2025-11-04 04:21:54 -08:00
SpudGunMan
ca6d0cce4e enhance 2025-11-04 04:15:16 -08:00
SpudGunMan
43051076ba Update battleship.py 2025-11-03 22:28:42 -08:00
SpudGunMan
83091e6100 Update battleship.py 2025-11-03 22:27:54 -08:00
SpudGunMan
6b512db552 New Game!! 2025-11-03 22:10:48 -08:00
SpudGunMan
09b684fad8 🎯🚢
battleship game
2025-11-03 22:07:38 -08:00
SpudGunMan
1122d6007e verse command
the other BBS have  Fortune it seems to be a popular thing to do. here is a verse command hidden enable like 🐝 to return bible verses better? @joshbowyer
2025-11-03 15:29:09 -08:00
SpudGunMan
f51cace2c3 Update .gitignore 2025-11-03 14:12:13 -08:00
SpudGunMan
78cefd3704 news template 2025-11-03 14:07:37 -08:00
SpudGunMan
421efd7521 Update .gitignore 2025-11-03 14:07:21 -08:00
SpudGunMan
e64f6317ab Update update.sh 2025-11-03 14:03:01 -08:00
SpudGunMan
18a6c9dfac copy data directory lost 2025-11-03 14:01:22 -08:00
SpudGunMan
a96d57580a file overhaul
fixed filereader enhanced newsread fixed bee
2025-11-03 13:58:57 -08:00
SpudGunMan
1388771cc1 compression default
display/game server is now launched with launcher and will eventually be rolled into something?
2025-11-02 22:53:50 -08:00
SpudGunMan
2cbfdb0b78 launch game display 2025-11-02 22:38:49 -08:00
SpudGunMan
38bef50e12 Update game_serve.py 2025-11-02 22:01:13 -08:00
SpudGunMan
24090ce19f fullscreen.ini 2025-11-02 21:26:05 -08:00
SpudGunMan
14ea1e3d97 enhance
game.ini to custom values and doc
2025-11-02 21:14:21 -08:00
SpudGunMan
fb7bf1975b Update tictactoe_vid.py 2025-11-02 21:04:38 -08:00
SpudGunMan
1e7887d480 Update tictactoe.py 2025-11-02 20:49:03 -08:00
SpudGunMan
398a4c6c63 Update README.md 2025-11-02 20:39:16 -08:00
SpudGunMan
9ab6b3be89 Update game_serve.py 2025-11-02 20:34:44 -08:00
SpudGunMan
255be455b7 Update game_serve.py 2025-11-02 20:34:24 -08:00
SpudGunMan
95a35520c2 Update game_serve.py 2025-11-02 20:34:01 -08:00
SpudGunMan
22ec62a2f2 importHelp 2025-11-02 20:32:49 -08:00
SpudGunMan
3f95f1d533 3DTTT 2025-11-02 20:27:15 -08:00
SpudGunMan
4e04ebee76 Update tictactoe_vid.py 2025-11-02 20:21:42 -08:00
SpudGunMan
91fb93ca8d Update README.md 2025-11-02 20:19:43 -08:00
SpudGunMan
f690f16771 tic-tac-toe 3d
thanks again @martinbogo
2025-11-02 20:07:55 -08:00
SpudGunMan
2805240abc APIthrottle->autoBan 2025-11-01 13:16:15 -07:00
SpudGunMan
7a5b7e64d7 Update system.py 2025-11-01 13:13:25 -07:00
SpudGunMan
0fd881aa4b cleanup 2025-11-01 13:12:14 -07:00
SpudGunMan
932112abb2 enhance 2025-11-01 13:09:15 -07:00
SpudGunMan
f3d1fd0ec5 API Throttle
see last PR
2025-11-01 13:01:21 -07:00
SpudGunMan
e92b1a2876 api throttle
only imposed on `latest` command here also cleaned up imports for rss.py

apiThrottleValue = 20/autoBanTimeframe
2025-11-01 12:54:28 -07:00
SpudGunMan
11d3c1eaf4 Update README.md 2025-11-01 12:19:46 -07:00
SpudGunMan
0361153592 Update README.md 2025-11-01 11:26:11 -07:00
SpudGunMan
0c6fcf10ef Update README.md 2025-11-01 09:39:08 -07:00
SpudGunMan
647ae92649 cleanup 2025-11-01 00:49:33 -07:00
SpudGunMan
254eef4be9 fix No Data 2025-10-31 20:30:38 -07:00
SpudGunMan
bd0a94e2a1 refactor ✈️
add better altitude detector
2025-10-31 20:06:56 -07:00
SpudGunMan
2d8256d9f7 Update test_bot.py 2025-10-31 17:25:20 -07:00
SpudGunMan
1f9b81865e Update test_bot.py 2025-10-31 17:15:30 -07:00
SpudGunMan
17221cf37f enhance auto-block
with string protectors
2025-10-31 16:19:13 -07:00
SpudGunMan
47dd75bfb3 autoBlock, enhance ban list and such
https://github.com/SpudGunMan/meshing-around/issues/252

# Enable or disable automatic banning of nodes
autoBanEnabled = False
2025-10-31 16:02:14 -07:00
SpudGunMan
d4773705ce echo welcome! 2025-10-31 13:24:44 -07:00
SpudGunMan
4f46e659d9 TOC updated 2025-10-31 12:41:52 -07:00
SpudGunMan
404f84f39c echo motd 2025-10-31 12:39:19 -07:00
SpudGunMan
c07ec534a7 Update mesh_bot.py 2025-10-31 12:33:38 -07:00
SpudGunMan
4d88aed0d8 🐬
enhance echo with admin functions
2025-10-31 12:04:52 -07:00
SpudGunMan
b1946608f4 FIX👀
how the heck did I miss this!
2025-10-31 11:42:33 -07:00
SpudGunMan
b92cf48fd0 fix update errors
removing this logic for now
2025-10-31 07:24:07 -07:00
SpudGunMan
227ffc94e6 Update update.sh 2025-10-31 07:22:58 -07:00
SpudGunMan
b9f5a0c7f9 refactor
https://github.com/SpudGunMan/meshing-around/issues/249
2025-10-31 07:04:21 -07:00
SpudGunMan
d56c1380c3 fix overload
floods node otherwise
2025-10-30 23:03:28 -07:00
SpudGunMan
e8a8eefcc2 leaderboard enhancment
dont count messages to bot
2025-10-30 22:47:28 -07:00
SpudGunMan
5738e8d306 fix 2025-10-30 20:14:29 -07:00
SpudGunMan
11359e4016 cleanup 2025-10-30 20:09:16 -07:00
SpudGunMan
7bb31af1d2 Update mesh_bot.py 2025-10-30 19:48:53 -07:00
SpudGunMan
fd115916f5 cleanup 2025-10-30 18:50:54 -07:00
SpudGunMan
32b60297c8 Update README.md 2025-10-30 17:00:01 -07:00
SpudGunMan
f15a871967 fix alerting 2025-10-30 16:59:55 -07:00
SpudGunMan
a346354dbc add reporting service back in
let me know if errors
2025-10-30 13:00:44 -07:00
SpudGunMan
3d8007bbf6 docs 2025-10-30 10:32:35 -07:00
SpudGunMan
bb254474d0 fix config.ini
ownership issues my fault for not having this done a long time ago
2025-10-30 10:23:46 -07:00
SpudGunMan
37e3790ee4 Update update.sh 2025-10-30 09:03:19 -07:00
SpudGunMan
0ec380931a Update README.md 2025-10-30 07:42:46 -07:00
SpudGunMan
9cfd1bc670 Update README.md 2025-10-30 07:39:12 -07:00
SpudGunMan
a672c94303 Update README.md 2025-10-30 07:38:32 -07:00
SpudGunMan
92b3574c22 news sort by 2025-10-30 07:24:54 -07:00
SpudGunMan
27d8e198ae Update rss.py 2025-10-30 05:52:52 -07:00
SpudGunMan
11eeaa445a Update rss.py 2025-10-30 05:51:20 -07:00
SpudGunMan
57efc8a69b more
🐄🫑
2025-10-30 00:16:49 -07:00
SpudGunMan
7442ce11b4 Update rss.py 2025-10-29 23:58:47 -07:00
SpudGunMan
8bb6ba4d8e Update rss.py 2025-10-29 23:48:24 -07:00
SpudGunMan
da10af8d93 Update rss.py 2025-10-29 23:37:18 -07:00
SpudGunMan
46a33178f6 Update rss.py 2025-10-29 23:35:12 -07:00
SpudGunMan
e07c5a923e headline
headline command which uses NewsAPI.org
2025-10-29 23:28:14 -07:00
SpudGunMan
d330f3e0d6 patchAlerts 2025-10-29 21:51:57 -07:00
SpudGunMan
eddb2fe08c patch alerting 2025-10-29 21:49:14 -07:00
SpudGunMan
ebe729cf13 leaderboardFix 2025-10-29 21:36:05 -07:00
SpudGunMan
41a45c6e9c Update README.md 2025-10-29 21:22:15 -07:00
SpudGunMan
4224579f79 Update checklist.md 2025-10-29 21:16:32 -07:00
SpudGunMan
aa43d4acad auto Approve
approval is needed to alarm
2025-10-29 21:12:54 -07:00
SpudGunMan
4406f2b86f it SPEAKS
KittenML/KittenTTS
2025-10-29 20:52:14 -07:00
SpudGunMan
649c959304 Update radio.py 2025-10-29 19:28:41 -07:00
SpudGunMan
3529e40743 Update radio.py 2025-10-29 19:05:00 -07:00
SpudGunMan
f5c2dfa5e4 cleanup 2025-10-29 12:19:09 -07:00
SpudGunMan
1fb144ae1e docs 2025-10-29 11:43:33 -07:00
SpudGunMan
7e66ffc3a0 docs 2025-10-29 11:42:48 -07:00
SpudGunMan
d7371fae98 change approve 2025-10-29 11:39:47 -07:00
SpudGunMan
e4c51c97a1 Update checklist.py 2025-10-29 11:37:19 -07:00
SpudGunMan
70f072d222 default is nonApproved 2025-10-29 11:29:33 -07:00
SpudGunMan
8bb587cc7a Update checklist.py 2025-10-29 11:22:32 -07:00
SpudGunMan
313c313412 cleanup admin checklist 2025-10-29 11:15:02 -07:00
SpudGunMan
e5e8fbd0b5 Update checklist.py 2025-10-29 10:55:44 -07:00
SpudGunMan
2ef96f3ae3 Update checklist.py 2025-10-29 10:52:30 -07:00
SpudGunMan
a58605aba3 Update system.py 2025-10-29 10:47:29 -07:00
SpudGunMan
ffdd3a1ea9 enhance 2025-10-29 10:44:42 -07:00
SpudGunMan
185de28139 Update system.py 2025-10-29 10:30:26 -07:00
SpudGunMan
0eea36fba2 Update system.py 2025-10-29 10:16:20 -07:00
SpudGunMan
cb9e62894d Update system.py 2025-10-29 10:11:11 -07:00
SpudGunMan
9443d5fb0a Update system.py 2025-10-29 10:06:53 -07:00
SpudGunMan
1751648b12 Update system.py 2025-10-29 10:03:26 -07:00
SpudGunMan
8823d415c3 Update mesh_bot.py 2025-10-29 09:58:44 -07:00
SpudGunMan
55a1d951a7 Update mesh_bot.py 2025-10-29 09:57:42 -07:00
SpudGunMan
c8096107a0 Update mesh_bot.py 2025-10-29 09:55:56 -07:00
SpudGunMan
5bdf1a9d6c Update mesh_bot.py 2025-10-29 09:54:56 -07:00
SpudGunMan
85344db27e Update settings.py 2025-10-29 09:50:51 -07:00
SpudGunMan
5990a859d9 Update settings.py 2025-10-29 09:49:23 -07:00
SpudGunMan
ad6a55b9cd Update checklist.py 2025-10-29 09:47:50 -07:00
SpudGunMan
6fcd981eae Update mesh_bot.py 2025-10-29 09:47:11 -07:00
SpudGunMan
9564c92cc8 Update checklist.py 2025-10-29 09:46:08 -07:00
SpudGunMan
149dc10df6 Create test_checklist.py 2025-10-29 09:44:27 -07:00
SpudGunMan
e211efca4e Update mesh_bot.py 2025-10-29 09:35:37 -07:00
SpudGunMan
a974de790b refactor Alerts 2025-10-29 09:31:21 -07:00
SpudGunMan
777c423f17 refactor 2025-10-29 09:30:59 -07:00
SpudGunMan
dbcb93eabb refactor Alerts 🚨 2025-10-29 08:29:20 -07:00
SpudGunMan
69518ea317 enhance 2025-10-29 08:21:09 -07:00
SpudGunMan
11faea2b4e purge 2025-10-29 00:48:59 -07:00
SpudGunMan
acb0e870d6 cleanup 2025-10-29 00:15:46 -07:00
SpudGunMan
17cce3b98b Update custom_scheduler.template 2025-10-28 20:23:24 -07:00
SpudGunMan
ed768b48fe Update custom_scheduler.template 2025-10-28 20:22:25 -07:00
SpudGunMan
cb8dc50424 Update install.sh 2025-10-28 20:21:29 -07:00
SpudGunMan
17cde0ca36 Update config.template 2025-10-28 20:11:20 -07:00
SpudGunMan
206b72ec4f init 2025-10-28 19:50:11 -07:00
Kelly
eadc843e27 Merge pull request #247 from SpudGunMan/copilot/enhancement-basic-scheduler
Add scheduler support for news, RSS, marine weather, system info, tide, and solar
refactored some other logic around scheduler and also the update and installer
2025-10-28 19:44:54 -07:00
SpudGunMan
14709e2828 Update scheduler.py 2025-10-28 19:43:42 -07:00
SpudGunMan
4a5d877a3d Update scheduler.py 2025-10-28 19:43:24 -07:00
SpudGunMan
0159c90708 install patch 2025-10-28 19:29:12 -07:00
SpudGunMan
05648f23f2 Update update.sh 2025-10-28 19:06:37 -07:00
SpudGunMan
f27fbdf3c9 Update scheduler.py 2025-10-28 19:01:15 -07:00
SpudGunMan
998c4078bc 🐑 2025-10-28 18:58:20 -07:00
SpudGunMan
666ae24d2c sunday 2025-10-28 18:42:29 -07:00
SpudGunMan
41e7c1207a Update scheduler.py 2025-10-28 18:00:37 -07:00
SpudGunMan
41c6de4183 Update inventory.md 2025-10-28 17:47:54 -07:00
SpudGunMan
af83ba636f not gonna
promise anything
2025-10-28 17:43:45 -07:00
copilot-swe-agent[bot]
8b54c52e7f Update config.template with new scheduler options documentation
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-29 00:41:59 +00:00
copilot-swe-agent[bot]
240dd4b46f Update documentation for new scheduler options
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-29 00:40:19 +00:00
copilot-swe-agent[bot]
7505c9ec22 Add basic scheduler support for news, readrss, mwx, sysinfo, tide, and sun
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-29 00:37:53 +00:00
SpudGunMan
14c22c8156 Create fakeNode.py 2025-10-28 17:31:56 -07:00
copilot-swe-agent[bot]
88dcce2b23 Initial plan 2025-10-29 00:31:30 +00:00
Kelly
5bc842c7e8 Merge pull request #243 from SpudGunMan/copilot/enhance-check-in-check-out
Add inventory/POS system and enhance check-in/check-out with safety monitoring
2025-10-28 17:22:42 -07:00
SpudGunMan
f73bef5894 refactor 2025-10-28 17:21:46 -07:00
SpudGunMan
9371e96feb refactor 2025-10-28 17:21:31 -07:00
SpudGunMan
85345ca45f Update db_admin.py 2025-10-28 17:21:10 -07:00
SpudGunMan
823554f689 rename template 2025-10-28 17:02:12 -07:00
SpudGunMan
5426202d51 Update system.py 2025-10-28 16:02:18 -07:00
SpudGunMan
685e0762bc Update README.md 2025-10-28 15:33:20 -07:00
SpudGunMan
8bc81cee00 docs 2025-10-28 14:02:18 -07:00
SpudGunMan
82f55c6a32 refactor
added loan items
2025-10-28 13:57:56 -07:00
SpudGunMan
be885aa00c Update inventory.md 2025-10-28 13:49:53 -07:00
SpudGunMan
536fd4deea Update checklist.py 2025-10-28 13:49:46 -07:00
SpudGunMan
eb25e55c97 Update inventory.py 2025-10-28 13:46:14 -07:00
SpudGunMan
b7f25c7c5c Update inventory.md 2025-10-28 13:43:06 -07:00
SpudGunMan
c1f1bc5eb9 docs 2025-10-28 13:40:50 -07:00
SpudGunMan
a9c00e92c7 Update checklist.py 2025-10-28 13:26:50 -07:00
SpudGunMan
713e3102f3 Update inventory.py 2025-10-28 13:22:06 -07:00
SpudGunMan
25136d1dd6 Update checklist.py 2025-10-28 13:22:02 -07:00
SpudGunMan
3795ae17ea Update mesh_bot.py 2025-10-28 13:04:23 -07:00
SpudGunMan
aef62bfbc3 archive 2025-10-28 12:55:14 -07:00
Kelly
cbb4bf0a3c Merge pull request #246 from SpudGunMan/copilot/support-js8call-integration
Add WSJT-X and JS8Call integration for forwarding digital mode messages to mesh network. Not fully tested Please test and let me know what needs changed
2025-10-28 12:49:00 -07:00
SpudGunMan
22ebc2bdbe refactor 2025-10-28 12:47:33 -07:00
SpudGunMan
517c6cbf82 Update config.template 2025-10-28 12:37:04 -07:00
copilot-swe-agent[bot]
2b0d7267b5 Optimize callsign matching performance
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-28 19:14:01 +00:00
copilot-swe-agent[bot]
ee4f910d6e Improve callsign matching to prevent false positives
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-28 19:11:58 +00:00
copilot-swe-agent[bot]
49c88306a0 Add tests and fix import issues for WSJT-X/JS8Call
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-28 19:08:15 +00:00
copilot-swe-agent[bot]
0f918ebccd Add documentation for WSJT-X and JS8Call integration
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-28 19:05:36 +00:00
copilot-swe-agent[bot]
69fac4ba98 Add WSJT-X and JS8Call integration support
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-28 19:02:48 +00:00
copilot-swe-agent[bot]
80745bec50 Initial plan 2025-10-28 18:54:26 +00:00
SpudGunMan
5afb1df41a Update llm.md 2025-10-28 11:52:08 -07:00
SpudGunMan
fbb7971cb0 Update llm.md 2025-10-28 11:36:46 -07:00
SpudGunMan
23c2d701df Update locationdata.py 2025-10-28 11:14:41 -07:00
SpudGunMan
2f1c305b06 tallestNode
enhancement to leaderboard thanks glocktuber
2025-10-28 11:05:48 -07:00
SpudGunMan
978fa19b56 refactor leaderboard load()
allow upgrades
2025-10-28 10:57:43 -07:00
SpudGunMan
b5de21a073 Update llm.md 2025-10-28 10:43:54 -07:00
SpudGunMan
f225c21c7a Update custom_scheduler.py 2025-10-28 06:16:32 -07:00
SpudGunMan
23ebb715c9 Update custom_scheduler.py 2025-10-28 06:13:20 -07:00
SpudGunMan
af0645f761 Update README.md 2025-10-28 06:02:53 -07:00
SpudGunMan
113750869f Update README.md 2025-10-28 05:48:34 -07:00
copilot-swe-agent[bot]
c2a18e9f9e Fix documentation clarity on penny rounding and overdue alerts
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-28 05:52:30 +00:00
copilot-swe-agent[bot]
fcaab86e71 Add comprehensive documentation for inventory and enhanced checklist
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-28 05:49:11 +00:00
copilot-swe-agent[bot]
47c84d91f1 Integrate inventory and enhanced checklist into mesh_bot
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-28 05:46:05 +00:00
copilot-swe-agent[bot]
8372817733 Add inventory/POS system and enhance checklist with time intervals
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-28 05:42:24 +00:00
copilot-swe-agent[bot]
9683d8b79e Initial plan 2025-10-28 05:35:13 +00:00
SpudGunMan
6f16fc6afb docs 2025-10-27 22:30:43 -07:00
SpudGunMan
fd971d8cc5 Update README.md
ffs
2025-10-27 22:23:05 -07:00
SpudGunMan
96193a22e8 LLM docs 2025-10-27 22:22:06 -07:00
SpudGunMan
02b0cde1c8 Update llm.py 2025-10-27 22:00:52 -07:00
SpudGunMan
40f4de02d9 Update system.py 2025-10-27 21:59:57 -07:00
SpudGunMan
0b1d626f09 refactor 2025-10-27 21:52:59 -07:00
SpudGunMan
964883cae9 Update system.py 2025-10-27 21:52:02 -07:00
SpudGunMan
6ab1102d07 Update wiki.py 2025-10-27 21:30:00 -07:00
SpudGunMan
c8d8880806 Update wiki.py 2025-10-27 21:25:12 -07:00
Kelly
21c2f7df18 Merge pull request #236 from SpudGunMan/copilot/link-llm-to-wiki-module
Add RAG support to LLM module with Wikipedia/Kiwix and OpenWebUI integration
2025-10-27 20:45:58 -07:00
SpudGunMan
cb51cf921b Update llm.py 2025-10-27 20:43:22 -07:00
SpudGunMan
908e84e155 Update README.md 2025-10-27 20:32:14 -07:00
SpudGunMan
b9eaf7deb0 Update wiki.py 2025-10-27 20:32:09 -07:00
SpudGunMan
128ac456eb Update wiki.py 2025-10-27 20:15:22 -07:00
SpudGunMan
1269214264 Update llm.py 2025-10-27 20:15:15 -07:00
SpudGunMan
4daf087fa5 Update llm.py 2025-10-27 20:03:14 -07:00
SpudGunMan
9282c63206 Update llm.md 2025-10-27 20:00:50 -07:00
SpudGunMan
710342447f Update llm.py 2025-10-27 19:26:53 -07:00
SpudGunMan
8e2c3a43fb refactor2 2025-10-27 18:50:58 -07:00
SpudGunMan
8d82823ccc refactor1 2025-10-27 17:31:47 -07:00
SpudGunMan
27789d7508 patch 2025-10-27 17:23:23 -07:00
SpudGunMan
680ba98a1c bumping version
thanks dependabot
2025-10-27 04:38:47 -07:00
SpudGunMan
4d71a64971 Update mesh_bot.py 2025-10-26 22:17:01 -07:00
SpudGunMan
d608754b5e dedupe 2025-10-26 21:51:02 -07:00
copilot-swe-agent[bot]
70ab741746 Update README with RAG and OpenWebUI documentation
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-27 03:35:36 +00:00
copilot-swe-agent[bot]
b0cf5914bf Add RAG support with Wikipedia/Kiwix and OpenWebUI integration
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-27 03:32:42 +00:00
copilot-swe-agent[bot]
434fbc3eef Initial plan 2025-10-27 03:26:02 +00:00
SpudGunMan
1186801d7e Update globalalert.py 2025-10-26 20:06:01 -07:00
SpudGunMan
902d764ca0 Update custom_scheduler.py 2025-10-26 19:41:48 -07:00
SpudGunMan
00fd29e679 Update custom_scheduler.py 2025-10-26 19:40:42 -07:00
SpudGunMan
163920b399 Update custom_scheduler.py 2025-10-26 19:36:44 -07:00
SpudGunMan
850ee2d291 Update wiki.py 2025-10-26 13:52:06 -07:00
SpudGunMan
cefbe93178 Update bbstools.md 2025-10-26 12:13:24 -07:00
SpudGunMan
44b2837ba0 fix typo for link command 2025-10-26 12:11:03 -07:00
SpudGunMan
1aa6a7a41a Update dxspot.py 2025-10-26 11:53:39 -07:00
SpudGunMan
7abd1fd704 Update dxspot.py
ffs
2025-10-26 11:51:31 -07:00
SpudGunMan
d35832caa8 Update dxspot.py 2025-10-26 11:49:56 -07:00
SpudGunMan
b4b0f2c561 Update dxspot.py 2025-10-26 11:40:50 -07:00
SpudGunMan
1e8ff95769 Update dxspot.py 2025-10-26 11:37:31 -07:00
SpudGunMan
41093be614 Update bbstools.md 2025-10-26 11:28:55 -07:00
SpudGunMan
6fe874e192 Update bbstools.md 2025-10-26 11:28:12 -07:00
SpudGunMan
3fa5d96073 Update bbstools.md 2025-10-26 11:25:03 -07:00
SpudGunMan
242c1c8741 Update README.md 2025-10-26 11:13:51 -07:00
SpudGunMan
c679cee66c dox 2025-10-26 11:06:48 -07:00
SpudGunMan
ca896c0f35 enhance 2025-10-26 10:19:52 -07:00
SpudGunMan
e3cd727cc3 enhance
filters
2025-10-26 10:11:22 -07:00
SpudGunMan
ded8470677 Update dxspot.py 2025-10-26 09:56:29 -07:00
SpudGunMan
f0b63b8b20 Update simulator.py 2025-10-26 09:54:25 -07:00
SpudGunMan
8a9c7a1147 changes to dx spotter
by is now of and xota is now ota
2025-10-26 09:42:02 -07:00
SpudGunMan
2d0e6b54b3 Update update.sh 2025-10-26 09:29:44 -07:00
SpudGunMan
0745847d3a Update update.sh 2025-10-26 09:28:54 -07:00
SpudGunMan
6c49c5c87f Update update.sh
https://github.com/SpudGunMan/meshing-around/issues/234
2025-10-26 09:24:14 -07:00
SpudGunMan
719fa95c1c Update README.md
thanks for this its been fun to help make this project for the community
2025-10-26 09:09:04 -07:00
SpudGunMan
b642961d26 Update README.md 2025-10-26 08:57:57 -07:00
SpudGunMan
f59d97f6ad Update mesh_bot.py 2025-10-26 08:48:29 -07:00
SpudGunMan
92d5f01ce5 allow days x for wx 2025-10-26 08:40:03 -07:00
SpudGunMan
39e53eb599 Update mesh_bot.py 2025-10-26 08:37:57 -07:00
SpudGunMan
9f6165503e Update locationdata.py 2025-10-26 08:28:41 -07:00
SpudGunMan
ec27ab65da Update custom_scheduler.py 2025-10-26 07:35:59 -07:00
SpudGunMan
f34eefb75a Update custom_scheduler.py 2025-10-26 07:34:21 -07:00
SpudGunMan
ce2ccb1455 Update scheduler.py 2025-10-26 07:31:16 -07:00
SpudGunMan
da144a2b89 scheduler enhancment
this brings scheduler into the 19th century
2025-10-26 07:26:54 -07:00
SpudGunMan
bbdccb382a Update dxspot.py 2025-10-26 06:02:07 -07:00
SpudGunMan
95f75b8e0a ... 2025-10-26 05:57:55 -07:00
SpudGunMan
0bf4915cd5 Update update.sh
https://github.com/SpudGunMan/meshing-around/issues/234
2025-10-26 05:51:59 -07:00
SpudGunMan
f83793acc9 Update README.md 2025-10-26 05:43:10 -07:00
SpudGunMan
abb2fa6b61 Update README.md 2025-10-26 05:42:51 -07:00
SpudGunMan
6d90d6f207 dx command 2025-10-26 05:37:04 -07:00
SpudGunMan
9c9e9a02e6 Update globalalert.py 2025-10-25 21:01:47 -07:00
SpudGunMan
80fc795f35 enhance 2025-10-25 18:50:12 -07:00
SpudGunMan
166c49854f Update config.template 2025-10-25 18:37:44 -07:00
SpudGunMan
a685fc3a9b Update config.template 2025-10-25 18:37:22 -07:00
SpudGunMan
91da1a4c58 Update update.sh 2025-10-25 18:34:06 -07:00
SpudGunMan
9889fd0da8 logs 2025-10-25 18:29:45 -07:00
Kelly
bebd9352ea Merge pull request #231 from SpudGunMan/copilot/add-mesh-bot-timer
Add systemd timer for daily mesh_bot_w3.service execution at 4:20 AM
2025-10-25 17:30:15 -07:00
SpudGunMan
fd1cd2a44c patch 2025-10-25 17:28:16 -07:00
SpudGunMan
ac55a51c87 # messages
https://github.com/SpudGunMan/meshing-around/issues/233
2025-10-25 16:01:54 -07:00
SpudGunMan
86144cd888 Update scheduler.py 2025-10-25 13:12:55 -07:00
SpudGunMan
d7a37ce9f1 haha 2025-10-25 12:59:59 -07:00
SpudGunMan
da7035dfed Update joke.py 2025-10-25 12:28:01 -07:00
SpudGunMan
da500981a2 Update mesh_bot.py 2025-10-25 12:23:48 -07:00
SpudGunMan
b4dc2207a6 refactor scheduler 2025-10-25 12:22:02 -07:00
SpudGunMan
b69a187466 Update mesh_bot.py 2025-10-25 12:10:57 -07:00
SpudGunMan
66d143d68e fix 2025-10-25 11:37:45 -07:00
SpudGunMan
49f2dcff88 Update test_bot.py 2025-10-25 10:59:46 -07:00
SpudGunMan
2c3c3fed10 Update INSTALL.md 2025-10-25 08:45:07 -07:00
SpudGunMan
2872fb040e refactor 2025-10-25 08:35:16 -07:00
copilot-swe-agent[bot]
6097ff899c Add explicit Unit directive to mesh_bot_w3.timer
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-25 14:02:49 +00:00
copilot-swe-agent[bot]
aec75d598a Add systemd timer to run mesh_bot_w3.service daily at 4:20 am
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-25 13:59:45 +00:00
copilot-swe-agent[bot]
0640fdbbae Initial plan 2025-10-25 13:55:40 +00:00
SpudGunMan
8312f4e683 Update system.py 2025-10-24 23:07:14 -07:00
SpudGunMan
990ea4f4e4 leaderboard fix messages 2025-10-24 21:57:16 -07:00
SpudGunMan
9acf9df3bb fix messages win 2025-10-24 21:47:32 -07:00
SpudGunMan
37942e950e fixLeaderboardLoading 2025-10-24 21:45:29 -07:00
SpudGunMan
8a0e1cba7c Update bbstools.py 2025-10-24 21:25:58 -07:00
SpudGunMan
8d0a53ec3e Update system.py 2025-10-24 21:24:18 -07:00
SpudGunMan
2ea3917eba Alllllllllll the rssssss'ssss
srsly I hope this time
2025-10-24 21:20:16 -07:00
SpudGunMan
75410c98e3 sweep 2025-10-24 20:40:23 -07:00
SpudGunMan
10171a712e moar cleanup 🧹
tighter memory control
2025-10-24 20:32:30 -07:00
SpudGunMan
fa76a76203 BIG OLD PATCH 🍠
pz days ... haha. I hope this works.
fancy potato
2025-10-24 19:54:46 -07:00
SpudGunMan
e0e275a49c Revert "scheduler memory issue"
This reverts commit bf39c2f088.
2025-10-24 18:30:08 -07:00
SpudGunMan
bf39c2f088 scheduler memory issue 2025-10-24 18:19:35 -07:00
SpudGunMan
34d36057c1 ATOM FEEDS
oh yea its 2003
2025-10-24 18:05:08 -07:00
SpudGunMan
4e1d1de883 Update adding_more.md 2025-10-24 17:52:24 -07:00
SpudGunMan
97f103dfd7 Update test_bot.py 2025-10-24 17:46:15 -07:00
SpudGunMan
47089871b1 Update test_bot.py 2025-10-24 17:45:16 -07:00
SpudGunMan
cc7ef129f6 Update test_bot.py 2025-10-24 17:42:50 -07:00
SpudGunMan
0fa5d06a3a Update test_bot.py 2025-10-24 17:12:06 -07:00
SpudGunMan
7fc44ec06e Update README.md 2025-10-24 17:12:00 -07:00
SpudGunMan
184760096e game test unit
🧩
2025-10-24 17:01:43 -07:00
SpudGunMan
8868d10388 Update hangman.py 2025-10-24 16:53:43 -07:00
SpudGunMan
1ce2ecd75c Update README.md 2025-10-24 16:50:04 -07:00
SpudGunMan
69e1c21488 enhance hangman.json
example JSON: [\"apple\",\"banana\",\"cherry\"]
2025-10-24 16:46:57 -07:00
SpudGunMan
97a2ffce7b gamepackFix
clean up globals
2025-10-24 15:46:11 -07:00
SpudGunMan
4c0d3a597e Update test_bot.py 2025-10-24 14:58:32 -07:00
SpudGunMan
094f7e61a0 Update wiki.py
fixed
2025-10-24 13:22:36 -07:00
SpudGunMan
a54ecaa5a1 Update mesh_bot.py 2025-10-24 13:21:49 -07:00
SpudGunMan
bd12392d69 Update system.py
doh
2025-10-24 13:05:40 -07:00
SpudGunMan
882bcf3f4b wiki wiki 2025-10-24 13:02:13 -07:00
SpudGunMan
c0d0ca3743 Update compose.yaml 2025-10-24 12:59:29 -07:00
SpudGunMan
d74d848646 Update compose.yaml 2025-10-24 12:58:16 -07:00
SpudGunMan
2afb915b56 Update test_bot.py 2025-10-24 12:50:58 -07:00
SpudGunMan
d5e48bead1 Update compose.yaml 2025-10-24 12:46:27 -07:00
SpudGunMan
3c80848f61 refactor wikipedia
also removed that old package!!!
2025-10-24 12:45:24 -07:00
SpudGunMan
64345fe47a Update wiki.py 2025-10-24 12:30:18 -07:00
SpudGunMan
32f734d69b Update wiki.py 2025-10-24 12:27:30 -07:00
SpudGunMan
aa6de00c5b Update wiki.py 2025-10-24 12:26:24 -07:00
SpudGunMan
6df4ba5756 Update test_bot.py
risky stuff lower
2025-10-24 12:13:50 -07:00
SpudGunMan
a11a2780db Update bbstools.py 2025-10-24 11:55:39 -07:00
SpudGunMan
980414f872 Update test_bot.py 2025-10-24 11:23:04 -07:00
SpudGunMan
f26334d625 Update wiki.py 2025-10-24 11:22:52 -07:00
SpudGunMan
24546b28d6 Create test_bot.py 2025-10-24 10:33:19 -07:00
SpudGunMan
f33da848cd cleanup 2025-10-24 10:32:28 -07:00
SpudGunMan
57ce15de4e Update radio.py 2025-10-24 10:19:05 -07:00
SpudGunMan
b8886e0662 Update qrz.py 2025-10-24 10:16:41 -07:00
SpudGunMan
9a1e86f25e Update qrz.py 2025-10-24 10:13:33 -07:00
SpudGunMan
fa8021ab5a Update checklist.py 2025-10-24 10:06:11 -07:00
SpudGunMan
f3917f1c3d Update locationdata.py 2025-10-24 10:00:35 -07:00
SpudGunMan
c1443048fd Update llm.py 2025-10-24 09:39:57 -07:00
SpudGunMan
da430557f3 Update filemon.py 2025-10-24 09:36:07 -07:00
SpudGunMan
84152bda65 Update checklist.py 2025-10-24 09:35:53 -07:00
SpudGunMan
b6e80ae576 Update bbstools.py 2025-10-24 09:18:31 -07:00
SpudGunMan
18ac26864c better resolution for gametracker
thanks pdx
2025-10-24 08:24:05 -07:00
SpudGunMan
b661fbc750 Revert "fix init of trackers"
This reverts commit 3049d18663.
2025-10-24 08:20:36 -07:00
SpudGunMan
3049d18663 fix init of trackers
thanks @pdxlocations
2025-10-24 08:12:24 -07:00
SpudGunMan
126f81fbd3 Update README.md 2025-10-23 23:38:26 -07:00
SpudGunMan
337d43a7af Update README.md 2025-10-23 23:32:53 -07:00
SpudGunMan
8c3121d5d6 Update entrypoint.sh 2025-10-23 23:29:26 -07:00
SpudGunMan
1d577c9ec5 Update compose.yaml 2025-10-23 23:12:28 -07:00
SpudGunMan
3540b8f110 Update entrypoint.sh 2025-10-23 23:10:15 -07:00
SpudGunMan
3fdebf3bf9 Update entrypoint.sh 2025-10-23 23:07:54 -07:00
SpudGunMan
430279809e Update entrypoint.sh 2025-10-23 23:06:02 -07:00
SpudGunMan
7ba3a78718 Update entrypoint.sh 2025-10-23 23:04:25 -07:00
SpudGunMan
c329391450 Update entrypoint.sh 2025-10-23 23:03:55 -07:00
SpudGunMan
6bc3c3e980 Update entrypoint.sh 2025-10-23 23:00:36 -07:00
SpudGunMan
48788ceda8 Update entrypoint.sh 2025-10-23 22:57:50 -07:00
SpudGunMan
8f5bae3b05 Update README.md 2025-10-23 22:55:18 -07:00
SpudGunMan
17c693c2f7 Update entrypoint.sh 2025-10-23 22:55:16 -07:00
SpudGunMan
ff91356c2a Update compose.yaml 2025-10-23 22:45:54 -07:00
SpudGunMan
180d9f4728 Update compose.yaml 2025-10-23 22:43:27 -07:00
SpudGunMan
1202a076d1 Update compose.yaml 2025-10-23 22:42:55 -07:00
SpudGunMan
9b62d7f4d8 Update compose.yaml 2025-10-23 22:42:00 -07:00
SpudGunMan
9451d23c09 Update compose.yaml 2025-10-23 22:40:48 -07:00
SpudGunMan
dcdef40e89 Update Dockerfile 2025-10-23 22:28:25 -07:00
SpudGunMan
817dde42f2 Update Dockerfile 2025-10-23 22:23:39 -07:00
SpudGunMan
b384d2d5b1 Update entrypoint.sh 2025-10-23 22:18:56 -07:00
SpudGunMan
4db46f16f2 Update compose.yaml 2025-10-23 22:15:12 -07:00
SpudGunMan
5590391f7e Update compose.yaml
i get no security!
2025-10-23 22:11:22 -07:00
SpudGunMan
ccb505f37f confounded 2025-10-23 22:09:34 -07:00
SpudGunMan
d883927572 enhance 2025-10-23 21:57:50 -07:00
SpudGunMan
b0109be3b0 Update README.md 2025-10-23 21:52:13 -07:00
SpudGunMan
98af757d93 enhance 2025-10-23 21:50:54 -07:00
SpudGunMan
f8746ff348 Create null 2025-10-23 21:46:59 -07:00
SpudGunMan
32fbfba3e9 config 2025-10-23 21:45:23 -07:00
SpudGunMan
d2501bf353 Update compose.yaml 2025-10-23 21:38:19 -07:00
SpudGunMan
db9d7d9790 Update README.md 2025-10-23 21:34:56 -07:00
SpudGunMan
c6b5a1c708 docker
i never enjoy docker
2025-10-23 21:26:32 -07:00
SpudGunMan
a36f1580b3 Update entrypoint.sh 2025-10-23 21:24:59 -07:00
SpudGunMan
f051e95986 Update entrypoint.sh 2025-10-23 21:24:45 -07:00
SpudGunMan
bafcfad190 Update entrypoint.sh 2025-10-23 21:21:43 -07:00
SpudGunMan
8b2059c444 Update compose.yaml 2025-10-23 21:07:43 -07:00
SpudGunMan
fd4b5607d7 Update compose.yaml 2025-10-23 21:02:42 -07:00
SpudGunMan
df30ee9cc4 Update compose.yaml 2025-10-23 20:53:26 -07:00
SpudGunMan
c1135ecadf cleanup 2025-10-23 20:40:00 -07:00
SpudGunMan
899702eecc Update compose.yaml 2025-10-23 20:26:55 -07:00
SpudGunMan
d4604d8cbd Update compose.yaml 2025-10-23 20:23:03 -07:00
SpudGunMan
c674b0a404 Update scheduler.py 2025-10-23 20:18:42 -07:00
SpudGunMan
d59ddfd517 fix for Malice
sorry this was so painfull
2025-10-23 20:15:15 -07:00
SpudGunMan
f68c533488 errorLoggin 2025-10-23 20:08:42 -07:00
SpudGunMan
6e47d71028 Update Dockerfile 2025-10-23 20:00:02 -07:00
SpudGunMan
f9af9b756d Update compose.yaml
how did I miss this
2025-10-23 19:57:18 -07:00
SpudGunMan
c19d442190 Update compose.yaml 2025-10-23 19:53:01 -07:00
SpudGunMan
4c2d0cdebb Update compose.yaml 2025-10-23 19:49:01 -07:00
SpudGunMan
612dbf01d3 Update compose.yaml 2025-10-23 19:47:40 -07:00
SpudGunMan
28846b24a6 Update compose.yaml 2025-10-23 19:40:43 -07:00
SpudGunMan
cd398375a2 Update compose.yaml 2025-10-23 19:33:40 -07:00
SpudGunMan
01372a0f2c Update compose.yaml 2025-10-23 19:30:08 -07:00
SpudGunMan
8254ec5baf Update compose.yaml 2025-10-23 19:23:25 -07:00
SpudGunMan
549e12ffc1 Update Dockerfile 2025-10-23 18:50:47 -07:00
SpudGunMan
d940cdf534 Update Dockerfile 2025-10-23 15:57:34 -07:00
SpudGunMan
54837884a7 Update locationdata.py 2025-10-23 15:48:08 -07:00
Kelly
91501d42db Merge pull request #158 from pcs3rd/create-docker-image
Create docker image and push to packages on new release
2025-10-23 15:44:57 -07:00
SpudGunMan
e0bcc31204 Update README.md 2025-10-23 15:37:43 -07:00
SpudGunMan
1cb56aa1b7 set bbslink via config.ini 2025-10-23 15:32:10 -07:00
SpudGunMan
8cf2db3b49 Update custom_scheduler.py 2025-10-23 15:22:05 -07:00
SpudGunMan
755fc4fac3 Update joke.py 2025-10-23 14:51:14 -07:00
SpudGunMan
7c341ed0e7 cleanup 2025-10-23 14:50:18 -07:00
SpudGunMan
c87db75f2f cleanupPartOne
I hope I didnt break a lot of things!
2025-10-23 13:46:36 -07:00
SpudGunMan
13875b7cf8 Update pong_bot.py 2025-10-23 13:33:17 -07:00
SpudGunMan
fd4925ee92 Update mesh_bot.py 2025-10-23 13:18:31 -07:00
SpudGunMan
eccc48ff3f whats all this 2025-10-23 13:15:51 -07:00
SpudGunMan
3a6d464398 Update bbstools.md 2025-10-23 12:52:54 -07:00
SpudGunMan
6c7e8558b0 Update bbstools.md 2025-10-23 12:51:01 -07:00
SpudGunMan
74a744c77e Update wiki.py 2025-10-23 12:45:12 -07:00
SpudGunMan
5225998c92 Update rss.py 2025-10-23 12:43:33 -07:00
SpudGunMan
97a0ff3112 enhance 2025-10-23 12:41:37 -07:00
SpudGunMan
1250479219 Update README.md 2025-10-23 12:33:17 -07:00
SpudGunMan
f8bcc4f495 Update README.md 2025-10-23 12:26:34 -07:00
SpudGunMan
5d1608f366 Update README.md 2025-10-23 12:25:16 -07:00
SpudGunMan
a19dc93350 Update locationdata.py 2025-10-23 12:13:25 -07:00
SpudGunMan
30d4e487c9 Update locationdata.py 2025-10-23 12:12:54 -07:00
SpudGunMan
5bf1ade2b0 enhance 2025-10-23 12:10:45 -07:00
SpudGunMan
13cefc2002 Update locationdata.py 2025-10-23 12:08:00 -07:00
SpudGunMan
640bead32c Update locationdata.py 2025-10-23 12:06:45 -07:00
SpudGunMan
49d9b58627 Update mesh_bot.py 2025-10-23 12:02:55 -07:00
SpudGunMan
ad1a8aa1ce Update locationdata.py 2025-10-23 12:01:56 -07:00
SpudGunMan
55567815ef map
to csv
2025-10-23 11:57:55 -07:00
SpudGunMan
346fb38bbd heasder csv 2025-10-23 11:40:24 -07:00
SpudGunMan
104e70c01c Update README.md 2025-10-23 11:33:55 -07:00
SpudGunMan
2111bb46ae bbs doc 2025-10-23 10:27:28 -07:00
Kelly
459dad4c32 Merge branch 'main' into create-docker-image 2025-10-23 09:43:52 -07:00
SpudGunMan
d9febeef0f cleanupX2 2025-10-23 09:39:21 -07:00
SpudGunMan
f8a94fca71 cleanup
yikes this was messy
2025-10-23 09:21:48 -07:00
SpudGunMan
a30b3dc2d2 Update dependabot.yml
https://github.com/SpudGunMan/meshing-around/pull/228
2025-10-23 08:38:09 -07:00
Kelly
b45254795d Merge pull request #228 from SpudGunMan/dependabot/docker/python-3.14-slim
Bump python from 3.13-slim to 3.14-slim
2025-10-23 08:37:41 -07:00
SpudGunMan
4a209e0c17 Update greetings.yml 2025-10-23 08:30:14 -07:00
dependabot[bot]
1aecb42186 Bump python from 3.13-slim to 3.14-slim
Bumps python from 3.13-slim to 3.14-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.14-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-23 15:29:14 +00:00
SpudGunMan
6513e9f177 Update dependabot.yml 2025-10-23 08:28:22 -07:00
SpudGunMan
dd14034f3c Update update.sh 2025-10-23 00:20:45 -07:00
SpudGunMan
8308c2f98c Update README.md 2025-10-22 23:44:02 -07:00
SpudGunMan
5050f1c5eb Update install.sh 2025-10-22 23:43:20 -07:00
SpudGunMan
553137d228 Update install.sh 2025-10-22 23:41:43 -07:00
SpudGunMan
d3e7a4d5e4 Update install.sh 2025-10-22 23:25:11 -07:00
SpudGunMan
2e26819c2f Update install.sh 2025-10-22 22:40:14 -07:00
SpudGunMan
f03b85cebe typoFix
https://github.com/SpudGunMan/meshing-around/issues/227
thanks @mesb1
2025-10-22 22:32:53 -07:00
SpudGunMan
99f25345e8 Update README.md 2025-10-22 22:19:54 -07:00
SpudGunMan
4952bb3ecc documentation 2025-10-22 22:12:22 -07:00
SpudGunMan
f79f714317 enhance 2025-10-22 22:00:53 -07:00
SpudGunMan
a6db9bc878 Update README.md 2025-10-22 21:41:56 -07:00
SpudGunMan
774f76ecf1 Update README.md 2025-10-22 21:33:25 -07:00
SpudGunMan
3038d72996 Update README.md 2025-10-22 21:29:42 -07:00
SpudGunMan
33245e8443 Update README.md 2025-10-22 21:23:08 -07:00
SpudGunMan
54fb30d048 Update README.md 2025-10-22 21:20:27 -07:00
SpudGunMan
86c86d2f97 Update README.md 2025-10-22 21:18:23 -07:00
SpudGunMan
bb46981a85 Update README.md 2025-10-22 21:17:24 -07:00
SpudGunMan
e32cdf803c Update scheduler.py 2025-10-22 21:09:21 -07:00
SpudGunMan
c6e8feefd7 Update scheduler.py 2025-10-22 21:08:55 -07:00
SpudGunMan
1f48e9a4aa Create INSTALL.md 2025-10-22 21:03:55 -07:00
SpudGunMan
571c9c521f documentation 2025-10-22 21:02:12 -07:00
SpudGunMan
52b88ce16b documentation 2025-10-22 20:53:55 -07:00
SpudGunMan
6f38bff473 Update README.md 2025-10-22 20:31:20 -07:00
SpudGunMan
2b964390f8 Update README.md 2025-10-22 20:30:46 -07:00
SpudGunMan
a305acc492 Update README.md 2025-10-22 20:22:42 -07:00
SpudGunMan
cf66556fe6 enhance
fix bingo play
2025-10-22 20:21:17 -07:00
SpudGunMan
76e9bd8677 Update system.py 2025-10-22 18:56:49 -07:00
SpudGunMan
c8c3c0f80b Update wodt.py 2025-10-22 18:54:14 -07:00
SpudGunMan
3a9330d831 Update wodt.py 2025-10-22 18:47:16 -07:00
SpudGunMan
8d3b0ce4bf Update system.py
ahh
2025-10-22 18:46:50 -07:00
SpudGunMan
bb0a22c69b Update system.py 2025-10-22 18:45:45 -07:00
SpudGunMan
df369c3d29 Update system.py 2025-10-22 18:40:19 -07:00
SpudGunMan
db1ca48f0a Update system.py 2025-10-22 18:37:22 -07:00
SpudGunMan
a38d0a6ed7 Update system.py 2025-10-22 18:32:49 -07:00
SpudGunMan
ac8d308f58 Update system.py 2025-10-22 18:30:05 -07:00
SpudGunMan
08b54d9009 Update README.md 2025-10-22 18:25:49 -07:00
SpudGunMan
9da6416433 wordOfTheDay Games
Simple word fun, bingo and customizable word of the day lists via JSON files. Slot Machine for Emoji 🎰 shout out to pee-wee
2025-10-22 18:06:25 -07:00
SpudGunMan
efcfb749dc Update settings.py 2025-10-22 16:44:42 -07:00
SpudGunMan
493d2792d6 Update config.template 2025-10-22 16:44:31 -07:00
SpudGunMan
aa68ce120e Update scheduler.py 2025-10-22 16:17:27 -07:00
SpudGunMan
e3a9a00c92 better custom_ handling
thanks @pdxlocations for the idea
2025-10-22 14:54:06 -07:00
SpudGunMan
f1feb1be0d Update mesh_bot.py 2025-10-22 12:53:30 -07:00
SpudGunMan
290695327d enhance Ping with ping @NodeShortID
you should be able to now ping @ and if its a node ID it will send a BBS mail with a joke in it

@mesb1 thanks for idea
https://github.com/SpudGunMan/meshing-around/issues/224
2025-10-22 12:31:25 -07:00
SpudGunMan
c3ea07fde5 enhance
https://github.com/SpudGunMan/meshing-around/issues/225

https://github.com/SpudGunMan/meshing-around/issues/196

https://github.com/SpudGunMan/meshing-around/issues/224
2025-10-22 11:48:04 -07:00
SpudGunMan
e551f1252a Update injectDM.py 2025-10-22 08:39:24 -07:00
Raymond Dean
57093d09ef Merge branch 'SpudGunMan:main' into create-docker-image 2025-10-22 09:43:27 -04:00
SpudGunMan
a8b2aefa28 Update locationdata.py 2025-10-21 22:31:57 -07:00
SpudGunMan
849565cacb Update joke.py 2025-10-21 21:40:20 -07:00
SpudGunMan
fdec3a6754 Update README.md 2025-10-21 20:56:47 -07:00
SpudGunMan
f3a97bc567 Update README.md 2025-10-21 20:45:40 -07:00
SpudGunMan
02625ad0f2 Update install.sh 2025-10-21 20:18:55 -07:00
SpudGunMan
b4ba4b0daf Update locationdata.py 2025-10-21 18:52:40 -07:00
SpudGunMan
fd7f8a94f5 Update locationdata.py 2025-10-21 18:47:29 -07:00
SpudGunMan
d252250edd last_alert_time 🚀
throttle sending alerts for the same node more than once every 30 minutes
2025-10-21 16:42:36 -07:00
SpudGunMan
d6410e0461 Update joke.py 2025-10-21 15:47:06 -07:00
SpudGunMan
050b4ab3ce Update joke.py 2025-10-21 15:46:48 -07:00
SpudGunMan
8ac1a1eed7 Update joke.py 2025-10-21 15:45:30 -07:00
SpudGunMan
370a417ce6 Update config.template 2025-10-21 15:10:13 -07:00
SpudGunMan
378b05df35 PingRefactor
anyone notice this?
2025-10-21 14:50:06 -07:00
SpudGunMan
d002c5ede8 remove LastHop 2025-10-21 14:26:43 -07:00
SpudGunMan
cd03cc56b4 🐇 2025-10-21 14:24:24 -07:00
SpudGunMan
d4fd484706 sentry_alert.sh
this enhances the sentry to optionally run a shell command you would create in the script/directory which will fire every time the alert fires. sentry_alert_near.sh and sentry_alert_far.sh are the needed files. it will error and remind you it cant find them.
2025-10-21 14:21:09 -07:00
SpudGunMan
82d519279e servicePackAttack
enhance for armbian builds
2025-10-21 13:34:38 -07:00
SpudGunMan
09302e8c91 ntp 2025-10-21 13:17:15 -07:00
SpudGunMan
91fc4605ec enhance 2025-10-21 12:57:10 -07:00
SpudGunMan
abc6c07ee3 Update locationdata.py 2025-10-21 12:52:34 -07:00
SpudGunMan
bbf8b04bd3 Update locationdata.py 2025-10-21 12:45:29 -07:00
SpudGunMan
5fd293c990 only seen with soft nodes
## FIXME needs better like a default interface setting or hash lookup
2025-10-21 12:33:13 -07:00
SpudGunMan
a9a65a6c6d refactor rxInt 2025-10-21 11:00:26 -07:00
SpudGunMan
34e95c86d6 log IP if there 2025-10-21 10:52:28 -07:00
Kelly
80891090c3 Merge pull request #222 from pdxlocations/allow-port-numbers
Missed a Spot to support hostname:port
thanks!
2025-10-21 10:49:00 -07:00
SpudGunMan
1b098fbf7b Update pong_bot.py 2025-10-21 10:47:42 -07:00
SpudGunMan
165d76cf8d add IP 2025-10-21 10:45:44 -07:00
Kelly
045c9d433b Merge pull request #223 from SpudGunMan/copilot/add-pylint-disable-comment 2025-10-21 10:28:51 -07:00
SpudGunMan
fbe5e008de Revert "Update joke.py"
This reverts commit 004adc7d9a.
2025-10-21 10:28:02 -07:00
Kelly
004adc7d9a Update joke.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-21 10:23:12 -07:00
copilot-swe-agent[bot]
9a2033452f Add Pylint disable comment above variable on line 58
Co-authored-by: SpudGunMan <12676665+SpudGunMan@users.noreply.github.com>
2025-10-21 17:08:51 +00:00
copilot-swe-agent[bot]
5638204f82 Initial plan 2025-10-21 17:05:27 +00:00
pdxlocations
e5c3b0cceb refactor 2025-10-21 09:33:01 -07:00
pdxlocations
18ac53b230 Refactor TCP interface handling to support hostname:poert 2025-10-21 09:17:54 -07:00
SpudGunMan
4aa65dad6a Update mesh_bot.py
https://github.com/SpudGunMan/meshing-around/issues/220
2025-10-21 09:03:00 -07:00
SpudGunMan
f65a7b7934 refactorMwx 2025-10-21 06:56:03 -07:00
SpudGunMan
886293087a Update greetings.yml 2025-10-20 23:10:25 -07:00
SpudGunMan
e05e6f3451 Update greetings.yml 2025-10-20 23:07:31 -07:00
SpudGunMan
4b5dd934e9 enhance 2025-10-20 23:07:03 -07:00
Kelly
008ddfb5a2 Merge pull request #221 from SpudGunMan/lab
DependaBot
2025-10-20 23:02:13 -07:00
SpudGunMan
e1330b9b9e DependaBot
DependaBot
2025-10-20 22:56:34 -07:00
SpudGunMan
7b43213094 Update README.md 2025-10-20 21:57:46 -07:00
SpudGunMan
b17c2b17ee Update system.py 2025-10-20 20:20:25 -07:00
SpudGunMan
b6505ee577 Update system.py 2025-10-20 20:19:43 -07:00
SpudGunMan
f7379b7ca5 Update system.py 2025-10-20 20:19:30 -07:00
SpudGunMan
ec9a1d88db Update system.py 2025-10-20 20:15:29 -07:00
SpudGunMan
a339570afe Update system.py 2025-10-20 20:10:08 -07:00
SpudGunMan
f5af9f419a Update system.py 2025-10-20 20:06:39 -07:00
SpudGunMan
ad5c1c90da Update system.py 2025-10-20 19:55:57 -07:00
SpudGunMan
eb1e0c82ea enhance Sentinel
# list of watched nodes numbers
sentryWatchList =
monitors for INSIDE or OUTSIDE the zone
2025-10-20 19:50:06 -07:00
SpudGunMan
8d2277bc59 Update SECURITY.md 2025-10-20 17:46:57 -07:00
Kelly
dc9908a72c Revise security support information and reporting process
Updated the security support table and reporting guidelines.
2025-10-20 17:08:31 -07:00
SpudGunMan
21123d2993 sysinfo from a DM
now lets you see the IP addresses of the bot
2025-10-20 16:33:58 -07:00
SpudGunMan
7dc3134d0b Update system.py 2025-10-20 16:20:27 -07:00
SpudGunMan
b125178492 Update survey.py 2025-10-20 16:16:49 -07:00
SpudGunMan
2ad9e84c33 Update pong_bot.py 2025-10-20 15:55:30 -07:00
SpudGunMan
63bd288caa fixMOTD
theMOTD is how did this happen
2025-10-20 15:40:32 -07:00
SpudGunMan
5c7d199831 surveyReport
run survey report to see return data
2025-10-20 15:34:09 -07:00
SpudGunMan
f56a39eeb6 refactor 2025-10-20 13:07:30 -07:00
SpudGunMan
ae5991ee39 moreTime
to spare
2025-10-20 13:03:33 -07:00
SpudGunMan
1324f83f17 enhance schedule
allow basic Joke and Weather messages with out extra config
# value can also be joke (everyXmin) or weather (hour) for special scheduled messages
# custom for module/scheduler.py custom schedule examples
2025-10-20 12:07:22 -07:00
SpudGunMan
08ae8c31a0 enhance:fix
I added some things to help any future aarg, sorry I broke it again I also
2025-10-20 11:45:49 -07:00
SpudGunMan
957e803951 cleanup 2025-10-20 11:35:26 -07:00
SpudGunMan
2de3441d67 enhance
with time stamp and also better CSV answers for review
2025-10-20 09:54:44 -07:00
SpudGunMan
2b420022f9 timeFix
will be make year 2000
2025-10-20 09:54:25 -07:00
SpudGunMan
d01f143adf Update system.py 2025-10-20 09:24:18 -07:00
SpudGunMan
5f5aeeadac Update system.py 2025-10-20 09:22:45 -07:00
SpudGunMan
d57826613c moved
to slurp repo
2025-10-20 09:19:18 -07:00
SpudGunMan
af09dc0cf9 improveUse 2025-10-19 22:43:51 -07:00
SpudGunMan
011bac41f2 lower 2025-10-19 22:32:06 -07:00
SpudGunMan
20467ea886 enhance 2025-10-19 22:30:40 -07:00
SpudGunMan
bbfd71f011 "IP-Network" 2025-10-19 22:27:40 -07:00
SpudGunMan
e1ff87a197 enhance Ping 2025-10-19 22:26:09 -07:00
SpudGunMan
a859f830bb coreFix
Enhance Packet hop and MQTT detection
2025-10-19 22:16:19 -07:00
SpudGunMan
d99698e7f3 Update blackjack.py 2025-10-19 20:25:36 -07:00
Raymond Dean
29a26d5d14 Merge branch 'SpudGunMan:main' into create-docker-image 2025-08-08 19:29:53 -04:00
Raymond Dean
43e6349351 Update compose example. 2025-07-15 17:42:17 -04:00
Raymond Dean
628f66e4b7 Update compose.yaml to expose port 8420 2025-07-12 14:08:29 -04:00
Raymond Dean
29f97c62d0 add compose example 2025-07-12 11:50:21 -04:00
Raymond Dean
b805e6d428 Update docker-image.yml 2025-07-12 11:19:35 -04:00
Raymond Dean
6d5ded7df6 Create and publish a Docker image on new release 2025-07-12 11:18:05 -04:00
104 changed files with 14507 additions and 3335 deletions

28
.github/dependabot.yml vendored Normal file
View 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
View 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
View 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!"

39
.gitignore vendored
View File

@@ -1,30 +1,31 @@
# config
config.ini
# Pickle files
*.pkl
# virtualenv
venv/
config_new.ini
ini_merge_log.txt
install_notes.txt
# logs
logs/
install_notes.txt
logs/*.log
# modified .service files
etc/*.service
# Python cache
__pycache__/
# rag data
data/rag/*
# qrz db
data/qrz.db
# fileMonitor test file
bee.txt
bible.txt
# .csv files
*.csv
# 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__/

View File

@@ -1,21 +1,43 @@
FROM python:3.13-slim
FROM python:3.14-slim
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y gettext tzdata locales nano && rm -rf /var/lib/apt/lists/*
ENV PYTHONUNBUFFERED=1 \
LANG=en_US.UTF-8 \
LANGUAGE=en_US:en \
LC_ALL=en_US.UTF-8 \
TZ=America/Los_Angeles
# Set the locale default to en_US.UTF-8
RUN sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \
dpkg-reconfigure --frontend=noninteractive locales && \
update-locale LANG=en_US.UTF-8
ENV LANG="en_US.UTF-8"
ENV 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 pip install -r requirements.txt
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
View 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.

660
README.md
View File

@@ -1,100 +1,110 @@
# Mesh Bot for Network Testing and BBS Activities
Welcome to the Mesh Bot project! This feature-rich bot is designed to enhance your [Meshtastic](https://meshtastic.org/docs/introduction/) network experience with a variety of powerful tools and fun features, connectivity and utility through text-based message delivery. Whether you're looking to perform network tests, send messages, or even play games, [mesh_bot.py](mesh_bot.py) has you covered.
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.
* [Getting Started](#getting-started)
![Example Use](etc/pong-bot.jpg "Example Use")
#### TLDR
* [install.sh](INSTALL.md)
* [Configuration Guide](modules/README.md)
* [Games Help](modules/games/README.md)
## Key Features
![CodeQlBadge](https://github.com/SpudGunMan/meshing-around/actions/workflows/dynamic/github-code-scanning/codeql/badge.svg)
### Intelligent Keyword Responder
- **Automated Responses**: The bot detects keywords like "ping" and responds with "pong" in direct messages (DMs) or group channels.
- **Customizable Triggers**: Monitor group channels for specific keywords and set custom responses.
- **Emergency Response**: Monitor channels for keywords indicating emergencies and alert a wider audience.
- **New Node Hello**: Greet new nodes on the mesh with a hello message
- **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.
### Network Tools
- **Build, Test Local Mesh**: Ping allow for message delivery testing with more realistic packets vs. telemetry
- **Test Node Hardware**: `test` will send incremental sized data into the radio buffer for overall length of message testing
- **Network Monitoring**: Alert on noisy nodes, node locations, and best placment for relay nodes.
- **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.
### Multi Radio/Node Support
- **Simultaneous Monitoring**: Monitor up to nine networks at the same time.
- **Flexible Messaging**: send mail and messages, between networks.
- **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.
### Multi-Radio/Node Support
- **Simultaneous Monitoring**: Observe up to nine networks at once.
- **Flexible Messaging**: Send mail and messages between networks.
### Advanced Messaging Capabilities
- **Mail Messaging**: Leave messages for other devices, which are sent as DMs when the device is seen.
- **Scheduler**: Schedule messages like weather updates or reminders for weekly VHF nets.
- **Store and Forward**: Replay messages with the `messages` command, and log messages locally to disk.
- **Send Mail**: Send mail to nodes using `bbspost @nodeNumber #message` or `bbspost @nodeShortName #message`.
- **BBS Linking**: Combine multiple bots to expand BBS reach.
- **E-Mail/SMS**: Send mesh-messages to E-Mail or SMS(Email) expanding visibility.
- **New Node Hello**: Send a hello to any new node seen in text message.
- **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.
### Interactive AI and Data Lookup
- **NOAA/USGS location Data**: Get localized weather(alerts), Earthquake, River Flow, and Tide information. Open-Meteo is used for wx only outside NOAA coverage.
- **Wiki Integration**: Look up data using Wikipedia results.
- **Ollama LLM AI**: Interact with the [Ollama](https://github.com/ollama/ollama/tree/main/docs) LLM AI for advanced queries and responses.
- **Satellite Pass Info**: Get passes for satellite at your location.
- **GeoMeasuring**: HowFar from point to point using collected GPS packets on the bot to plot a course or space. Find Center of points for Fox&Hound direction finding.
- **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.
### Proximity Alerts
- **Location-Based Alerts**: Get notified when members arrive back at a configured lat/long, perfect for remote locations like campsites.
- **High Flying Alerts**: Get notified when nodes with high altitude are seen on mesh
- **Voice/Command Triggers**: The following keywords can be used via voice (VOX) to trigger bot functions "Hey Chirpy!"
- Say "Hey Chirpy.."
- `joke`: Tells a joke
- `weather`: Returns local weather forecast
- `moon`: Returns moonrise/set and phase info
- `daylight`: Returns sunrise/sunset times
- `river`: Returns NOAA river flow info
- `tide`: Returns NOAA tide information
- `satellite`: Returns satellite pass info
### CheckList / Check In Out
- **Asset Tracking**: Maintain a list of node/asset checkin and checkout. Useful foraccountability of people, assets. Radio-Net, FEMA, Trailhead.
### Fun and Games
- **Built-in Games**: Enjoy games like DopeWars, Lemonade Stand, BlackJack, and VideoPoker.
- **FCC ARRL QuizBot**: The exam question pool quiz-bot.
- **Command-Based Gameplay**: Issue `games` to display help and start playing.
- **Telemetry Leaderboard**: Fun stats like lowest 🪫 battery or coldest temp 🥶
#### QuizMaster
- **Interactive Group Quizzes**: The QuizMaster module allows admins to start and stop quiz games for groups. Players can join, leave, and answer questions directly via DM or channel.
- **Scoring and Leaderboards**: Players can check their scores and see the top performers with `q: score` and `q: top`.
- **Easy Participation**: Players answer questions by prefixing their answer with `q:`, e.g., `q: 42`.
#### Survey Module
- **Custom Surveys**: Easily create and deploy custom surveys by editing JSON files in `data/survey`. Multiple surveys can be managed (e.g., `survey snow`).
- **User Feedback Collection**: Users can participate in surveys via DM, and responses are logged for later review.
### Radio Frequency Monitoring
- **SNR RF Activity Alerts**: Monitor a radio frequency and get alerts when high SNR RF activity is detected.
- **Hamlib Integration**: Use Hamlib (rigctld) to watch the S meter on a connected radio.
- **Speech to Text Brodcasting to Mesh** Using [vosk](https://alphacephei.com/vosk/models) to translate to text.
- **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 via API**: Use an internet-connected node to message Emergency Alerts from FEMA
- **NOAA EAS Alerts via API**: Use an internet-connected node to message Emergency Alerts from NOAA.
- **USGS Volcano Alerts via API**: Use an internet-connected node to message Emergency Alerts from USGS.
- **EAS Alerts over the air**: Utilizing external tools to report EAS alerts offline over mesh.
- **NINA alerts for Germany**: Emergency Alerts from xrepository.de feed
- **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 Monitor**: Monitor a flat/text file for changes, broadcast the contents of the message to the mesh channel.
- **News File**: On request of news, the contents of the file are returned. Can also call multiple news sources or files.
- **Shell Command Access**: Pass commands via DM directly to the host OS with replay protection.
- **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 Generator**: Visualize bot traffic and data flows with a built-in HTML generator for [data reporting](logs/README.md).
- **RSS and news feeds**: Get data in mesh from many sources!
- **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
- **Message Chunking**: Automatically chunk messages over 160 characters to ensure higher delivery success across hops.
- **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. For pico or low-powered devices, see projects for embedding, [buildroot](https://github.com/buildroot-meshtastic/buildroot-meshtastic), also see [femtofox](https://github.com/noon92/femtofox) 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.
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
@@ -102,456 +112,31 @@ If you dont have git you will need it `sudo apt-get install git`
```sh
git clone https://github.com/spudgunman/meshing-around
```
- **Automated Installation**: `install.sh` will automate optional venv and requirements installation.
- **Launch Script**: `launch.sh` only used in a venv install, to launch the bot and the report generator.
- **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.
## Full list of commands for the bot
### Docker Installation
Good for windows or OpenWebUI enabled bots
### Networking
| Command | Description | ✅ Works Off-Grid |
|---------|-------------|-
| `ping`, `ack` | Return data for signal. Example: `ping 15 #DrivingI5` (activates auto-ping every 20 seconds for count 15 via DM only) | ✅ |
| `cmd` | Returns the list of commands (the help message) | ✅ |
| `history` | Returns the last commands run by user(s) | ✅ |
| `leaderboard` | Shows extreme mesh metrics like lowest battery 🪫 `leaderboard reset` allows admin reset | ✅ |
| `lheard` | Returns the last 5 heard nodes with SNR. Can also use `sitrep` | ✅ |
| `motd` | Displays the message of the day or sets it. Example: `motd $New Message Of the day` | ✅ |
| `sysinfo` | Returns the bot node telemetry info | ✅ |
| `test` | used to test the limits of data transfer (`test 4` sends data to the maxBuffer limit default 200 charcters) via DM only | ✅ |
| `whereami` | Returns the address of the sender's location if known |
| `whoami` | Returns details of the node asking, also returned when position exchanged 📍 | ✅ |
| `whois` | Returns details known about node, more data with bbsadmin node | ✅ |
| `echo` | Echo string back, disabled by default | ✅ |
| `bannode` | Admin option to prevent a node from using bot. `bannode list` will load and use the data/bbs_ban_list.txt db | ✅ |
[docker.md](script/docker/README.md)
### Radio Propagation & Weather Forecasting
| Command | Description | |
|---------|-------------|-------------------
| `ea` and `ealert` | Return FEMA iPAWS/EAS alerts in USA or DE Headline or expanded details for USA | |
| `earthquake` | Returns the largest and number of USGS events for the location | |
| `hfcond` | Returns a table of HF solar conditions | |
| `rlist` | Returns a table of nearby repeaters from RepeaterBook | |
| `riverflow` | Return information from NOAA for river flow info. | |
| `solar` | Gives an idea of the x-ray flux | |
| `sun` and `moon` | Return info on rise and set local time | ✅ |
| `tide` | Returns the local tides (NOAA data source) | |
| `valert` | Returns USGS Volcano Data | |
| `wx` | Return local weather forecast, NOAA or Open Meteo (which also has `wxc` for metric and imperial) | |
| `wxa` and `wxalert` | Return NOAA alerts. Short title or expanded details | |
| `mwx` | Return the NOAA Coastal Marine Forecast data | |
## Module Help
Configuration Guide
[modules/README.md](modules/README.md)
### Bulletin Board & Mail
| Command | Description | |
|---------|-------------|-
| `bbshelp` | Returns the following help message | ✅ |
| `bbslist` | Lists the messages by ID and subject | ✅ |
| `bbsread` | Reads a message. Example: `bbsread #1` | ✅ |
| `bbspost` | Posts a message to the public board or sends a DM(Mail) Examples: `bbspost $subject #message`, `bbspost @nodeNumber #message`, `bbspost @nodeShortName #message` | ✅ |
| `bbsdelete` | Deletes a message. Example: `bbsdelete #4` | ✅ |
| `bbsinfo` | Provides stats on BBS delivery and messages (sysop) | ✅ |
| `bbslink` | Links Bulletin Messages between BBS Systems | ✅ |
| `email:` | Sends email to address on file for the node or `email: bob@test.net # hello from mesh` | |
| `sms:` | Send sms-email to multiple address on file | |
| `setemail`| Sets the email for easy communications | |
| `setsms` | Adds the SMS-Email for quick communications | |
| `clearsms` | Clears all SMS-Emails on file for node | |
### Game Help
Games are DM only by default
### Data Lookup
| Command | Description | |
|---------|-------------|-
| `askai` and `ask:` | Ask Ollama LLM AI for a response. Example: `askai what temp do I cook chicken` | ✅ |
| `messages` | Replays the last messages heard on device, like Store and Forward, returns the PublicChannel and Current | ✅ |
| `readnews` | returns the contents of a file (data/news.txt, by default) can also `news mesh` via the chunker on air | ✅ |
| `readrss` | returns a set RSS feed on air | |
| `satpass` | returns the pass info from API for defined NORAD ID in config or Example: `satpass 25544,33591`| |
| `wiki:` | Searches Wikipedia (or local Kiwix server) and returns the first few sentences of the first result if a match. Example: `wiki: lora radio` |
| `howfar` | returns the distance you have traveled since your last HowFar. `howfar reset` to start over | ✅ |
| `howtall` | returns height of something you give a shadow by using sun angle | ✅ |
### CheckList
| Command | Description | |
|---------|-------------|-
| `checkin` | Check in the node to the checklist database, you can add a note like `checkin ICO` or `checkin radio4` | ✅ |
| `checkout` | Checkout the node in the checklist database, checkout all from node | ✅ |
| `checklist` | Display the checklist database, with note | ✅ |
### Games (via DM only)
| Command | Description | |
|---------|-------------|-
| `blackjack` | Plays Blackjack (Casino 21) | ✅ |
| `dopewars` | Plays the classic drug trader game | ✅ |
| `golfsim` | Plays a 9-hole Golf Simulator | ✅ |
| `hamtest` | FCC/ARRL Quiz `hamtest general` or `hamtest extra` and `score` | ✅ |
| `hangman` | Plays the classic word guess game | ✅ |
| `joke` | Tells a joke | |
| `lemonstand` | Plays the classic Lemonade Stand finance game | ✅ |
| `mastermind` | Plays the classic code-breaking game | ✅ |
| `survey` | Issues out a survey to the user | ✅ |
| `quiz` | QuizMaster Bot `q: ?` for more | ✅ |
| `tic-tac-toe`| Plays the game classic game | ✅ |
| `videopoker` | Plays basic 5-card hold Video Poker | ✅ |
#### QuizMaster
To use QuizMaster the bbs_admin_list is the QuizMaster, who can `q: start` and `q: stop` to start and stop the game, `q: broadcast <message>` to send a message to all players.
Players can `q: join` to join the game, `q: leave` to leave the game, `q: score` to see their score, and `q: top` to see the top 3 players.
To Answer a question, just type the answer prefixed with `q: <answer>`
#### Survey
To use the Survey feature edit the json files in data/survey multiple surveys are possible such as `survey snow`
## Other Install Options
### Docker Installation - handy for windows
See further info on the [docker.md](script/docker/README.md)
### Manual Install
Install the required dependencies using pip:
```sh
pip install -r requirements.txt
```
Copy the configuration template to `config.ini` and edit it to suit your needs:
```sh
cp config.template config.ini
```
### Configuration Guide
The following is documentation for the config.ini file
If you have not done so, or want to 'factory reset', 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. To get the BLE MAC address, use:
```sh
meshtastic --ble-scan
```
**Note**: The code has been tested with a single BLE device and is written to support only one BLE port.
```ini
# 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/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/ttyUSB0'
# hostname = 192.168.0.1
# mac = 00:11:22:33:44:55
# Additional interface for dual radio support. See config.template for more.
[interface2]
enabled = False
```
### General Settings
The following settings determine how the bot responds. By default, the bot will not spam the default channel. Setting `respond_by_dm_only` to `True` will force all messages to be sent via DM, which may not be desired. Setting it to [`False`] will allow responses in the channel for all to see. If you have no default channel you can set this value to `-1` or any unused channel index. You can also have the bot ignore the defaultChannel for any commands, but still observe the channel.
```ini
[general]
respond_by_dm_only = True
defaultChannel = 0
ignoreDefaultChannel = False # ignoreDefaultChannel, the bot will ignore the default channel set above
ignoreChannels = # ignoreChannels is a comma separated list of channels to ignore, e.g. 4,5
cmdBang = False # require ! to be the first character in a command
explicitCmd = True # require explicit command, the message will only be processed if it starts with a command word disable to get more activity
```
### Location Settings
The weather forecasting defaults to NOAA, for locations outside the USA, you can set `UseMeteoWxAPI` to `True`, to use a global weather API. The `lat` and `lon` are default values when a node has no location data, as well as the default for all NOAA, repeater lookup. It is also the center of radius for Sentry.
```ini
[location]
enabled = True
lat = 48.50
lon = -123.0
# To fuzz the location of the above
fuzzConfigLocation = True
# Fuzz all values in all data
fuzzItAll = False
UseMeteoWxAPI = True
coastalEnabled = False # NOAA Coastal Data Enable NOAA Coastal Waters Forecasts and Tide
# Find the correct coastal weather directory at https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/
# this map can help https://www.weather.gov/marine select location and then look at the 'Forecast-by-Zone Map'
myCoastalZone = https://tgftp.nws.noaa.gov/data/forecasts/marine/coastal/pz/pzz135.txt # myCoastalZone is the .txt file with the forecast data
coastalForecastDays = 3 # number of data points to return, default is 3
```
### Module Settings
Modules can be enabled or disabled as needed. They are essentally larger functions of code which you may not want on your mesh or in memory space.
```ini
[bbs]
enabled = False
[general]
DadJokes = False
StoreForward = False
```
### History
The history command shows the last commands the user ran, and [`lheard`] reflects the last users on the bot.
```ini
enableCmdHistory = True # history command enabler
lheardCmdIgnoreNodes = # command history ignore list ex: 2813308004,4258675309
```
### Sentry Settings
Sentry Bot detects anyone coming close to the bot-node. uses the Location Lat/Lon value.
```ini
SentryEnabled = True # detect anyone close to the bot
emailSentryAlerts = True # if SMTP enabled send alert to sysop email list
SentryRadius = 100 # radius in meters to detect someone close to the bot
SentryChannel = 9 # holdoff time multiplied by seconds(20) of the watchdog
SentryHoldoff = 2 # channel to send a message to when the watchdog is triggered
sentryIgnoreList = # list of ignored nodes numbers ex: 2813308004,4258675309
highFlyingAlert = True # HighFlying Node alert
highFlyingAlertAltitude = 2000 # Altitude in meters to trigger the alert
highflyOpenskynetwork = True # check with OpenSkyNetwork if highfly detected for aircraft
```
### E-Mail / SMS Settings
To enable connectivity with SMTP allows messages from meshtastic into SMTP. The term SMS here is for connection via [carrier email](https://avtech.com/articles/138/list-of-email-to-sms-addresses/)
```ini
[smtp]
# enable or disable the SMTP module, minimum required for outbound notifications
enableSMTP = True # enable or disable the IMAP module for inbound email, not implemented yet
enableImap = False # list of Sysop Emails separate with commas, used only in emergency responder currently
sysopEmails =
# See config.template for all the SMTP settings
SMTP_SERVER = smtp.gmail.com
SMTP_AUTH = True
EMAIL_SUBJECT = Meshtastic✉
```
### Emergency Response Handler
Traps the following ("emergency", "911", "112", "999", "police", "fire", "ambulance", "rescue") keywords. Responds to the user, and calls attention to the text message in logs and via another network or channel.
```ini
[emergencyHandler]
enabled = True # enable or disable the emergency response handler
alert_channel = 2 # channel to send a message to when the emergency handler is triggered
alert_interface = 1
```
### EAS Alerting
To Alert on Mesh with the EAS API you can set the channels and enable, checks every 20min.
#### FEMA iPAWS/EAS and NINA
This uses USA: SAME, FIPS, to locate the alerts in the feed. By default ignoring Test messages.
```ini
eAlertBroadcastEnabled = False # Goverment IPAWS/CAP Alert Broadcast
eAlertBroadcastCh = 2,3 # Goverment Emergency IPAWS/CAP Alert Broadcast Channels
ignoreFEMAenable = True # Ignore any headline that includes followig word list
ignoreFEMAwords = test,exercise
# 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
# To use other country services enable only a single optional serivce
enableDEalerts = False # Use DE Alert Broadcast Data see template for filters
myRegionalKeysDE = 110000000000,120510000000
```
#### NOAA EAS
This uses the defined lat-long of the bot for collecting of data from the API. see [File-Monitoring](#File-Monitoring) for ideas to collect EAS alerts from a RTL-SDR.
```ini
wxAlertBroadcastEnabled = True # EAS Alert Broadcast
wxAlertBroadcastCh = 2,4 # EAS Alert Broadcast Channels
ignoreEASenable = True # Ignore any headline that includes followig word list
ignoreEASwords = test,advisory
```
#### USGS River flow data and Volcano alerts
Using the USGS water data page locate a water flow device, for example Columbia River at Vancouver, WA - USGS-14144700
Volcano Alerts use lat/long to determine ~1000km radius
```ini
[location]
# USGS Hydrology unique identifiers, LID or USGS ID https://waterdata.usgs.gov
riverList = 14144700 # example Mouth of Columbia River
# USGS Volcano alerts Enable USGS Volcano Alert Broadcast
volcanoAlertBroadcastEnabled = False
volcanoAlertBroadcastCh = 2
```
### Repeater Settings
A repeater function for two different nodes and cross-posting messages. The `repeater_channels` is a list of repeater channels 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. Use this feature responsibly to avoid creating a feedback loop.
```ini
[repeater] # repeater module
enabled = True
repeater_channels = [2, 3]
```
### Ollama (LLM/AI) Settings
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.
```ini
# Enable ollama LLM see more at https://ollama.com
ollama = True # Ollama model to use (defaults to gemma2:2b)
ollamaModel = gemma3:latest # Ollama model to use (defaults to gemma3:270m)
ollamaHostName = http://localhost:11434 # server instance to use (defaults to local machine install)
```
Also see `llm.py` for changing the defaults of:
```ini
# LLM System Variables
rawQuery = True # if True, the input is sent raw to the LLM if False, it is processed by the meshBotAI template
# Used in the meshBotAI template (legacy)
llmEnableHistory = True # enable history for the LLM model to use in responses adds to compute time
llmContext_fromGoogle = True # enable context from google search results helps with responses accuracy
googleSearchResults = 3 # number of google search results to include in the context more results = more compute time
```
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.
### Wikipedia Search Settings
The Wikipedia search module can use either the online Wikipedia API or a local Kiwix server for offline wiki access. Kiwix is especially useful for mesh networks operating in remote or offline environments.
```ini
# Enable or disable the wikipedia search module
wikipedia = True
# Use local Kiwix server instead of online Wikipedia
# Set to False to use online Wikipedia (default)
useKiwixServer = False
# Kiwix server URL (only used if useKiwixServer is True)
kiwixURL = http://127.0.0.1:8080
# Kiwix library name (e.g., wikipedia_en_100_nopic_2024-06)
# Find available libraries at https://library.kiwix.org/
kiwixLibraryName = wikipedia_en_100_nopic_2024-06
```
To set up a local Kiwix server:
1. Install Kiwix tools: https://kiwix.org/en/ `sudo apt install kiwix-tools -y`
2. Download a Wikipedia ZIM file to `data/`: https://library.kiwix.org/ `wget https://download.kiwix.org/zim/wikipedia/wikipedia_en_100_nopic_2025-09.zim`
3. Run the server: `kiwix-serve --port 8080 wikipedia_en_100_nopic_2025-09.zim`
4. Set `useKiwixServer = True` in your config.ini
The bot will automatically extract and truncate content to fit Meshtastic's message size limits (~500 characters).
### Radio Monitoring
A module allowing a Hamlib compatible radio to connect to the bot. When functioning, it will message the configured channel with a message of in use. **Requires hamlib/rigctld to be running as a service.**
```ini
[radioMon]
enabled = True
rigControlServerAddress = localhost:4532
sigWatchBroadcastCh = 2 # channel to broadcast to can be 2,3
signalDetectionThreshold = -10 # minimum SNR as reported by radio via hamlib
signalHoldTime = 10 # hold time for high SNR
signalCooldown = 5 # the following are combined to reset the monitor
signalCycleLimit = 5
```
### File Monitoring
Some dev notes for ideas of use
```ini
[fileMon]
filemon_enabled = True
file_path = alert.txt # text file to monitor for changes
broadcastCh = 2 # channel to send the message to can be 2,3 multiple channels comma separated
enable_read_news = False # news command will return the contents of a text file
news_file_path = news.txt
news_random_line = False # only return a single random line from the news file
enable_runShellCmd = False # enable the use of exernal shell commands, this enables some data in `sysinfo`
# if runShellCmd and you think it is safe to allow the x: command to run
# direct shell command handler the x: command in DMs user must be in bbs_admin_list
allowXcmd = True
```
#### Offline EAS
To Monitor EAS with no internet connection see the following notes
- [samedec](https://crates.io/crates/samedec) rust decoder much like multimon-ng
- [sameold](https://crates.io/crates/sameold) rust SAME message translator much like EAS2Text and dsame3
no examples yet for these tools
- [EAS2Text](https://github.com/A-c0rN/EAS2Text)
- depends on [multimon-ng](https://github.com/EliasOenal/multimon-ng), [direwolf](https://github.com/wb2osz/direwolf), [samedec](https://crates.io/crates/samedec) rust decoder much like multimon-ng
- [dsame3](https://github.com/jamieden/dsame3)
- has a sample .ogg file for testing alerts
The following example shell command can pipe the data using [etc/eas_alert_parser.py](etc/eas_alert_parser.py) to alert.txt
```bash
sox -t ogg WXR-RWT.ogg -esigned-integer -b16 -r 22050 -t raw - | multimon-ng -a EAS -v 1 -t raw - | python eas_alert_parser.py
```
The following example shell command will pipe rtl_sdr to alert.txt
```bash
rtl_fm -f 162425000 -s 22050 | multimon-ng -t raw -a EAS /dev/stdin | python eas_alert_parser.py
```
#### Newspaper on mesh
Maintain multiple news sources. Each source should be a file named `{source}_news.txt` in the `data/` directory (for example, `data/mesh_news.txt`).
- To read the default news, use the `readnews` command (reads from `data/news.txt`.
- To read a specific source, use `readnews abc` to read from `data/abc_news.txt`.
This allows you to organize and access different news feeds or categories easily.
External scripts can update these files as needed, and the bot will serve the latest content on request.
### Greet new nodes QRZ module
This isnt QRZ.com this is Q code for who is calling me, this will track new nodes and say hello
```ini
[qrz]
enabled = True # QRZ Hello to new nodes
qrz_hello_string = "send CMD or DM me for more info." # will be sent to all heard nodes once
training = True # Training mode will not send the hello message to new nodes, use this to build up database
```
### Scheduler
In the config.ini enable the module
```ini
[scheduler]
enabled = False # enable or disable the scheduler module
interface = 1 # channel to send the message to
channel = 2
message = "MeshBot says Hello! DM for more info."
value = # value can be min,hour,day,mon,tue,wed,thu,fri,sat,sun
interval = # interval to use when time is not set (e.g. every 2 days)
time = # time of day in 24:00 hour format when value is 'day' and interval is not set
```
The basic brodcast message can be setup in condig.ini. For advanced, See the [modules/scheduler.py](modules/scheduler.py) to edit the schedule. See [schedule documentation](https://schedule.readthedocs.io/en/stable/) for more. Recomend to backup changes so they dont get lost.
```python
#Send WX every Morning at 08:00 using handle_wxc function to channel 2 on device 1
schedule.every().day.at("08:00").do(lambda: send_message(handle_wxc(0, 1, 'wx'), 2, 0, 1))
#Send a Net Starting Now Message Every Wednesday at 19:00 using send_message function to channel 2 on device 1
schedule.every().wednesday.at("19:00").do(lambda: send_message("Net Starting Now", 2, 0, 1))
```
#### 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
```python
# Send bbslink looking for peers every other day at 10:00 using send_message function to channel 8 on device 1
schedule.every(2).days.at("10:00").do(lambda: send_message("bbslink MeshBot looking for peers", 8, 0, 1))
```
```ini
bbslink_enabled = True
bbslink_whitelist = # list of whitelisted nodes numbers ex: 2813308004,4258675309 empty list allows all
```
[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 nodes 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
@@ -583,56 +168,21 @@ I used ideas and snippets from other responder bots and want to call them out!
- ARRL Question Pool Data from https://github.com/russolsen/ham_radio_question_pool
### Special Thanks
- **xdep**: For the reporting tools.
- **Nestpebble**: For new ideas and enhancements.
- **mrpatrick1991**: For Docker configurations.
- **[https://github.com/A-c0rN](A-c0rN)**: Assistance with iPAWS and EAS
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**
- **PiDiBi**: For looking at test functions and other suggestions like wxc, CPU use, and alerting ideas.
- **WH6GXZ nurse dude**: For bashing on installer, Volcano Alerts 🌋
- **Josh**: For more bashing on installer!
- **dj505**: trying it on windows!
- **mikecarper**: ideas, and testing. hamtest
- **c.merphy360**: high altitude alerts
- **Iris**: testing and finding 🐞
- **FJRPiolt**: testing bugs out!!
- **Cisien, bitflip, Woof, propstg, snydermesh, trs2982, F0X, mesb1, and Hailo1999**: For testing and feature ideas on Discord and GitHub.
- **Meshtastic Discord Community**: For tossing out ideas and testing code.
- **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)
### Requirements
Python 3.8? or later is needed (docker on 3.13). The following can be installed with `pip install -r requirements.txt` or using the [install.sh](install.sh) script for venv and automation:
```sh
pip install meshtastic
pip install pubsub
```
Mesh-bot enhancements:
```sh
pip install pyephem
pip install requests
pip install geopy
pip install maidenhead
pip install beautifulsoup4
pip install dadjokes
pip install schedule
pip install wikipedia
```
For the Ollama LLM:
```sh
pip install googlesearch-python
```
To enable emoji in the Debian console, install the fonts:
```sh
sudo apt-get install fonts-noto-color-emoji
```
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
View 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
View 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
View 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

View File

@@ -57,11 +57,17 @@ 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
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
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
@@ -73,17 +79,28 @@ 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 ollama LLM see more at https://ollama.com
# Enable LLM local Ollama integration, set true for any LLM support
ollama = False
# Ollama model to use (defaults to gemma3:270m)
# ollamaModel = gemma3:latest
# server instance to use (defaults to local machine install)
# Ollama server instance to use (defaults to local machine install)
ollamaHostName = http://localhost:11434
# Produce LLM replies to messages that aren't commands?
# If False, the LLM only replies to the "ask:" and "askai" commands.
# 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
# if True, the input is sent raw to the LLM, if False uses legacy template query
rawLLMQuery = 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
@@ -127,19 +144,28 @@ alert_interface = 1
[sentry]
# detect anyone close to the bot
SentryEnabled = True
reqLocationEnabled = False
emailSentryAlerts = False
# radius in meters to detect someone close to the bot
SentryRadius = 100
# device interface and channel to send the alert message to
SentryInterface = 1
SentryChannel = 2
# holdoff time multiplied by seconds(20) of the watchdog
SentryHoldoff = 9
emailSentryAlerts = False
# Enable detection sensor alert, requires external GPIO sensor connected to node
detectionSensorAlert = False
# list of ignored nodes numbers ex: 2813308004,4258675309
sentryIgnoreList =
# Enable detection sensor alert, requires external sensor connected to node
detectionSensorAlert = False
# 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
@@ -174,6 +200,12 @@ lat = 48.50
lon = -123.0
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
# Default to metric units rather than imperial
useMetric = False
@@ -181,13 +213,27 @@ 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
# use Open-Meteo API for weather data not NOAA useful for non US locations
UseMeteoWxAPI = False
# 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
@@ -203,52 +249,49 @@ coastalForecastDays = 3
# for multiple rivers use comma separated list e.g. 12484500,14105700
riverList =
# NOAA EAS Alert Broadcast
wxAlertBroadcastEnabled = False
# Enable Ignore any message that includes following word list
ignoreEASenable = False
ignoreEASwords = test,advisory
# EAS Alert Broadcast Channels
wxAlertBroadcastCh = 2
# Add extra location to the weather alert
enableExtraLocationWx = False
# Goverment Alert Broadcast defaults to FEMA IPAWS
eAlertBroadcastEnabled = False
# 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
# Goverment Alert Broadcast Channels
eAlertBroadcastCh = 2
# Enable Ignore, headline that includes following word list
ignoreFEMAenable = True
ignoreFEMAwords = test,exercise
# USGS Volcano alerts Enable USGS Volcano Alert Broadcast
volcanoAlertBroadcastEnabled = False
volcanoAlertBroadcastCh = 2
# Enable Ignore any message that includes following word list
ignoreUSGSEnable = False
ignoreUSGSWords = test,advisory
# Use DE Alert Broadcast Data
# 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
# Satalite Pass Prediction
# Register for free API https://www.n2yo.com/login/
n2yoAPIKey =
# NORAD list https://www.n2yo.com/satellites/
satList = 25544,7530
# 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
@@ -277,21 +320,30 @@ 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. or custom for module/scheduler.py
# 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]
# using Hamlib rig control will monitor and alert on channel use
enabled = False
rigControlServerAddress = localhost:4532
# device interface to send the message to
# 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 = 127.0.0.1:4532
# minimum SNR as reported by radio via hamlib
signalDetectionThreshold = -10
# hold time for high SNR
@@ -299,17 +351,41 @@ signalHoldTime = 10
# the following are combined to reset the monitor
signalCooldown = 5
signalCycleLimit = 5
# enable VOX detection using default input
# 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
@@ -321,8 +397,10 @@ 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 line from the news file
# 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
@@ -330,9 +408,9 @@ enable_runShellCmd = False
# direct shell command handler the x: command in DMs
allowXcmd = False
# Enable 2 factor authentication for x: commands
2factor_enabled = True
twoFactor_enabled = True
# time in seconds to wait for the correct 2FA answer
2factor_timeout = 100
twoFactor_timeout = 100
[smtp]
# enable or disable the SMTP module
@@ -374,6 +452,8 @@ 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
@@ -409,3 +489,17 @@ 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

View File

@@ -1 +1,3 @@
database admin tool is in [./etc/db_admin.py](../etc/db_admin.py)
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

132
etc/README.md Normal file
View 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 Bots 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 bots 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.

View 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}")

View File

@@ -959,18 +959,6 @@
"To relay messages between satellites"
]
},
{
"id": "E2A13",
"correct": 1,
"refs": "",
"question": "Which of the following techniques is used by digital satellites to relay messages?",
"answers": [
"Digipeating",
"Store-and-forward",
"Multisatellite relaying",
"Node hopping"
]
},
{
"id": "E2B01",
"correct": 0,
@@ -2495,18 +2483,6 @@
"Utilizing a Class D final amplifier"
]
},
{
"id": "E4D05",
"correct": 0,
"refs": "",
"question": "What transmitter frequencies would create an intermodulation-product signal in a receiver tuned to 146.70 MHz when a nearby station transmits on 146.52 MHz?",
"answers": [
"146.34 MHz and 146.61 MHz",
"146.88 MHz and 146.34 MHz",
"146.10 MHz and 147.30 MHz",
"146.30 MHz and 146.90 MHz"
]
},
{
"id": "E4D06",
"correct": 2,
@@ -3851,18 +3827,6 @@
"Permeability"
]
},
{
"id": "E6D07",
"correct": 3,
"refs": "",
"question": "What is the current that flows in the primary winding of a transformer when there is no load on the secondary winding?",
"answers": [
"Stabilizing current",
"Direct current",
"Excitation current",
"Magnetizing current"
]
},
{
"id": "E6D08",
"correct": 1,

View File

@@ -35,18 +35,6 @@
"12 meters"
]
},
{
"id": "G1A04",
"correct": 3,
"refs": "[97.303(h)]",
"question": "Which of the following amateur bands is restricted to communication only on specific channels, rather than frequency ranges?",
"answers": [
"11 meters",
"12 meters",
"30 meters",
"60 meters"
]
},
{
"id": "G1A05",
"correct": 0,
@@ -347,18 +335,6 @@
"Submit a rule-making proposal to the FCC describing the codes and methods of the technique"
]
},
{
"id": "G1C09",
"correct": 2,
"refs": "[97.313(i)]",
"question": "What is the maximum power limit on the 60-meter band?",
"answers": [
"1500 watts PEP",
"10 watts RMS",
"ERP of 100 watts PEP with respect to a dipole",
"ERP of 100 watts PEP with respect to an isotropic antenna"
]
},
{
"id": "G1C11",
"correct": 3,
@@ -611,18 +587,6 @@
"1500 watts"
]
},
{
"id": "G1E09",
"correct": 0,
"refs": "[97.115]",
"question": "Under what circumstances are messages that are sent via digital modes exempt from Part 97 third-party rules that apply to other modes of communication?",
"answers": [
"Under no circumstances",
"When messages are encrypted",
"When messages are not encrypted",
"When under automatic control"
]
},
{
"id": "G1E10",
"correct": 0,
@@ -4079,18 +4043,6 @@
"All these choices are correct"
]
},
{
"id": "G8C01",
"correct": 2,
"refs": "",
"question": "On what band do amateurs share channels with the unlicensed Wi-Fi service?",
"answers": [
"432 MHz",
"902 MHz",
"2.4 GHz",
"10.7 GHz"
]
},
{
"id": "G8C02",
"correct": 0,

View File

@@ -1,5 +1,8 @@
# Load the bbs messages from the database file to screen for admin functions
import pickle # pip install pickle
import pickle
import sqlite3
print ("\n Meshing-Around Database Admin Tool\n")
# load the bbs messages from the database file
@@ -106,7 +109,70 @@ except Exception as e:
golfsim_score = "System: data/golfsim_hs.pkl not found"
print ("\n Meshing-Around Database Admin Tool\n")
# 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")

102
etc/fakeNode.py Normal file
View 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
View 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
View 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"

View File

@@ -13,14 +13,17 @@ User=pi
Group=pi
WorkingDirectory=/dir/
ExecStart=python3 mesh_bot.py
ExecStop=pkill -f 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

View File

@@ -2,9 +2,11 @@
Description=MeshingAround-ReportingTask
[Timer]
OnUnitActiveSec=1h
OnbootSec=5min
OnCalendar=*-*-* 04:20:00
Persistent=true
Unit=mesh_bot_reporting.service
#OnUnitActiveSec=1h
#OnbootSec=5min
[Install]
WantedBy=timers.target

View File

@@ -14,13 +14,15 @@ 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
Type=notify #try simple if any problems
[Install]
WantedBy=default.target
WantedBy=timers.target

View File

@@ -1,23 +0,0 @@
# /etc/systemd/system/mesh_bot_w3.service
# sudo systemctl daemon-reload
# sudo systemctl enable mesh_bot_w3.service
# sudo systemctl start mesh_bot_w3.service
[Unit]
Description=MeshingAround-W3Server
After=network.target
[Service]
Type=simple
User=pi
Group=pi
WorkingDirectory=/dir/
ExecStart=python3 modules/web.py
ExecStop=pkill -f mesh_bot_w3.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
Type=notify #try simple if any problems

View 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

View File

@@ -1,224 +0,0 @@
// Example to receive and decode Meshtastic UDP packets
// Make sure to install the meashtastic library and generate the .pb.h and .pb.c files from the Meshtastic .proto definitions
// https://github.com/meshtastic/protobufs/tree/master/meshtastic
// Example to receive and decode Meshtastic UDP packets
#include <WiFi.h>
#include <WiFiUdp.h>
// #include <AESLib.h> // or another AES library
#include "pb_decode.h"
#include "meshtastic/mesh.pb.h" // MeshPacket, Position, etc.
#include "meshtastic/portnums.pb.h" // Port numbers enum
#include "meshtastic/telemetry.pb.h" // Telemetry message
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
const char* default_key = "1PG7OiApB1nwvP+rz05pAQ=="; // Your network key here
uint8_t aes_key[16]; // Buffer for decoded key
const char* MCAST_GRP = "224.0.0.69";
const uint16_t MCAST_PORT = 4403;
unsigned long udpPacketCount = 0;
WiFiUDP udp;
IPAddress multicastIP;
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("Scanning for WiFi networks...");
int n = WiFi.scanNetworks();
if (n == 0) {
Serial.println("No networks found.");
} else {
Serial.print(n);
Serial.println(" networks found:");
for (int i = 0; i < n; ++i) {
Serial.print(i + 1);
Serial.print(": ");
Serial.print(WiFi.SSID(i));
Serial.print(" (RSSI ");
Serial.print(WiFi.RSSI(i));
Serial.print(")");
Serial.println((WiFi.encryptionType(i) == WIFI_AUTH_OPEN) ? " [OPEN]" : " [SECURED]");
delay(10);
}
}
Serial.println("Connecting to WiFi...");
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
unsigned long startAttemptTime = millis();
const unsigned long wifiTimeout = 20000;
while (WiFi.status() != WL_CONNECTED && millis() - startAttemptTime < wifiTimeout) {
delay(500);
Serial.print(".");
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nWiFi connected.");
Serial.print("IP address: ");
Serial.println(WiFi.localIP());
multicastIP.fromString(MCAST_GRP);
if (udp.beginMulticast(multicastIP, MCAST_PORT)) {
Serial.println("UDP multicast listener started.");
} else {
Serial.println("Failed to start UDP multicast listener.");
}
} else {
Serial.print("\nFailed to connect to WiFi. SSID: ");
Serial.println(ssid);
Serial.println("Check SSID, range, and password.");
}
}
void printHex(const uint8_t* buf, size_t len) {
for (size_t i = 0; i < len; i++) {
Serial.printf("%02X ", buf[i]);
}
Serial.println();
}
void printAscii(const uint8_t* buf, size_t len) {
for (size_t i = 0; i < len; i++) {
char c = static_cast<char>(buf[i]);
Serial.print(isprint(c) ? c : '.');
}
Serial.println();
}
void decodeKey() {
// Convert base64 key to raw bytes
// You may need to add a base64 decoding function/library
// Example: decode_base64(default_key, aes_key, sizeof(aes_key));
}
void decryptPayload(const uint8_t* encrypted, size_t len, uint8_t* decrypted) {
// Use AESLib or similar to decrypt
// Example: aes128_dec_single(decrypted, encrypted, aes_key);
}
void loop() {
int packetSize = udp.parsePacket();
if (!packetSize) {
delay(50);
return;
}
udpPacketCount++;
Serial.print("UDP packets seen: ");
Serial.println(udpPacketCount);
uint8_t buffer[512];
int len = udp.read(buffer, sizeof(buffer));
if (len <= 0) {
Serial.println("Failed to read UDP packet.");
delay(50);
return;
}
// Always show raw payload
Serial.print("Raw UDP payload (hex): ");
printHex(buffer, len);
Serial.print("Raw UDP payload (ASCII): ");
printAscii(buffer, len);
// Decode outer MeshPacket
meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero;
pb_istream_t stream = pb_istream_from_buffer(buffer, len);
if (!pb_decode(&stream, meshtastic_MeshPacket_fields, &pkt)) {
Serial.println("Failed to decode meshtastic_MeshPacket.");
delay(50);
return;
}
// Basic MeshPacket fields
Serial.print("id: "); Serial.println(pkt.id);
Serial.print("rx_time: "); Serial.println(pkt.rx_time);
Serial.print("rx_snr: "); Serial.println(pkt.rx_snr, 2);
Serial.print("rx_rssi: "); Serial.println(pkt.rx_rssi);
Serial.print("hop_limit: "); Serial.println(pkt.hop_limit);
Serial.print("priority: "); Serial.println(pkt.priority);
Serial.print("from: "); Serial.println(pkt.from);
Serial.print("to: "); Serial.println(pkt.to);
Serial.print("channel: "); Serial.println(pkt.channel);
// Only proceed if we have a decoded Data variant
if (pkt.which_payload_variant != meshtastic_MeshPacket_decoded_tag) {
Serial.println("Packet does not contain decoded Data (maybe encrypted or other variant).");
delay(50);
return;
}
const meshtastic_Data& data = pkt.decoded;
Serial.print("Portnum: "); Serial.println(data.portnum);
Serial.print("Payload size: "); Serial.println(data.payload.size);
if (data.payload.size == 0) {
Serial.println("No inner payload bytes.");
delay(50);
return;
}
// Decode by portnum
switch (data.portnum) {
case meshtastic_PortNum_TEXT_MESSAGE_APP: {
// Current schemas do not use a separate user.pb.h. Text payload is plain bytes.
Serial.print("Decoded text message: ");
printAscii(data.payload.bytes, data.payload.size);
break;
}
case meshtastic_PortNum_POSITION_APP: {
meshtastic_Position pos = meshtastic_Position_init_zero;
pb_istream_t ps = pb_istream_from_buffer(data.payload.bytes, data.payload.size);
if (pb_decode(&ps, meshtastic_Position_fields, &pos)) {
Serial.print("Position lat="); Serial.print(pos.latitude_i / 1e7, 7);
Serial.print(" lon="); Serial.print(pos.longitude_i / 1e7, 7);
Serial.print(" alt="); Serial.println(pos.altitude);
} else {
Serial.println("Failed to decode Position payload.");
}
break;
}
case meshtastic_PortNum_TELEMETRY_APP: {
meshtastic_Telemetry tel = meshtastic_Telemetry_init_zero;
pb_istream_t ts = pb_istream_from_buffer(data.payload.bytes, data.payload.size);
if (pb_decode(&ts, meshtastic_Telemetry_fields, &tel)) {
// Print a few common fields if present
if (tel.which_variant == meshtastic_Telemetry_device_metrics_tag) {
const meshtastic_DeviceMetrics& m = tel.variant.device_metrics;
Serial.print("Telemetry battery_level="); Serial.print(m.battery_level);
Serial.print(" voltage="); Serial.print(m.voltage);
Serial.print(" air_util_tx="); Serial.println(m.air_util_tx);
} else {
Serial.println("Telemetry decoded, different variant. Raw bytes:");
printHex(data.payload.bytes, data.payload.size);
}
} else {
Serial.println("Failed to decode Telemetry payload.");
}
break;
}
default: {
Serial.print("Unhandled portnum "); Serial.print((int)data.portnum);
Serial.println(", showing payload as hex:");
printHex(data.payload.bytes, data.payload.size);
break;
}
}
delay(50);
}

View File

@@ -13,14 +13,16 @@ User=pi
Group=pi
WorkingDirectory=/dir/
ExecStart=python3 pong_bot.py
ExecStop=pkill -f 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

53
etc/set-permissions.sh Normal file
View 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."

View File

@@ -1,7 +1,8 @@
#!/usr/bin/env python3
# # Simulate meshing-around de K7MHI 2024
from modules.log import * # Import the logger; ### --> If you are reading this put the script in the project root <-- ###
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
@@ -22,6 +23,8 @@ 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,8 +51,8 @@ def example_handler(message, nodeID, deviceID):
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

213
etc/yolo_vision.py Normal file
View 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!")

View File

@@ -1,47 +1,159 @@
#!/bin/bash
# meshing-around install helper script
# to uninstall, run with --nope
# install.sh
NOPE=0
cd "$(dirname "$0")"
program_path=$(pwd)
chronjob="0 1 * * * /usr/bin/python3 $program_path/etc/report_generator5.py"
printf "\n########################"
printf "\nMeshing Around Installer\n"
printf "########################\n"
printf "\nThis script will try and install the Meshing Around Bot and its dependencies.\n"
printf "Installer works best in raspian/debian/ubuntu or foxbuntu embedded systems.\n"
printf "If there is a problem, try running the installer again.\n"
printf "\nChecking for dependencies...\n"
# check if we are in /opt/meshing-around
if [ $program_path != "/opt/meshing-around" ]; then
printf "\nIt is suggested to project path to /opt/meshing-around\n"
printf "Do you want to move the project to /opt/meshing-around? (y/n)"
read move
if [[ $(echo "$move" | grep -i "^y") ]]; then
sudo mv $program_path /opt/meshing-around
cd /opt/meshing-around
printf "\nProject moved to /opt/meshing-around. re-run the installer\n"
exit 0
for arg in "$@"; do
if [[ "$arg" == "--nope" ]]; then
NOPE=1
fi
done
if [[ $NOPE -eq 1 ]]; then
echo "----------------------------------------------"
echo "Uninstalling Meshing Around ..."
echo "----------------------------------------------"
sudo systemctl stop mesh_bot || true
sudo systemctl disable mesh_bot || true
sudo systemctl stop pong_bot || true
sudo systemctl disable pong_bot || true
sudo systemctl stop mesh_bot_w3_server || true
sudo systemctl disable mesh_bot_w3_server || true
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
# check write access to program path
# 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
printf "\nInstall path not writable, try running the installer with sudo\n"
echo
echo "=========================================================="
echo " ERROR: Install path not writable."
echo " Try running the installer with sudo?"
echo "=========================================================="
exit 1
fi
# if hostname = femtofox, then we are on embedded
# 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
echo "Continuing installation in current directory: $program_path"
fi
fi
echo "----------------------------------------------"
echo "Embedded install? auto answers install stuff..."
echo "----------------------------------------------"
if [[ $(hostname) == "femtofox" ]]; then
printf "\nDetected femtofox embedded system\n"
printf "\n[INFO] Detected femtofox embedded system.\n"
embedded="y"
else
# check if running on embedded
printf "\nAre You installing into an embedded system like a luckfox or -native? most should say no here (y/n)"
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
@@ -71,17 +183,35 @@ else
printf "\nDependencies installed\n"
fi
# 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 $USER
sudo usermod -a -G tty $USER
sudo usermod -a -G bluetooth $USER
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.tmp etc/mesh_bot_w3.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/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
@@ -92,6 +222,10 @@ 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"
@@ -159,6 +293,10 @@ else
fi
fi
echo "----------------------------------------------"
echo "Installing bot service? - mesh or pong or none"
echo "----------------------------------------------"
# if $1 is passed
if [[ $1 == "pong" ]]; then
bot="pong"
@@ -173,53 +311,59 @@ else
read bot
fi
# 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/mesh_bot_reporting.service
sed -i $replace etc/mesh_bot_w3.service
# set the correct user in the service file?
#ask if we should add a user for the bot
if [[ $(echo "${embedded}" | grep -i "^n") ]]; then
printf "\nDo you want to add a local user (meshbot) no login, for the bot? (y/n)"
read meshbotservice
fi
if [[ $(echo "${meshbotservice}" | grep -i "^y") ]] || [[ $(echo "${embedded}" | grep -i "^y") ]]; then
sudo useradd -M meshbot
sudo usermod -L meshbot
sudo groupadd meshbot
sudo usermod -a -G meshbot meshbot
whoami="meshbot"
echo "Added user meshbot with no home directory"
# 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
whoami=$(whoami)
# 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
# set basic permissions for the bot user
sudo usermod -a -G dialout $whoami
sudo usermod -a -G tty $whoami
sudo usermod -a -G bluetooth $whoami
echo "Added user $whoami to dialout, tty, and bluetooth groups"
sudo chown -R $whoami:$whoami $program_path/logs
sudo chown -R $whoami:$whoami $program_path/data
echo "Permissions set for meshbot on logs and data directories"
echo "----------------------------------------------"
echo "Finalizing service installation..."
echo "----------------------------------------------"
# set the correct user in the service file
replace="s|User=pi|User=$whoami|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_w3.service
replace="s|Group=pi|Group=$whoami|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_w3.service
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"
# 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 [[ $(echo "${bot}" | grep -i "^p") ]]; then
# install service for pong bot
sudo cp etc/pong_bot.service /etc/systemd/system/
@@ -238,6 +382,33 @@ if [[ $(echo "${bot}" | grep -i "^m") ]]; then
service="mesh_bot"
fi
# 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
@@ -297,20 +468,22 @@ if [[ $(echo "${embedded}" | grep -i "^n") ]]; then
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 "Reporting chron job added to run report_generator5.py\n" >> install_notes.txt
printf "chronjob: %s\n" "$chronjob" >> 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"
printf "\nGood time to reboot? (y/n)"
read reboot
if [[ $(echo "${reboot}" | grep -i "^y") ]]; then
sudo reboot
fi
else
# we are on embedded
# replace "type = serial" with "type = tcp" in config.ini
@@ -323,20 +496,8 @@ else
# add service dependency for meshtasticd into service file
#replace="s|After=network.target|After=network.target meshtasticd.service|g"
# Set up the meshing around service
sudo cp /opt/meshing-around/etc/$service.service /etc/systemd/system/$service.service
sudo systemctl daemon-reload
sudo systemctl enable $service.service
sudo systemctl start $service.service
# check if the cron job already exists
if ! crontab -l | grep -q "$chronjob"; then
# add the cron job to run the report_generator5.py script
(crontab -l 2>/dev/null; echo "$chronjob") | crontab -
printf "\nAdded cron job to run report_generator5.py\n"
else
printf "\nCron job already exists, skipping\n"
fi
printf "Reference following commands:\n\n" "$service" > install_notes.txt
# 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
@@ -344,23 +505,59 @@ else
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
printf "\nInstallation complete!\n"
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_w3.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
@@ -370,9 +567,35 @@ exit 0
# sudo groupdel meshbot
# sudo userdel meshbot
# sudo rm -rf /opt/meshing-around
# 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
# after install shenannigans
# 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.github.com/MattIPv4/045239bc27b16b2bcf7a3a9a4648c08a -O bee.txt
# 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..

View File

@@ -5,31 +5,38 @@
cd "$(dirname "$0")"
if [ ! -f "config.ini" ]; then
if [[ ! -f "config.ini" ]]; then
cp config.template config.ini
fi
# activate the virtual environment if it exists
if [ -d "venv" ]; then
if [[ -d "venv" ]]; then
source venv/bin/activate
else
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
elif [[ "$1" == "html" ]]; then
python3 etc/report_generator.py
elif [ "$1" == "html5" ]; then
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) or a report to generate (html/html5) or addFav"
echo "Please provide a bot to launch (pong/mesh/display) or a report to generate (html/html5) or addFav"
exit 1
fi

View File

@@ -1,10 +1,16 @@
# Logs and Reports
Logs will collect here. Give a day of logs or a bunch of messages to have good reports.
## Reporting Note
Reporting is via [../etc/report_generator5.py](../etc/report_generator5.py). The report_generator5 has newer feel and HTML5 coding. The index.html output is published in [../etc/www](../etc/www) there is a .cfg file created on first run for configuring values as needed (like moving web root)
- Make sure to have `SyslogToFile = True` and default of DEBUG log level to fully enable reporting! ‼️
- If you are in a venv and using launch.sh you can `launch.sh html5`
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
```
![reportView](../etc/reporting.jpg)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

207
modules/adding_more.md Normal file
View 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 youre stuck, ask for help on the projects 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 Pythons `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
View 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 bots 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 Bots 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.
---

View File

@@ -2,8 +2,10 @@
# 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
useSynchCompression = False
@@ -20,30 +22,32 @@ bbs_dm = []
def load_bbsdb():
global bbs_messages
# load the bbs messages from the database file
try:
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:
#example [1, 'Welcome to meshBBS', 'Welcome to the BBS, please post a message!', 0]
msgHash = hash(tuple(msg[1:3])) # Create a hash of the message content (subject and body)
# Check if the message already exists in bbs_messages
msgHash = hash(tuple(msg[1:3]))
if all(hash(tuple(existing_msg[1:3])) != msgHash for existing_msg in bbs_messages):
# if the message is not a duplicate, add it to bbs_messages Maintain the message ID sequence
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")
bbs_messages = [[1, "Welcome to meshBBS", "Welcome to the BBS, please post a message!",0]]
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}")
bbs_messages = [[1, "Welcome to meshBBS", "Welcome to the BBS, please post a message!",0]]
return False
def save_bbsdb():
global bbs_messages
@@ -97,7 +101,7 @@ def bbs_delete_message(messageID = 0, fromNode = 0):
def bbs_post_message(subject, message, fromNode, threadID=0, replytoID=0):
# post a message to the bbsdb
now = today.strftime('%Y-%m-%d %H:%M:%S')
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
thread = threadID
replyto = replytoID
# post a message to the bbsdb and assign a messageID
@@ -251,7 +255,19 @@ def bbs_sync_posts(input, peerNode, RxNode):
#store the message
subject = input.split("$")[1].split("#")[0]
body = input.split("#")[1]
fromNodeHex = 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:

442
modules/checklist.md Normal file
View 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.

View File

@@ -2,38 +2,51 @@
# K7MHI Kelly Keeton 2024
import sqlite3
from modules.log import *
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", "purgein", "purgeout")
trap_list_checklist = ("checkin", "checkout", "checklist", "approvecl", "denycl",)
def initialize_checklist_database():
# create the database
conn = sqlite3.connect(checklist_db)
c = conn.cursor()
# Check if the checkin table exists, and create it if it doesn't
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)''')
# Check if the checkout table exists, and create it if it doesn't
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)''')
conn.commit()
conn.close()
logger.debug("System: Ensured data/checklist.db exists with required tables")
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))
# checkin a user
# 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) VALUES (?, ?, ?, ?, ?)", (name, date, time, location, notes))
# # remove any checkouts that are older than the checkin
# c.execute("DELETE FROM checkout WHERE checkout_date < ? OR (checkout_date = ? AND checkout_time < ?)", (date, date, time))
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) VALUES (?, ?, ?, ?, ?)", (name, date, time, location, notes))
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()
@@ -43,85 +56,260 @@ def checkin(name, date, time, location, notes):
else:
return "Checked✅In: " + str(name)
def delete_checkin(checkin_id):
# delete a checkin
conn = sqlite3.connect(checklist_db)
c = conn.cursor()
c.execute("DELETE FROM checkin WHERE checkin_id = ?", (checkin_id,))
conn.commit()
conn.close()
return "Checkin deleted." + str(checkin_id)
def checkout(name, date, time_str, location, notes):
def checkout(name, date, time_str, location, notes, all=False, checkin_id=None):
location = ", ".join(map(str, location))
# checkout a user
conn = sqlite3.connect(checklist_db)
c = conn.cursor()
checked_out_ids = []
durations = []
try:
# Check if the user has a checkin before checking out
c.execute("""
SELECT checkin_id FROM checkin
WHERE checkin_name = ?
AND NOT EXISTS (
SELECT 1 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
""", (name,))
checkin_record = c.fetchone()
if checkin_record:
c.execute("INSERT INTO checkout (checkout_name, checkout_date, checkout_time, location, checkout_notes) VALUES (?, ?, ?, ?, ?)", (name, date, time_str, location, notes))
# calculate length of time checked in
c.execute("SELECT checkin_time FROM checkin WHERE checkin_id = ?", (checkin_record[0],))
checkin_time = c.fetchone()[0]
checkin_datetime = time.strptime(date + " " + checkin_time, "%Y-%m-%d %H:%M:%S")
time_checked_in_seconds = time.time() - time.mktime(checkin_datetime)
timeCheckedIn = time.strftime("%H:%M:%S", time.gmtime(time_checked_in_seconds))
# # remove the checkin record older than the checkout
# c.execute("DELETE FROM checkin WHERE checkin_date < ? OR (checkin_date = ? AND checkin_time < ?)", (date, date, time_str))
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()
c.execute("INSERT INTO checkout (checkout_name, checkout_date, checkout_time, location, checkout_notes) VALUES (?, ?, ?, ?, ?)", (name, date, time_str, location, notes))
return checkout(name, date, time_str, location, notes, all=all, checkin_id=checkin_id)
else:
conn.close()
raise
conn.commit()
conn.close()
if checkin_record:
if reverse_in_out:
return "CheckedIn: " + str(name) + " duration " + timeCheckedIn
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:
return "Checked⌛Out: " + str(name) + " duration " + timeCheckedIn
if reverse_in_out:
return f"Checked⌛In: {name} duration {durations[0]}"
else:
return f"Checked⌛Out: {name} duration {durations[0]}"
else:
return "None found for " + str(name)
return f"None found for {name}"
def delete_checkout(checkout_id):
# delete a checkout
def approve_checkin(checkin_id):
"""Approve a pending check-in"""
conn = sqlite3.connect(checklist_db)
c = conn.cursor()
c.execute("DELETE FROM checkout WHERE checkout_id = ?", (checkout_id,))
conn.commit()
conn.close()
return "Checkout deleted." + str(checkout_id)
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()
c.execute("""
SELECT * FROM checkin
WHERE checkin_id NOT IN (
SELECT checkin_id FROM checkout
WHERE checkout_date > checkin_date OR (checkout_date = checkin_date AND checkout_time > checkin_time)
)
""")
rows = c.fetchall()
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()
timeCheckedIn = ""
# 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)
@@ -132,9 +320,31 @@ def list_checkin():
timeCheckedIn = f"{days}d {hours:02}:{minutes:02}:{seconds:02}"
else:
timeCheckedIn = f"{hours:02}:{minutes:02}:{seconds:02}"
checkin_list += "ID: " + row[1] + " checked-In for " + timeCheckedIn
# 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]
checkin_list += " 📝" + row[5]
if row != rows[-1]:
checkin_list += "\n"
# if empty list
@@ -149,31 +359,113 @@ def process_checklist_command(nodeID, message, name="none", location="none"):
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]
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):
return checkin(name, current_date, current_time, location, comment)
elif ("checkout" in message.lower() and not reverse_in_out) or ("checkin" in message.lower() and reverse_in_out):
return checkout(name, current_date, current_time, location, comment)
elif "purgein" in message.lower():
return delete_checkin(nodeID)
elif "purgeout" in message.lower():
return delete_checkout(nodeID)
elif "?" in message.lower():
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"
"checkout to check out\n"
"purgeout to delete your checkout record\n"
"Example: checkin Arrived at park")
"checkin [interval] [note]\n"
"checkout [all] [note]\n"
"Example: checkin 60 Leaving for a hike")
else:
return ("Command: checklist followed by\n"
"checkin to check out\n"
"purgeout to delete your checkin record\n"
"Example: checkout Leaving park")
elif "checklist" in message.lower():
"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."
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
View 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

View File

@@ -1,27 +1,61 @@
# File monitor module for the meshing-around bot
# 2024 Kelly Keeton K7MHI
from modules.log import *
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):
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 random_line_only:
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 = f.readlines()
return random.choice(lines)
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:
@@ -31,13 +65,67 @@ def read_file(file_monitor_file_path, random_line_only=False):
logger.warning(f"FileMon: Error reading file: {file_monitor_file_path}")
return None
def read_news(source=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)
return read_file(file_path, news_random_line_only)
# 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
@@ -69,28 +157,33 @@ async def watch_file():
return content
await asyncio.sleep(1) # Check every
def call_external_script(message, script="script/runShell.sh"):
# Call an external script with the message as an argument this is a example only
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
# 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"
# Use subprocess.run for better resource management
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
return output if output else None
except Exception as e:
logger.warning(f"FileMon: Error calling external script: {e}")
return None
@@ -172,6 +265,9 @@ def initNewsSources():
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
View 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:**
Youll be shown a list of cities. Enter the number to choose your starting location.
2. **Each Day:**
- Youll 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 youre within 20 yards, youll automatically putt to finish the hole.
2. **Scoring:**
- The bot tracks your strokes and score relative to par.
- After each hole, youll 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, youll 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!
![Example Use](../../etc/3dttt.jpg "Example Use")
## 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, its 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 its 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., `_ _ _ _ _`).
- Youll 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! Heres 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 users 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
View 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

View 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)

View File

@@ -2,12 +2,12 @@
# Adapted for Meshtastic mesh-bot by K7MHI Kelly Keeton 2024
from random import choices, shuffle
from modules.log import *
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
from modules.settings import jackTracker
SUITS = ("♥️", "♦️", "♠️", "♣️")
RANKS = (
@@ -297,7 +297,7 @@ def playBlackJack(nodeID, message, last_cmd=None):
#resend the hand
msg += show_some(p_cards, d_cards, p_hand)
return msg
elif message.lower() == "blackjack":
elif "blackjack" in message.lower():
return f"\nTo place a bet, enter the amount you wish to wager."
else:
try:

View File

@@ -4,7 +4,8 @@
import random
import time
import pickle
from modules.log import *
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
@@ -382,15 +383,19 @@ def endGameDw(nodeID):
with open('data/dopewar_hs.pkl', 'wb') as file:
pickle.dump(dwHighScore, file)
msg = "You finished with $" + "{:,}".format(cash) + " and beat the high score!🎉💰"
return msg
if cash > starting_cash:
elif cash > starting_cash:
msg = 'You made money! 💵 Up ' + str((cash/starting_cash).__round__()) + 'x! Well done.'
return msg
if cash == starting_cash:
elif cash == starting_cash:
msg = 'You broke even... hope you at least had fun 💉💊'
return msg
if cash < starting_cash:
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
@@ -495,6 +500,11 @@ def playDopeWars(nodeID, cmd):
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()
@@ -605,9 +615,6 @@ def playDopeWars(nodeID, cmd):
# render_game_screen
msg = render_game_screen(nodeID, game_day, total_days, loc_choice, -1, price_list, 0, 'nothing')
return msg
elif 'e' in menu_choice:
msg = endGameDw(nodeID)
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

View File

@@ -4,7 +4,8 @@
import random
import time
import pickle
from modules.log import *
from modules.log import logger
from modules.settings import golfTracker
# Clubs setup
driver_distances = list(range(230, 280, 5))

View File

@@ -9,7 +9,8 @@
import json
import random
import os
from modules.log import *
from modules.log import logger
from modules.settings import hamtestTracker
class HamTest:
def __init__(self):
@@ -135,8 +136,16 @@ class HamTest:
# 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
hamtestTracker = []
hamtest = HamTest()

View File

@@ -1,5 +1,9 @@
# 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 = [
@@ -118,6 +122,25 @@ class Hangman:
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

View File

@@ -3,7 +3,8 @@
# 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 *
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!",
@@ -46,15 +47,27 @@ lameJokes = [
"Chuck Norris can kill two stones with one bird.",
"Chuck Norris can speak braille.",
"Chuck Norris can build a snowman out of rain.",
"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."]
"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():
@@ -88,8 +101,8 @@ def tableOfContents():
'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': '🍍',
'mango': '🥭', 'apple': '🍎', 'green apple': '🍏', 'pear': '🍐', 'peach': '🍑', 'cherries': '🍒', 'strawberry': '🍓', 'kiwi': '🥝', 'tomato': '🍅', 'coconut': '🥥', 'avocado': '🥑',
'eggplant': '🍆', 'potato': '🥔', 'carrot': '🥕', 'corn': '🌽', 'hot pepper': '🌶️', 'cucumber': '🥒', 'leafy green': '🥬', 'broccoli': '🥦', 'garlic': '🧄', 'onion': '🧅',
'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': '🥗',
@@ -103,19 +116,19 @@ def tableOfContents():
'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': '🏪',
'school': '🏫', 'department store': '🏬', 'factory': '🏭', 'japanese castle': '🏯', 'castle': '🏰', 'wedding': '💒', 'tokyo tower': '🗼', 'statue of liberty': '🗽', 'church': '', 'mosque': '🕌',
'hindu temple': '🛕', 'synagogue': '🕍', 'shinto shrine': '⛩️', 'kaaba': '🕋', 'fountain': '', 'tent': '', 'foggy': '🌁', 'night with stars': '🌃', 'sunrise over mountains': '🌄', 'sunrise': '🌅',
'cityscape at dusk': '🌆', 'sunset': '🌇', 'cityscape': '🏙️', 'bridge at night': '🌉', 'hot springs': '♨️', 'carousel horse': '🎠', 'ferris wheel': '🎡', 'roller coaster': '🎢', 'barber pole': '💈',
'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': '🔭', 'rocket': '🚀', 'satellite': '🛰️', 'spaceship': '🛸', 'planet': '🪐', 'black hole': '🕳️', 'galaxy': '🌌',
'comet': '☄️', 'constellation': '🌠', 'lightning': '', 'magnet': '🧲', 'battery': '🔋', 'computer': '💻', 'keyboard': '⌨️', 'mouse': '🖱️', 'printer': '🖨️', 'floppy disk': '💾',
'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': '🔊', 'flashlight': '🔦', 'circuit': '🔌', 'chip': '💻',
'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': '🦿',
@@ -124,21 +137,17 @@ def tableOfContents():
'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': '🐫',
'llama': '🦙', 'alpaca': '🦙', 'buffalo': '🐃', 'ox': '🐂', 'deer': '🦌', 'moose': '🦌', 'reindeer': '🦌', 'goat': '🐐', 'sheep': '🐑', 'ram': '🐏', 'lamb': '🐑', 'horse': '🐴',
'unicorn': '🦄', 'zebra': '🦓', 'cow': '🐄', 'pig': '🐖', 'boar': '🐗', 'mouse': '🐁', 'rat': '🐀', 'hamster': '🐹', 'rabbit': '🐇', 'chipmunk': '🐿️', 'beaver': '🦫', 'hedgehog': '🦔',
'bat': '🦇', 'bear': '🐻', 'koala': '🐨', 'panda': '🐼', 'sloth': '🦥', 'otter': '🦦', 'skunk': '🦨', 'kangaroo': '🦘', 'badger': '🦡', 'turkey': '🦃', 'chicken': '🐔', 'rooster': '🐓',
'peacock': '🦚', 'parrot': '🦜', 'swan': '🦢', 'flamingo': '🦩', 'dodo': '🦤', 'crocodile': '🐊', 'turtle': '🐢', 'lizard': '🦎', 'snake': '🐍', 'dragon': '🐉', 'sauropod': '🦕', 't-rex': '🦖',
'whale': '🐋', 'dolphin': '🐬', 'fish': '🐟', 'blowfish': '🐡', 'shark': '🦈', 'octopus': '🐙', 'shell': '🐚', 'crab': '🦀', 'lobster': '🦞', 'shrimp': '🦐', 'squid': '🦑', 'snail': '🐌', 'butterfly': '🦋',
'bee': '🐝', 'beetle': '🐞', 'ant': '🐜', 'cricket': '🦗', 'spider': '🕷️', 'scorpion': '🦂', 'mosquito': '🦟', 'microbe': '🦠', 'locomotive': '🚂', 'arm': '💪', 'leg': '🦵', 'sponge': '🧽',
'toothbrush': '🪥', 'broom': '🧹', 'basket': '🧺', 'roll of paper': '🧻', 'bucket': '🪣', 'soap': '🧼', 'toilet paper': '🧻', 'shower': '🚿', 'bathtub': '🛁', 'razor': '🪒', 'lotion': '🧴',
'letter': '✉️', 'envelope': '✉️', 'mail': '📬', 'post': '📮', 'golf': '⛳️', 'golfing': '⛳️', 'office': '🏢', 'meeting': '📅', 'presentation': '📊', 'report': '📄', 'document': '📄',
'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': '⚾️', 'quarter': '🏈', 'half': '🏈', 'overtime': '🏈', 'penalty': '⚽️', 'foul': '', 'timeout': '', 'substitute': '🔄', 'bench': '🪑', 'sideline': '🏟', 'dugout': '⚾️', 'locker room': '🚪', 'shower': '🚿',
'uniform': '👕', 'jersey': '👕', 'cleats': '👟', 'helmet': '⛑️', 'pads': '🛡️', 'gloves': '🧤', 'bat': '⚾️', 'ball': '⚽️', 'puck': '🏒', 'stick': '🏒', 'net': '🥅', 'hoop': '🏀', 'goalpost': '🥅', 'whistle': '🔔',
'scoreboard': '📊', 'fans': '👥', 'crowd': '👥', 'cheer': '📣', 'boo': '😠', 'applause': '👏', 'celebration': '🎉', 'parade': '🎉', 'trophy': '🏆', 'medal': '🏅', 'ribbon': '🎀', 'cup': '🏆', 'championship': '🏆',
'league': '🏆', 'season': '🏆', 'playoffs': '🏆', 'finals': '🏆', 'champion': '🏆', 'runner-up': '🥈', 'third place': '🥉', 'snowman': '☃️', 'snowmen': '⛄️'
'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
@@ -168,14 +177,16 @@ def sendWithEmoji(message):
i += 1
return ' '.join(words)
def tell_joke(nodeID=0, vox=False):
def tell_joke(nodeID=0, vox=False, test=False):
dadjoke = Dadjoke()
if test:
return sendWithEmoji(dadjoke.joke)
try:
if dad_jokes_emojiJokes or vox:
if dad_jokes_emojiJokes:
renderedLaugh = sendWithEmoji(dadjoke.joke)
else:
renderedLaugh = dadjoke.joke
return renderedLaugh
except Exception as e:
return random.choice(lameJokes)

View File

@@ -6,8 +6,8 @@ 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 * # mesh-bot logging
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
@@ -23,7 +23,6 @@ lemonadeLemons = [{'nodeID': 0, 'cost': 4.00, 'count': 8, 'min': 2.00, 'unit': 0
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}]
from modules.settings import lemonadeTracker
def get_sales_amount(potential, unit, price):
"""Gets the sales amount.

View File

@@ -12,7 +12,7 @@ Game Rules:
"""
import pickle
from modules.log import *
from modules.log import logger, getPrettyTime
from datetime import datetime, timedelta
from geopy.distance import geodesic

View File

@@ -4,8 +4,9 @@
import random
import time
import pickle
from modules.log import *
from modules.settings import mindTracker
from modules.log import logger
from modules.system import mindTracker
def chooseDifficultyMMind(message):
usrInput = message.lower()
msg = ''
@@ -210,7 +211,7 @@ def compareCodeMMind(secret_code, user_guess, nodeID):
def playGameMMind(diff, secret_code, turn_count, nodeID, message):
msg = ''
won = False
if turn_count <= 10:
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"
@@ -239,7 +240,7 @@ def playGameMMind(diff, secret_code, turn_count, nodeID, message):
# reset turn count in tracker
for i in range(len(mindTracker)):
if mindTracker[i]['nodeID'] == nodeID:
mindTracker[i]['turns'] = 0
mindTracker[i]['turns'] = 1
mindTracker[i]['secret_code'] = ''
mindTracker[i]['cmd'] = 'new'
@@ -276,6 +277,7 @@ def start_mMind(nodeID, message):
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

View File

@@ -11,7 +11,8 @@
import json
import os
import random
from modules.log import *
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

View File

@@ -1,277 +1,301 @@
# Tic-Tac-Toe game for Meshtastic mesh-bot
# Board positions chosen by numbers 1-9
# 2025
from modules.log import *
import random
import time
import modules.settings as my_settings
from modules.settings import tictactoeTracker
# to molly and jake, I miss you both so much.
useSynchCompression = True
if useSynchCompression:
import zlib
if disable_emojis_in_games:
X = "X"
O = "O"
else:
X = ""
O = "⭕️"
# to (max), molly and jake, I miss you both so much.
class TicTacToe:
def __init__(self):
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, id):
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."]
"""Start a new game"""
games = won = 0
ret = ""
if id in self.game:
games = self.game[id]["games"]
won = self.game[id]["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"
self.game[id] = {
"board": [" "] * 9, # 3x3 board as flat list
"player": X, # Human is X, bot is O
"games": games + 1,
"won": won,
"turn": "human" # whose turn it is
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"
}
ret += self.show_board(id)
ret += "Pick 1-9:"
return ret
def rndTeaPrice(self, tea=42):
"""Return a random tea between 0 and tea."""
return random.uniform(0, tea)
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 show_board(self, id):
"""Display compact board with move numbers"""
g = self.game[id]
b = g["board"]
# Show board with positions
board_str = ""
for i in range(3):
row = ""
for j in range(3):
pos = i * 3 + j
if disable_emojis_in_games:
cell = b[pos] if b[pos] != " " else str(pos + 1)
else:
cell = b[pos] if b[pos] != " " else f" {str(pos + 1)} "
row += cell
if j < 2:
row += " | "
board_str += row
if i < 2:
#board_str += "\n-+-+-\n"
board_str += "\n"
return board_str + "\n"
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 make_move(self, id, position):
"""Make a move for the current player"""
g = self.game[id]
# Validate position
if position < 1 or position > 9:
return False
pos = position - 1
if g["board"][pos] != " ":
return False
# Make human move
g["board"][pos] = X
return True
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 bot_move(self, id):
"""AI makes a move: tries to win, block, or pick random"""
g = self.game[id]
def make_move(self, nodeID, position):
g = self.game[nodeID]
board = g["board"]
# Try to win
move = self.find_winning_move(id, O)
if move != -1:
board[move] = O
return move
# Try to block player
move = self.find_winning_move(id, X)
if move != -1:
board[move] = O
return move
# Pick random move
move = self.find_random_move(id)
if move != -1:
board[move] = O
return move
# No moves possible
return -1
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 find_winning_move(self, id, player):
"""Find a winning move for the given player"""
g = self.game[id]
board = g["board"][:]
# Check all empty positions
for i in range(9):
if board[i] == " ":
board[i] = player
if self.check_winner_on_board(board) == player:
return i
board[i] = " "
return -1
def find_random_move(self, id: str, tea_price: float = 42.0) -> int:
"""Find a random empty position, using time and tea_price for extra randomness."""
board = self.game[id]["board"]
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 == " "]
current_time = time.time()
from_china = self.rndTeaPrice(time.time() % 7) # Correct usage
tea_price = from_china
tea_price = (42 * 7) - (13 / 2) + (tea_price % 5)
if not empty:
return -1
# Combine time and tea_price for a seed
seed = int(current_time * 1000) ^ int(tea_price * 1000)
local_random = random.Random(seed)
local_random.shuffle(empty)
return empty[0]
if empty:
move = random.choice(empty)
board[move] = self.O
return move+1
return -1
def check_winner_on_board(self, board):
"""Check winner on given board state"""
# Winning combinations
wins = [
[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
]
for combo in wins:
if board[combo[0]] == board[combo[1]] == board[combo[2]] != " ":
return board[combo[0]]
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 check_winner(self, id):
"""Check if there's a winner"""
g = self.game[id]
return self.check_winner_on_board(g["board"])
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 is_board_full(self, id):
"""Check if board is full"""
g = self.game[id]
return " " not in g["board"]
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 game_over_msg(self, id):
"""Generate game over message"""
g = self.game[id]
winner = self.check_winner(id)
if winner == X:
g["won"] += 1
return "🎉You won! (n)ew (e)nd"
elif winner == O:
return "🤖Bot wins! (n)ew (e)nd"
else:
return "🤝Tie, The only winning move! (n)ew (e)nd"
def play(self, id, input_msg):
"""Main game play function"""
if id not in self.game:
return self.new_game(id)
# If input is just "tictactoe", show current board
if input_msg.lower().strip() == ("tictactoe" or "tic-tac-toe"):
return self.show_board(id) + "Your turn! Pick 1-9:"
g = self.game[id]
# Parse player move
try:
# Extract just the number from the input
numbers = [char for char in input_msg if char.isdigit()]
if not numbers:
if input_msg.lower().startswith('q'):
self.end_game(id)
return "Game ended. To start a new game, type 'tictactoe'."
elif input_msg.lower().startswith('n'):
return self.new_game(id)
elif input_msg.lower().startswith('b'):
return self.show_board(id) + "Your turn! Pick 1-9:"
position = int(numbers[0])
except (ValueError, IndexError):
return "Enter 1-9, or (e)nd (n)ew game, send (b)oard to see board🧩"
# Make player move
if not self.make_move(id, position):
return "Invalid move! Pick 1-9:"
# Check if player won
if self.check_winner(id):
result = self.game_over_msg(id) + "\n" + self.show_board(id)
self.end_game(id)
return result
# Check for tie
if self.is_board_full(id):
result = self.game_over_msg(id) + "\n" + self.show_board(id)
self.end_game(id)
return result
# Bot's turn
bot_pos = self.bot_move(id)
# Check if bot won
if self.check_winner(id):
result = self.game_over_msg(id) + "\n" + self.show_board(id)
self.end_game(id)
return result
# Check for tie after bot move
if self.is_board_full(id):
result = self.game_over_msg(id) + "\n" + self.show_board(id)
self.end_game(id)
return result
# Continue game
return self.show_board(id) + "Your turn! Pick 1-9:"
def end_game(self, id):
"""Clean up finished game but keep stats"""
if id in self.game:
games = self.game[id]["games"]
won = self.game[id]["won"]
# Remove game but we'll create new one on next play
del self.game[id]
# Preserve stats for next game
self.game[id] = {
"board": [" "] * 9,
"player": X,
"games": games,
"won": won,
"turn": "human"
}
def end(self, id):
"""End game completely (called by 'end' command)"""
if id in self.game:
del self.game[id]
# Global instances for the bot system
tictactoeTracker = []
tictactoe = TicTacToe()
def end(self, nodeID):
"""End and remove the game for the given nodeID."""
if nodeID in self.game:
del self.game[nodeID]

View 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()

View File

@@ -3,12 +3,13 @@
import random
import time
import pickle
from modules.log import *
from modules.log import logger, getPrettyTime
from modules.settings import vpTracker
vpStartingCash = 20
from modules.settings import vpTracker
# Define the Card class
class CardVP:
global vpTracker
card_values = { # value of the ace is high until it needs to be low
2: 2,
@@ -260,6 +261,7 @@ class PlayerVP:
def getLastCmdVp(nodeID):
global vpTracker
last_cmd = ""
for i in range(len(vpTracker)):
if vpTracker[i]['nodeID'] == nodeID:
@@ -267,6 +269,7 @@ def getLastCmdVp(nodeID):
return last_cmd
def setLastCmdVp(nodeID, cmd):
global vpTracker
for i in range(len(vpTracker)):
if vpTracker[i]['nodeID'] == nodeID:
vpTracker[i]['cmd'] = cmd
@@ -296,154 +299,159 @@ def loadHSVp():
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 = ''
# Initialize the player
if getLastCmdVp(nodeID) is None or getLastCmdVp(nodeID=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
# load the player bankroll from 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
player.bankroll = vpTracker[i]['cash']
vpTracker[i]['time'] = time.time()
# 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
# 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."
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']
# 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")
# 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"
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
# redraw done
setLastCmdVp(nodeID, "endGame")
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:
# 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']
# 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()
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'])
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."
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
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
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
View 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']}")

View File

@@ -2,27 +2,28 @@
# K7MHI Kelly Keeton 2024
import json # pip install json
from geopy.geocoders import Nominatim # pip install geopy
import maidenhead as mh # pip install maidenhead
#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 *
#import xml.dom.minidom
from modules.log import logger
from modules.settings import urlTimeoutSeconds, NO_ALERTS, myRegionalKeysDE
trap_list_location_eu = ("ukalert", "ukwx", "ukflood")
trap_list_location_de = ("dealert", "dewx", "deflood")
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)
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 NO_ALERTS
return
if alert:
return "🚨" + alert.get_text(strip=True)
@@ -35,7 +36,7 @@ def get_nina_alerts():
alerts = []
for regionalKey in myRegionalKeysDE:
url = ("https://nina.api.proxy.bund.dev/api31/dashboard/" + regionalKey + ".json")
response = requests.get(url)
response = requests.get(url, timeout=urlTimeoutSeconds)
data = response.json()
for item in data:
@@ -47,13 +48,13 @@ def get_nina_alerts():
return NO_ALERTS
def get_wxUKgov():
# get UK weather warnings
# 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)
response = requests.get(url, timeout=urlTimeoutSeconds)
soup = bs.BeautifulSoup(response.content, 'xml')
items = soup.find_all('item')
@@ -71,7 +72,62 @@ def get_wxUKgov():
def get_floodUKgov():
# get UK flood warnings
# 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

View File

@@ -7,7 +7,7 @@
# https://pythonhosted.org/RPIO/
import RPIO
from modules.log import *
from modules.log import logger, getPrettyTime
trap_list_gpio = ("gpio", "pin", "relay", "switch", "pwm")
# set up input channel without pull-up

423
modules/inventory.md Normal file
View 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
View 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
View 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/
---

View File

@@ -2,29 +2,29 @@
# 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 *
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
if not rawLLMQuery:
# this may be removed in the future
from googlesearch import search # pip install googlesearch-python
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
openaiAPI = "https://api.openai.com/v1/completions" # not used, if you do push a enhancement!
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
llmContext_fromGoogle = True # enable context from google search results adds to compute time but really helps with responses accuracy
googleSearchResults = 3 # number of google search results to include in the context more results = more compute time
antiFloodLLM = []
llmChat_history = {}
trap_list_llm = ("ask:", "askai")
@@ -50,24 +50,6 @@ meshBotAI = """
"""
if llmContext_fromGoogle:
meshBotAI = meshBotAI + """
CONTEXT
The following is the location of the user
{location_name}
The following is for context around the prompt to help guide your response.
{context}
"""
else:
meshBotAI = meshBotAI + """
CONTEXT
The following is the location of the user
{location_name}
"""
if llmEnableHistory:
meshBotAI = meshBotAI + """
HISTORY
@@ -99,22 +81,6 @@ def llmTool_math_calculator(expression):
except Exception as e:
return f"Error in calculation: {e}"
def llmTool_get_google(query, num_results=3):
"""
Example tool function to perform a Google search and return results.
:param query: The search query string.
:param num_results: Number of search results to return.
:return: A list of search result titles and descriptions.
"""
results = []
try:
googleSearch = search(query, advanced=True, num_results=num_results)
for result in googleSearch:
results.append(f"{result.title}: {result.description}")
return results
except Exception as e:
return [f"Error in Google search: {e}"]
llmFunctions = [
{
@@ -139,54 +105,176 @@ llmFunctions = [
"required": ["expression"]
}
},
{
"name": "llmTool_get_google",
"description": "Perform a Google search and return results.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query string."
},
"num_results": {
"type": "integer",
"description": "Number of search results to return.",
"default": 3
}
},
"required": ["query"]
}
}
]
def get_google_context(input, num_results):
# Get context from Google search results
googleResults = []
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:
googleSearch = search(input, advanced=True, num_results=num_results)
if googleSearch:
for result in googleSearch:
googleResults.append(f"{result.title} {result.description}")
else:
googleResults = ['no other context provided']
# 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: context gathering failed, likely due to network issues")
googleResults = ['no other context provided']
return googleResults
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
result = requests.post(ollamaAPI, data=json.dumps(llmQuery))
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:
raise Exception(f"HTTP Error: {result.status_code}")
return result
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):
"""
@@ -212,24 +300,28 @@ def send_ollama_tooling_query(prompt, functions, model=None, max_tokens=450):
else:
raise Exception(f"HTTP Error: {result.status_code} - {result.text}")
def llm_query(input, nodeID=0, location_name=None):
def llm_query(input, nodeID=0, location_name=None, init=False):
global antiFloodLLM, llmChat_history
googleResults = []
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 input == " " and rawLLMQuery:
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
else:
elif init:
input = input.strip()
# classic model for gemma2, deepseek-r1, etc
logger.debug(f"System: Using classic LLM model framework, ideally 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 askai: and ask: from the input
# 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()
@@ -244,34 +336,84 @@ def llm_query(input, nodeID=0, location_name=None):
else:
antiFloodLLM.append(nodeID)
if llmContext_fromGoogle and not rawLLMQuery:
googleResults = get_google_context(input, googleSearchResults)
# 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, ["", ""])
if googleResults:
logger.debug(f"System: Google-Enhanced LLM Query: {input} From:{nodeID}")
else:
logger.debug(f"System: LLM Query: {input} From:{nodeID}")
response = ""
result = ""
location_name += f" at the current time of {datetime.now().strftime('%Y-%m-%d %H:%M:%S %Z')}"
try:
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
else:
# Build the query from the template
modelPrompt = meshBotAI.format(input=input, context='\n'.join(googleResults), location_name=location_name, llmModel=llmModel, history=history)
# Use OpenWebUI if enabled
if useOpenWebUI and openWebUIAPIKey:
logger.debug(f"System: LLM Query: Using OpenWebUI API for LLM query {input} From:{nodeID}")
llmQuery = {"model": llmModel, "prompt": modelPrompt, "stream": False, "max_tokens": tokens}
# Query the model via Ollama web API
result = send_ollama_query(llmQuery)
# 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:
@@ -283,13 +425,17 @@ def llm_query(input, nodeID=0, location_name=None):
response = result.strip().replace('\n', ' ')
if rawLLMQuery and requestTruncation and len(response) > 450:
#retryy loop to truncate the response
# retry loop to truncate the response
logger.warning(f"System: LLM Query: Response exceeded {tokens} characters, requesting truncation")
truncateQuery = {"model": llmModel, "prompt": truncatePrompt + response, "stream": False, "max_tokens": tokens}
truncateResult = send_ollama_query(truncateQuery)
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 = result.strip().replace('\n', ' ')
response = truncateResult.strip().replace('\n', ' ')
# done with the query, remove the user from the anti flood list
antiFloodLLM.remove(nodeID)

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,11 @@
import logging
from logging.handlers import TimedRotatingFileHandler
import re
from datetime import datetime, timedelta
from modules.settings import *
import modules.settings as my_settings
# if LOGGING_LEVEL is not set in settings.py, default to DEBUG
if not LOGGING_LEVEL:
LOGGING_LEVEL = "DEBUG"
if not my_settings.LOGGING_LEVEL:
my_settings.LOGGING_LEVEL = "DEBUG"
LOGGING_LEVEL = getattr(logging, LOGGING_LEVEL)
LOGGING_LEVEL = getattr(logging, my_settings.LOGGING_LEVEL)
class CustomFormatter(logging.Formatter):
grey = '\x1b[38;21m'
@@ -38,11 +36,17 @@ class CustomFormatter(logging.Formatter):
return formatter.format(record)
class plainFormatter(logging.Formatter):
ansi_escape = re.compile(r'\x1b\[([0-9]+)(;[0-9]+)*m')
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)
return self.ansi_escape.sub('', message)
for code in self.ansi_codes:
message = message.replace(code, '')
return message
# Create logger
logger = logging.getLogger("MeshBot System Logger")
@@ -56,7 +60,6 @@ msgLogger.propagate = False
# Define format for logs
logFormat = '%(asctime)s | %(levelname)8s | %(message)s'
msgLogFormat = '%(asctime)s | %(message)s'
today = datetime.now()
# Create stdout handler for logging to the console
stdout_handler = logging.StreamHandler()
@@ -67,16 +70,16 @@ stdout_handler.setFormatter(CustomFormatter(logFormat))
# Add handlers to the logger
logger.addHandler(stdout_handler)
if syslog_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=log_backup_count, encoding='utf-8')
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 log_messages_to_file:
if my_settings.log_messages_to_file:
# Create file handler for logging to a file
file_handler = TimedRotatingFileHandler('logs/messages.log', when='midnight', backupCount=log_backup_count, encoding='utf-8')
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)

View File

@@ -1,18 +1,29 @@
# 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 *
from modules.log import logger
from modules.settings import qrz_db
def initalize_qrz_database():
# create the database
conn = sqlite3.connect(qrz_db)
c = conn.cursor()
# Check if the qrz table exists, and create it if it doesn't
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()
conn.close()
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

55
modules/radio.md Normal file
View 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 systems 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/)
---

View File

@@ -3,52 +3,44 @@
# depends on rigctld running externally as a network service
# 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
# 2024 Kelly Keeton K7MHI
# 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
from modules.log import *
import asyncio
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,
)
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"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"RadioMon: VOX detection disabled due to import error")
# module global variables
previousStrength = -40
signalCycle = 0
FREQ_NAME_MAP = {
462562500: "GRMS CH1",
@@ -89,6 +81,140 @@ FREQ_NAME_MAP = {
# 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)
@@ -100,12 +226,15 @@ def get_freq_common_name(freq):
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:
logger.error(f"RadioMon: Error connecting to rigctld: {e}")
logger.error(f"System: RadioMon: Error connecting to rigctld: {e}")
return ERROR_FETCHING_DATA
try:
@@ -119,7 +248,7 @@ def get_hamlib(msg="f"):
data = data.replace(b'\n',b'')
return data.decode("utf-8").rstrip()
except Exception as e:
logger.error(f"RadioMon: Error fetching data from rigctld: {e}")
logger.error(f"System: RadioMon: Error fetching data from rigctld: {e}")
return ERROR_FETCHING_DATA
def get_sig_strength():
@@ -129,7 +258,7 @@ def get_sig_strength():
def checkVoxTrapWords(text):
try:
if not voxOnTrapList:
logger.debug(f"RadioMon: VOX detected: {text}")
logger.debug(f"System: RadioMon: VOX detected: {text}")
return text
if text:
traps = [voxTrapList] if isinstance(voxTrapList, str) else voxTrapList
@@ -139,27 +268,27 @@ def checkVoxTrapWords(text):
trap_lower = trap_clean.lower()
idx = text_lower.find(trap_lower)
if debugVoxTmsg:
logger.debug(f"RadioMon: VOX checking for trap word '{trap_lower}' in: '{text}' (index: {idx})")
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"RadioMon: VOX detected trap word '{trap_lower}' in: '{text}' (remaining: '{new_text}')")
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"RadioMon: VOX action '{word}' with '{new_text}'")
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"RadioMon: VOX returning text after trap word '{trap_lower}': '{new_text}'")
logger.debug(f"System: RadioMon: VOX returning text after trap word '{trap_lower}': '{new_text}'")
return new_text
if debugVoxTmsg:
logger.debug(f"RadioMon: VOX no trap word found in: '{text}'")
logger.debug(f"System: RadioMon: VOX no trap word found in: '{text}'")
return None
except Exception as e:
logger.debug(f"RadioMon: Error in checkVoxTrapWords: {e}")
logger.debug(f"System: RadioMon: Error in checkVoxTrapWords: {e}")
return None
async def signalWatcher():
@@ -169,7 +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"RadioMon: {message}. Waiting for {signalHoldTime} seconds")
logger.debug(f"System: RadioMon: {message}. Waiting for {signalHoldTime} seconds")
previousStrength = signalStrength
signalCycle = 0
await asyncio.sleep(signalHoldTime)
@@ -189,7 +318,7 @@ async def signalWatcher():
async def make_vox_callback(loop, q):
def vox_callback(indata, frames, time, status):
if status:
logger.warning(f"RadioMon: VOX input status: {status}")
logger.warning(f"System: RadioMon: VOX input status: {status}")
try:
loop.call_soon_threadsafe(q.put_nowait, bytes(indata))
except asyncio.QueueFull:
@@ -202,7 +331,7 @@ async def make_vox_callback(loop, q):
loop.call_soon_threadsafe(q.put_nowait, bytes(indata))
except asyncio.QueueFull:
# If still full, just drop this frame
logger.debug("RadioMon: VOX queue full, dropping audio frame")
logger.debug("System: RadioMon: VOX queue full, dropping audio frame")
except RuntimeError:
# Loop may be closed
pass
@@ -214,7 +343,7 @@ async def voxMonitor():
model = voxModel
device_info = sd.query_devices(voxInputDevice, 'input')
samplerate = 16000
logger.debug(f"RadioMon: VOX monitor started on device {device_info['name']} with samplerate {samplerate} using trap words: {voxTrapList if voxOnTrapList else 'none'}")
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)
@@ -241,6 +370,267 @@ async def voxMonitor():
await asyncio.sleep(0.1)
except Exception as e:
logger.error(f"RadioMon: Error in VOX monitor: {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

View File

@@ -1,9 +1,16 @@
# rss feed module for meshing-around 2025
from modules.log import *
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):
@@ -16,9 +23,12 @@ class MLStripper(HTMLParser):
return ''.join(self.fed)
def strip_tags(html_text):
s = MLStripper()
s.feed(html_text)
return s.get_data()
# 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
@@ -48,48 +58,127 @@ def get_rss_feed(msg):
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': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'}
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)
# Try both namespaced and non-namespaced item tags
items = root.findall('.//item')
ns = None
if not items:
# Try to find the namespace dynamically
for elem in root.iter():
if elem.tag.endswith('item'):
ns_uri = elem.tag.split('}')[0].strip('{')
items = root.findall(f'.//{{{ns_uri}}}item')
ns = ns_uri
break
# 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:
return "No RSS feed entries found."
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:
if ns:
title = item.findtext(f'{{{ns}}}title', default='No title')
link = item.findtext(f'{{{ns}}}link', default=None)
description = item.findtext(f'{{{ns}}}description', default='No description')
pub_date = item.findtext(f'{{{ns}}}pubDate', default='No date')
else:
title = item.findtext('title', default='No title')
link = item.findtext('link', default=None)
description = item.findtext('description', default='No description')
pub_date = item.findtext('pubDate', default='No date')
# 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)
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

View File

@@ -1,104 +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, BroadcastScheduler
from modules.system import send_message
# methods available for custom scheduler messages
from mesh_bot import tell_joke, welcome_message, MOTD, handle_wxc, handle_moon, handle_sun, handle_riverFlow, handle_tide, handle_satpass
from modules.settings import MOTD, schedulerMotd, schedulerMessage, schedulerChannel, schedulerInterface, schedulerValue, schedulerTime, schedulerInterval
async def setup_scheduler(
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, logger, BroadcastScheduler
):
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 = int(schedulerChannel)
schedulerInterface = int(schedulerInterface)
# Setup the scheduler based on configuration
schedulerChannel = safe_int(schedulerChannel, 0, type="channel")
schedulerInterface = safe_int(schedulerInterface, 1, type="interface")
schedulerIntervalInt = safe_int(schedulerInterval, 5, type="interval")
try:
if schedulerMotd:
scheduler_message = MOTD
else:
scheduler_message = schedulerMessage
scheduler_message = MOTD if schedulerMotd else schedulerMessage
def send_sched_msg():
send_message(scheduler_message, schedulerChannel, 0, schedulerInterface)
# Basic Scheduler Options
if 'custom' not in schedulerValue:
# Basic scheduler job to run the schedule see examples below for custom schedules
if schedulerValue.lower() == 'day':
if schedulerTime != '':
schedule.every().day.at(schedulerTime).do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface))
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:
schedule.every(int(schedulerInterval)).days.do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface))
elif 'mon' in schedulerValue.lower() and schedulerTime != '':
schedule.every().monday.at(schedulerTime).do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface))
elif 'tue' in schedulerValue.lower() and schedulerTime != '':
schedule.every().tuesday.at(schedulerTime).do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface))
elif 'wed' in schedulerValue.lower() and schedulerTime != '':
schedule.every().wednesday.at(schedulerTime).do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface))
elif 'thu' in schedulerValue.lower() and schedulerTime != '':
schedule.every().thursday.at(schedulerTime).do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface))
elif 'fri' in schedulerValue.lower() and schedulerTime != '':
schedule.every().friday.at(schedulerTime).do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface))
elif 'sat' in schedulerValue.lower() and schedulerTime != '':
schedule.every().saturday.at(schedulerTime).do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface))
elif 'sun' in schedulerValue.lower() and schedulerTime != '':
schedule.every().sunday.at(schedulerTime).do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface))
elif 'hour' in schedulerValue.lower():
schedule.every(int(schedulerInterval)).hours.do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface))
elif 'min' in schedulerValue.lower():
schedule.every(int(schedulerInterval)).minutes.do(lambda: send_message(scheduler_message, schedulerChannel, 0, schedulerInterface))
logger.debug(f"System: Starting the basic scheduler to send '{scheduler_message}' on schedule '{schedulerValue}' every {schedulerInterval} interval at time '{schedulerTime}' on Device:{schedulerInterface} Channel:{schedulerChannel}")
else:
# Default schedule if no valid configuration is provided
# custom scheduler job to run the schedule see examples below
logger.debug(f"System: Starting the scheduler to send reminder every Monday at noon on Device:{schedulerInterface} Channel:{schedulerChannel}")
schedule.every().monday.at("12:00").do(lambda: logger.info("System: Scheduled Broadcast Enabled Reminder"))
# send a joke every 15 minutes
#schedule.every(15).minutes.do(lambda: send_message(tell_joke(), schedulerChannel, 0, schedulerInterface))
# Start the Broadcast Scheduler
await BroadcastScheduler()
# 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}")
# Enhanced Examples of using the scheduler, Times here are in 24hr format
# https://schedule.readthedocs.io/en/stable/
# Good Morning Every day at 09:00 using send_message function to channel 2 on device 1
#schedule.every().day.at("09:00").do(lambda: send_message("Good Morning", 2, 0, 1))
# Send WX every Morning at 08:00 using handle_wxc function to channel 2 on device 1
#schedule.every().day.at("08:00").do(lambda: send_message(handle_wxc(0, 1, 'wx'), 2, 0, 1))
# Send Weather Channel Notice Wed. Noon on channel 2, device 1
#schedule.every().wednesday.at("12:00").do(lambda: send_message("Weather alerts available on 'Alerts' channel with default 'AQ==' key.", 2, 0, 1))
# Send config URL for Medium Fast Network Use every other day at 10:00 to default channel 2 on device 1
#schedule.every(2).days.at("10:00").do(lambda: send_message("Join us on Medium Fast https://meshtastic.org/e/#CgcSAQE6AggNEg4IARAEOAFAA0gBUB5oAQ", 2, 0, 1))
# Send a Net Starting Now Message Every Wednesday at 19:00 using send_message function to channel 2 on device 1
#schedule.every().wednesday.at("19:00").do(lambda: send_message("Net Starting Now", 2, 0, 1))
# Send a Welcome Notice for group on the 15th and 25th of the month at 12:00 using send_message function to channel 2 on device 1
#schedule.every().day.at("12:00").do(lambda: send_message("Welcome to the group", 2, 0, 1)).day(15, 25)
# Send a Welcome Notice for group on the 15th and 25th of the month at 12:00
#schedule.every().day.at("12:00").do(lambda: send_message("Welcome to the group", schedulerChannel, 0, schedulerInterface)).day(15, 25)
# Send a joke every 6 hours
#schedule.every(6).hours.do(lambda: send_message(tell_joke(), schedulerChannel, 0, schedulerInterface))
# Send a joke every 2 minutes
#schedule.every(2).minutes.do(lambda: send_message(tell_joke(), schedulerChannel, 0, schedulerInterface))
# Send the Welcome Message every other day at 08:00
#schedule.every(2).days.at("08:00").do(lambda: send_message(welcome_message, schedulerChannel, 0, schedulerInterface))
# Send the MOTD every day at 13:00
#schedule.every().day.at("13:00").do(lambda: send_message(MOTD, schedulerChannel, 0, schedulerInterface))
# Send bbslink looking for peers every other day at 10:00
#schedule.every(2).days.at("10:00").do(lambda: send_message("bbslink MeshBot looking for peers", schedulerChannel, 0, schedulerInterface))
return True

View File

@@ -32,6 +32,11 @@ cmdHistory = [] # list to hold the command history for lheard and history comman
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
@@ -45,6 +50,14 @@ 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()
@@ -71,7 +84,7 @@ if 'sentry' not in config:
config.write(open(config_file, 'w'))
if 'location' not in config:
config['location'] = {'enabled': 'True', 'lat': '48.50', 'lon': '-123.0', 'UseMeteoWxAPI': 'False', 'useMetric': 'False', 'NOAAforecastDuration': '4', 'NOAAalertCount': '2', 'NOAAalertsEnabled': 'True', 'wxAlertBroadcastEnabled': 'False', 'wxAlertBroadcastChannel': '2', 'repeaterLookup': 'rbook'}
config['location'] = {'enabled': 'True', 'lat': '48.50', 'lon': '-123.0', 'fuzzConfigLocation': 'True',}
config.write(open(config_file, 'w'))
if 'bbs' not in config:
@@ -118,6 +131,14 @@ 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', '')
@@ -239,6 +260,7 @@ try:
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)
@@ -249,6 +271,10 @@ try:
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
@@ -258,12 +284,10 @@ try:
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(',')
# 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(',')
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
@@ -271,6 +295,7 @@ try:
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
@@ -281,6 +306,9 @@ try:
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)
@@ -299,28 +327,50 @@ try:
coastalForecastDays = config['location'].getint('coastalForecastDays', 3) # default 3 days
# location alerts
emergencyAlertBrodcastEnabled = config['location'].getboolean('eAlertBroadcastEnabled', False) # default False
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
wxAlertsEnabled = config['location'].getboolean('NOAAalertsEnabled', True) # default True
ignoreEASenable = config['location'].getboolean('ignoreEASenable', False) # default False
ignoreEASwords = config['location'].get('ignoreEASwords', 'test,advisory').split(',') # default test,advisory
myRegionalKeysDE = config['location'].get('myRegionalKeysDE', '110000000000').split(',') # default city Berlin
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
ignoreFEMAenable = config['location'].getboolean('ignoreFEMAenable', True) # default True
ignoreFEMAwords = config['location'].get('ignoreFEMAwords', 'test,exercise').split(',') # default test,exercise
wxAlertBroadcastChannel = config['location'].get('wxAlertBroadcastCh', '2').split(',') # default Channel 2
emergencyAlertBroadcastCh = config['location'].get('eAlertBroadcastCh', '2').split(',') # default Channel 2
volcanoAlertBroadcastEnabled = config['location'].getboolean('volcanoAlertBroadcastEnabled', False) # default False
volcanoAlertBroadcastChannel = config['location'].get('volcanoAlertBroadcastCh', '2').split(',') # default Channel 2
ignoreUSGSEnable = config['location'].getboolean('ignoreVolcanoEnable', False) # default False
ignoreUSGSWords = config['location'].get('ignoreVolcanoWords', 'test,advisory').split(',') # default test,advisory
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', 'data/bbsdb.pkl')
@@ -334,6 +384,7 @@ try:
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)
@@ -341,6 +392,16 @@ try:
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)
@@ -374,6 +435,7 @@ try:
# 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
sigWatchBroadcastCh = config['radioMon'].get('sigWatchBroadcastCh', '2').split(',') # default Channel 2
sigWatchBroadcastInterface = config['radioMon'].getint('sigWatchBroadcastInterface', 1) # default interface 1
@@ -390,6 +452,17 @@ try:
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)
@@ -398,10 +471,13 @@ try:
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('2factor_enabled', True) # default True
xCmd2factor_timeout = config['fileMon'].getint('2factor_timeout', 100) # default 100 seconds
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
@@ -420,6 +496,8 @@ try:
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
@@ -434,8 +512,16 @@ try:
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)

View File

@@ -3,7 +3,12 @@
# https://avtech.com/articles/138/list-of-email-to-sms-addresses/
# 2024 Kelly Keeton K7MHI
from modules.log import *
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

View File

@@ -7,7 +7,10 @@ import xml.dom.minidom
from datetime import datetime
import ephem # pip install pyephem
from datetime import timezone
from modules.log import *
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")
@@ -34,19 +37,27 @@ def hf_band_conditions():
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:
logger.error("Solar: Error fetching solar conditions")
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
@@ -65,6 +76,77 @@ def drap_xray_conditions():
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()

View File

@@ -10,8 +10,11 @@
import json
import os # For file operations
import csv
from datetime import datetime
from collections import Counter
from modules.log import *
from modules.log import logger
from modules.settings import surveyRecordLocation, surveyRecordID
allowedSurveys = [] # List of allowed survey names
@@ -61,8 +64,9 @@ class SurveyModule:
'answers': [],
'location': location if surveyRecordLocation and location is not None else 'N/A'
}
msg = f"'{survey_name}'📝survey\nSend answer' or 'end'\n"
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}")
@@ -95,18 +99,99 @@ class SurveyModule:
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:
row = list(map(str, self.responses[user_id]['answers']))
if surveyRecordID:
row.insert(0, str(user_id))
if surveyRecordLocation:
location = self.responses[user_id].get('location')
row.insert(1 if surveyRecordID else 0, str(location) if location is not None else "N/A")
# 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."""
@@ -125,7 +210,8 @@ class SurveyModule:
return "Please answer with a letter (A, B, C, ...)."
option_index = ord(answer_char) - 65
if 0 <= option_index < len(question['options']):
self.responses[user_id]['answers'].append(str(option_index))
# 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:

File diff suppressed because it is too large Load Diff

466
modules/test_bot.py Normal file
View 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()

View File

@@ -1,122 +1,135 @@
# meshbot wiki module
from modules.log import *
import wikipedia # pip install wikipedia
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
# Kiwix support for local wiki
if use_kiwix_server:
import requests
from bs4 import BeautifulSoup
from urllib.parse import quote
from bs4.element import Comment
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
# Kiwix helper functions (only loaded if use_kiwix_server is True)
if wikipedia_enabled and use_kiwix_server:
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, 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 text_from_html(body):
"""Extract visible text from HTML content"""
soup = BeautifulSoup(body, 'html.parser')
texts = soup.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)
def get_kiwix_summary(search_term):
"""Query local Kiwix server for Wikipedia article"""
try:
search_encoded = quote(search_term)
# Try direct article access first
wiki_article = search_encoded.capitalize().replace("%20", "_")
exact_url = f"{kiwix_url}/raw/{kiwix_library_name}/content/A/{wiki_article}"
response = requests.get(exact_url, timeout=urlTimeoutSeconds)
if response.status_code == 200:
# Extract and clean text
text = text_from_html(response.text)
# Remove common Wikipedia metadata prefixes
text = text.split("Jump to navigation", 1)[-1]
text = text.split("Jump to search", 1)[-1]
# Truncate to reasonable length (first few sentences)
sentences = text.split('. ')
summary = '. '.join(sentences[:wiki_return_limit])
if summary and not summary.endswith('.'):
summary += '.'
return summary.strip()[:500] # Hard limit at 500 chars
# If direct access fails, try search
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 = BeautifulSoup(response.text, 'html.parser')
links = [a['href'] for a in soup.find_all('a', href=True) if "start=" not in a['href']]
for link in links[:3]: # Check first 3 results
article_name = link.split("/")[-1]
if not article_name or article_name[0].islower():
continue
article_url = f"{kiwix_url}{link}"
article_response = requests.get(article_url, timeout=urlTimeoutSeconds)
if article_response.status_code == 200:
text = text_from_html(article_response.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 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]
logger.warning(f"System: No Kiwix Results for:{search_term}")
# try to fall back to online Wikipedia if available
return get_wikipedia_summary(search_term, force=True)
except requests.RequestException as e:
logger.warning(f"System: Kiwix connection error: {e}")
return "Unable to connect to local wiki server"
except Exception as e:
logger.warning(f"System: Error with Kiwix for:{search_term} {e}")
return ERROR_FETCHING_DATA
else:
return summary.strip()
def get_wikipedia_summary(search_term, location=None, force=False):
lat, lon = location if location else (None, None)
# Use Kiwix if configured
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:
# Otherwise use online Wikipedia
wikipedia_search = wikipedia.search(search_term, results=3)
wikipedia_suggest = wikipedia.suggest(search_term)
#wikipedia_aroundme = wikipedia.geosearch(lat,lon, results=3)
#logger.debug(f"System: Wikipedia Nearby:{wikipedia_aroundme}")
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.debug(f"System: Wikipedia search error for:{search_term} {e}")
logger.warning(f"System: Wikipedia API error for:{search_term} {e}")
return ERROR_FETCHING_DATA
if len(wikipedia_search) == 0:
logger.warning(f"System: No Wikipedia Results for:{search_term}")
return ERROR_FETCHING_DATA
try:
logger.debug(f"System: Searching Wikipedia for:{search_term}, First Result:{wikipedia_search[0]}, Suggest Word:{wikipedia_suggest}")
summary = wikipedia.summary(search_term, sentences=wiki_return_limit, auto_suggest=False, redirect=True)
except wikipedia.DisambiguationError as e:
logger.warning(f"System: Disambiguation Error for:{search_term} trying {wikipedia_search[0]}")
summary = wikipedia.summary(wikipedia_search[0], sentences=wiki_return_limit, auto_suggest=True, redirect=True)
except wikipedia.PageError as e:
logger.warning(f"System: Wikipedia Page Error for:{search_term} {e} trying {wikipedia_search[0]}")
summary = wikipedia.summary(wikipedia_search[0], sentences=wiki_return_limit, auto_suggest=True, redirect=True)
except Exception as e:
logger.warning(f"System: Error with Wikipedia for:{search_term} {e}")
return ERROR_FETCHING_DATA
return summary

View File

@@ -3,7 +3,8 @@
import requests
import json
from modules.log import *
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)

View File

@@ -10,8 +10,10 @@ except ImportError:
import asyncio
import time # for sleep, get some when you can :)
from datetime import datetime
import random
from modules.log import *
from modules.log import logger, CustomFormatter, msgLogger
import modules.settings as my_settings
from modules.system import *
# Global Variables
@@ -50,9 +52,6 @@ def auto_response(message, snr, rssi, hop, pkiStatus, message_from_id, channel_n
logger.debug(f"System: Bot detected Commands:{cmds}")
# run the first command after sorting
bot_response = command_handler[cmds[0]['cmd']]()
# wait a responseDelay to avoid message collision from lora-ack
time.sleep(responseDelay)
return bot_response
@@ -66,36 +65,51 @@ def handle_cmd(message, message_from_id, deviceID):
def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, channel_number):
global multiPing
if "?" in message and isDM:
return message.split("?")[0].title() + " command returns SNR and RSSI, or hopcount from your message. Try adding e.g. @place or #tag"
pingHelp = "🤖Ping Command Help:\n" \
"🏓 Send 'ping' or 'ack' or 'test' to get a response.\n" \
"🏓 Send 'ping <number>' to get multiple pings in DM"
"🏓 ping @USERID to send a Joke from the bot"
return pingHelp
msg = ""
type = ''
if "ping" in message.lower():
msg = "🏓PONG\n"
msg = "🏓PONG"
type = "🏓PING"
elif "test" in message.lower() or "testing" in message.lower():
msg = random.choice(["🎙Testing 1,2,3\n", "🎙Testing\n",\
"🎙Testing, testing\n",\
"🎙Ah-wun, ah-two...\n", "🎙Is this thing on?\n",\
"🎙Roger that!\n",])
msg = random.choice(["🎙Testing 1,2,3", "🎙Testing",\
"🎙Testing, testing",\
"🎙Ah-wun, ah-two...", "🎙Is this thing on?",\
"🎙Roger that!",])
type = "🎙TEST"
elif "ack" in message.lower():
msg = random.choice(["✋ACK-ACK!\n", "✋Ack to you!\n"])
type = "✋ACK"
elif "cqcq" in message.lower() or "cq" in message.lower() or "cqcqcq" in message.lower():
if deviceID == 1:
myname = get_name_from_number(myNodeNum1, 'short', 1)
myname = get_name_from_number(deviceID, 'short', 1)
elif deviceID == 2:
myname = get_name_from_number(myNodeNum2, 'short', 2)
myname = get_name_from_number(deviceID, 'short', 2)
msg = f"QSP QSL OM DE {myname} K\n"
else:
msg = "🔊 Can you hear me now?"
if hop == "Direct":
msg = msg + f"SNR:{snr} RSSI:{rssi}"
# append SNR/RSSI or hop info
if hop.startswith("Gateway") or hop.startswith("MQTT"):
msg += " [GW]"
elif hop.startswith("Direct"):
msg += " [RF]"
else:
msg = msg + hop
#flood
msg += " [F]"
if (float(snr) != 0 or float(rssi) != 0) and "Hop" not in hop:
msg += f"\nSNR:{snr} RSSI:{rssi}"
elif "Hop" in hop:
# janky, remove the words Gateway or MQTT if present
hop = hop.replace("Gateway", "").replace("Direct", "").replace("MQTT", "").strip()
msg += f"\n{hop} "
if "@" in message:
msg = msg + " @" + message.split("@")[1]
@@ -125,13 +139,13 @@ def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, chann
pingCount = int(message.split(" ")[1])
if pingCount == 123 or pingCount == 1234:
pingCount = 1
elif not autoPingInChannel and not isDM:
elif not my_settings.autoPingInChannel and not isDM:
# no autoping in channels
pingCount = 1
if pingCount > 51:
pingCount = 50
except:
except ValueError:
pingCount = -1
if pingCount > 1:
@@ -142,7 +156,7 @@ def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, chann
msg = f"🚦Initalizing {pingCount} auto-ping"
# if not a DM add the username to the beginning of msg
if not useDMForResponse and not isDM:
if not my_settings.useDMForResponse and not isDM:
msg = "@" + get_name_from_number(message_from_id, 'short', deviceID) + " " + msg
return msg
@@ -150,10 +164,10 @@ def handle_ping(message_from_id, deviceID, message, hop, snr, rssi, isDM, chann
def handle_motd(message, message_from_id, isDM):
global MOTD
isAdmin = False
msg = ""
msg = MOTD
# check if the message_from_id is in the bbs_admin_list
if bbs_admin_list != ['']:
for admin in bbs_admin_list:
if my_settings.bbs_admin_list != ['']:
for admin in my_settings.bbs_admin_list:
if str(message_from_id) == admin:
isAdmin = True
break
@@ -182,7 +196,7 @@ def handle_echo(message, message_from_id, deviceID, isDM, channel_number):
parts = message.lower().split("echo ", 1)
if len(parts) > 1 and parts[1].strip() != "":
echo_msg = parts[1]
if channel_number != echoChannel:
if channel_number != my_settings.echoChannel:
echo_msg = "@" + get_name_from_number(message_from_id, 'short', deviceID) + " " + echo_msg
return echo_msg
else:
@@ -216,76 +230,123 @@ def onReceive(packet, interface):
rxType = type(interface).__name__
# Valies assinged to the packet
rxNode, message_from_id, snr, rssi, hop, hop_away, channel_number = 0, 0, 0, 0, 0, 0, 0
rxNode = message_from_id = snr = rssi = hop = hop_away = channel_number = hop_start = hop_count = hop_limit = 0
pkiStatus = (False, 'ABC')
replyIDset = False
rxNodeHostName = None
emojiSeen = False
simulator_flag = False
isDM = False
channel_name = "unknown"
session_passkey = None
playingGame = False
if DEBUGpacket:
# Debug print the interface object
for item in interface.__dict__.items(): intDebug = f"{item}\n"
for item in interface.__dict__.items():
intDebug = f"{item}\n"
logger.debug(f"System: Packet Received on {rxType} Interface\n {intDebug} \n END of interface \n")
# Debug print the packet for debugging
logger.debug(f"Packet Received\n {packet} \n END of packet \n")
# set the value for the incomming interface
if rxType == 'SerialInterface':
rxInterface = interface.__dict__.get('devPath', 'unknown')
if port1 in rxInterface: rxNode = 1
elif multiple_interface and port2 in rxInterface: rxNode = 2
elif multiple_interface and port3 in rxInterface: rxNode = 3
elif multiple_interface and port4 in rxInterface: rxNode = 4
elif multiple_interface and port5 in rxInterface: rxNode = 5
elif multiple_interface and port6 in rxInterface: rxNode = 6
elif multiple_interface and port7 in rxInterface: rxNode = 7
elif multiple_interface and port8 in rxInterface: rxNode = 8
elif multiple_interface and port9 in rxInterface: rxNode = 9
# determine the rxNode based on the interface type
if rxType == 'TCPInterface':
rxHost = interface.__dict__.get('hostname', 'unknown')
if rxHost and hostname1 in rxHost and interface1_type == 'tcp': rxNode = 1
elif multiple_interface and rxHost and hostname2 in rxHost and interface2_type == 'tcp': rxNode = 2
elif multiple_interface and rxHost and hostname3 in rxHost and interface3_type == 'tcp': rxNode = 3
elif multiple_interface and rxHost and hostname4 in rxHost and interface4_type == 'tcp': rxNode = 4
elif multiple_interface and rxHost and hostname5 in rxHost and interface5_type == 'tcp': rxNode = 5
elif multiple_interface and rxHost and hostname6 in rxHost and interface6_type == 'tcp': rxNode = 6
elif multiple_interface and rxHost and hostname7 in rxHost and interface7_type == 'tcp': rxNode = 7
elif multiple_interface and rxHost and hostname8 in rxHost and interface8_type == 'tcp': rxNode = 8
elif multiple_interface and rxHost and hostname9 in rxHost and interface9_type == 'tcp': rxNode = 9
rxNodeHostName = interface.__dict__.get('ip', None)
rxNode = next(
(i for i in range(1, 10)
if multiple_interface and rxHost and
globals().get(f'hostname{i}', '').split(':', 1)[0] in rxHost and
globals().get(f'interface{i}_type', '') == 'tcp'),None)
if rxType == 'BLEInterface':
if interface1_type == 'ble': rxNode = 1
elif multiple_interface and interface2_type == 'ble': rxNode = 2
elif multiple_interface and interface3_type == 'ble': rxNode = 3
elif multiple_interface and interface4_type == 'ble': rxNode = 4
elif multiple_interface and interface5_type == 'ble': rxNode = 5
elif multiple_interface and interface6_type == 'ble': rxNode = 6
elif multiple_interface and interface7_type == 'ble': rxNode = 7
elif multiple_interface and interface8_type == 'ble': rxNode = 8
elif multiple_interface and interface9_type == 'ble': rxNode = 9
if rxType == 'SerialInterface':
rxInterface = interface.__dict__.get('devPath', 'unknown')
rxNode = next(
(i for i in range(1, 10)
if globals().get(f'port{i}', '') in rxInterface),None)
# check if the packet has a channel flag use it
if rxType == 'BLEInterface':
rxNode = next(
(i for i in range(1, 10)
if globals().get(f'interface{i}_type', '') == 'ble'),0)
if rxNode is None:
# default to interface 1 ## FIXME needs better like a default interface setting or hash lookup
if 'decoded' in packet and packet['decoded']['portnum'] in ['ADMIN_APP', 'SIMULATOR_APP']:
session_passkey = packet.get('decoded', {}).get('admin', {}).get('sessionPasskey', None)
rxNode = 1
# check if the packet has a channel flag use it ## FIXME needs to be channel hash lookup
if packet.get('channel'):
channel_number = packet.get('channel', 0)
channel_number = packet.get('channel')
channel_name = "unknown"
try:
res = resolve_channel_name(channel_number, rxNode, interface)
if res:
try:
channel_name, _ = res
except Exception:
channel_name = "unknown"
else:
# Search all interfaces for this channel
cache = build_channel_cache()
found_on_other = None
for device in cache:
for chan_name, info in device.get("channels", {}).items():
if str(info.get('number')) == str(channel_number) or str(info.get('hash')) == str(channel_number):
found_on_other = device.get("interface_id")
found_chan_name = chan_name
break
if found_on_other:
break
if found_on_other and found_on_other != rxNode:
logger.debug(
f"System: Received Packet on Channel:{channel_number} ({found_chan_name}) on Interface:{rxNode}, but this channel is configured on Interface:{found_on_other}"
)
except Exception as e:
logger.debug(f"System: channel resolution error: {e}")
#debug channel info
# if "unknown" in str(channel_name):
# logger.debug(f"System: Received Packet on Channel:{channel_number} on Interface:{rxNode}")
# else:
# logger.debug(f"System: Received Packet on Channel:{channel_number} Name:{channel_name} on Interface:{rxNode}")
# check if the packet has a simulator flag
simulator_flag = packet.get('decoded', {}).get('simulator', False)
if isinstance(simulator_flag, dict):
# assume Software Simulator
simulator_flag = True
# set the message_from_id
message_from_id = packet['from']
# check if the packet has a channel flag use it
if packet.get('channel'):
channel_number = packet.get('channel', 0)
# if message_from_id is not in the seenNodes list add it
if not any(node.get('nodeID') == message_from_id for node in seenNodes):
seenNodes.append({'nodeID': message_from_id, 'rxInterface': rxNode, 'channel': channel_number, 'welcome': False, 'first_seen': time.time(), 'lastSeen': time.time()})
else:
# update lastSeen time
for node in seenNodes:
if node.get('nodeID') == message_from_id:
node['lastSeen'] = time.time()
break
# CHECK with ban_hammer() if the node is banned
if str(message_from_id) in my_settings.bbs_ban_list or str(message_from_id) in my_settings.autoBanlist:
logger.warning(f"System: Banned Node {message_from_id} tried to send a message. Ignored. Try adding to node firmware-blocklist")
return
# handle TEXT_MESSAGE_APP
try:
if 'decoded' in packet and packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP':
message_bytes = packet['decoded']['payload']
message_string = message_bytes.decode('utf-8')
via_mqtt = packet['decoded'].get('viaMqtt', False)
transport_mechanism = packet['decoded'].get('transport_mechanism', 'unknown')
# check if the packet is from us
if message_from_id == myNodeNum1 or message_from_id == myNodeNum2:
logger.warning(f"System: Packet from self {message_from_id} loop or traffic replay deteted")
if message_from_id in [myNodeNum1, myNodeNum2, myNodeNum3, myNodeNum4, myNodeNum5, myNodeNum6, myNodeNum7, myNodeNum8, myNodeNum9]:
logger.warning(f"System: Packet from self {message_from_id} loop or traffic replay detected")
# get the signal strength and snr if available
if packet.get('rxSnr') or packet.get('rxRssi'):
@@ -294,53 +355,76 @@ def onReceive(packet, interface):
# check if the packet has a publicKey flag use it
if packet.get('publicKey'):
pkiStatus = (packet.get('pkiEncrypted', False), packet.get('publicKey', 'ABC'))
pkiStatus = packet.get('pkiEncrypted', False), packet.get('publicKey', 'ABC')
# check if the packet has replyId flag // currently unused in the code
if packet.get('replyId'):
replyIDset = packet.get('replyId', False)
# check if the packet has emoji flag set it // currently unused in the code
if packet.get('emoji'):
emojiSeen = packet.get('emoji', False)
# check if the packet has a hop count flag use it
if packet.get('hopsAway'):
hop_away = packet.get('hopsAway', 0)
else:
# if the packet does not have a hop count try other methods
if packet.get('hopLimit'):
hop_limit = packet.get('hopLimit', 0)
else:
hop_limit = 0
if packet.get('hopStart'):
hop_start = packet.get('hopStart', 0)
else:
hop_start = 0
if enableHopLogs:
logger.debug(f"System: Packet HopDebugger: hop_away:{hop_away} hop_limit:{hop_limit} hop_start:{hop_start}")
if packet.get('hopStart'):
hop_start = packet.get('hopStart', 0)
if packet.get('hopLimit'):
hop_limit = packet.get('hopLimit', 0)
if hop_away == 0 and hop_limit == 0 and hop_start == 0:
hop = "Last Hop"
hop_count = 0
if hop_start == hop_limit:
# calculate hop count
hop = ""
if hop_limit > 0 and hop_start >= hop_limit:
hop_count = hop_away + (hop_start - hop_limit)
elif hop_limit > 0 and hop_start < hop_limit:
hop_count = hop_away + (hop_limit - hop_start)
else:
hop_count = hop_away
if hop_count > 0:
# set hop string from calculated hop count
hop = f"{hop_count} Hop" if hop_count == 1 else f"{hop_count} Hops"
if hop_start == hop_limit and "lora" in str(transport_mechanism).lower() and (snr != 0 or rssi != 0) and hop_count == 0:
# 2.7+ firmware direct hop over LoRa
hop = "Direct"
hop_count = 0
elif hop_start == 0 and hop_limit > 0 or via_mqtt:
hop = "MQTT"
hop_count = 0
else:
# set hop to Direct if the message was sent directly otherwise set the hop count
if hop_away > 0:
hop_count = hop_away
else:
hop_count = hop_start - hop_limit
#print (f"calculated hop count: {hop_start} - {hop_limit} = {hop_count}")
hop = f"{hop_count} hops"
if via_mqtt or "mqtt" in str(transport_mechanism).lower():
hop = "MQTT"
via_mqtt = True
elif "udp" in str(transport_mechanism).lower():
hop = "Gateway"
if hop in ("MQTT", "Gateway") and hop_count > 0:
hop = f" {hop_count} Hops"
# Add relay node info if present
if packet.get('relayNode') is not None:
relay_val = packet['relayNode']
last_byte = relay_val & 0xFF
if last_byte == 0x00:
hex_val = 'FF'
else:
hex_val = f"{last_byte:02X}"
hop += f" (Relay:{hex_val})"
if my_settings.enableHopLogs:
logger.debug(f"System: Packet HopDebugger: hop_away:{hop_away} hop_limit:{hop_limit} hop_start:{hop_start} calculated_hop_count:{hop_count} final_hop_value:{hop} via_mqtt:{via_mqtt} transport_mechanism:{transport_mechanism} Hostname:{rxNodeHostName}")
# check with stringSafeChecker if the message is safe
if stringSafeCheck(message_string, message_from_id) is False:
logger.warning(f"System: Possibly Unsafe Message from {get_name_from_number(message_from_id, 'long', rxNode)}")
if help_message in message_string or welcome_message in message_string or "CMD?:" in message_string:
# ignore help and welcome messages
logger.warning(f"Got Own Welcome/Help header. From: {get_name_from_number(message_from_id, 'long', rxNode)}")
return
# If the packet is a DM (Direct Message) respond to it, otherwise validate its a message for us on the channel
if packet['to'] == myNodeNum1 or packet['to'] == myNodeNum2:
if packet['to'] in [myNodeNum1, myNodeNum2, myNodeNum3, myNodeNum4, myNodeNum5, myNodeNum6, myNodeNum7, myNodeNum8, myNodeNum9]:
# message is DM to us
isDM = True
# check if the message contains a trap word, DMs are always responded to
@@ -371,7 +455,7 @@ def onReceive(packet, interface):
send_message(auto_response(message_string, snr, rssi, hop, pkiStatus, message_from_id, channel_number, rxNode, isDM), channel_number, message_from_id, rxNode)
else:
# or respond to channel message on the channel itself
if channel_number == publicChannel and antiSpam:
if channel_number == my_settings.publicChannel and my_settings.antiSpam:
# warning user spamming default channel
logger.warning(f"System: AntiSpam protection, sending DM to: {get_name_from_number(message_from_id, 'long', rxNode)}")
@@ -384,7 +468,7 @@ def onReceive(packet, interface):
else:
# message is not for bot to respond to
# ignore the message but add it to the message history list
if zuluTime:
if my_settings.zuluTime:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
else:
timestamp = datetime.now().strftime("%Y-%m-%d %I:%M:%S%p")
@@ -402,17 +486,17 @@ def onReceive(packet, interface):
msgLogger.info(f"Device:{rxNode} Channel:{channel_number} | {get_name_from_number(message_from_id, 'long', rxNode)} | " + message_string.replace('\n', '-nl-'))
# repeat the message on the other device
if repeater_enabled and multiple_interface:
if my_settings.repeater_enabled and multiple_interface:
# wait a responseDelay to avoid message collision from lora-ack.
time.sleep(responseDelay)
time.sleep(my_settings.responseDelay)
rMsg = (f"{message_string} From:{get_name_from_number(message_from_id, 'short', rxNode)}")
# if channel found in the repeater list repeat the message
if str(channel_number) in repeater_channels:
if str(channel_number) in my_settings.repeater_channels:
for i in range(1, 10):
if globals().get(f'interface{i}_enabled', False) and i != rxNode:
logger.debug(f"Repeating message on Device{i} Channel:{channel_number}")
send_message(rMsg, channel_number, 0, i)
time.sleep(responseDelay)
time.sleep(my_settings.responseDelay)
else:
# Evaluate non TEXT_MESSAGE_APP packets
consumeMetadata(packet, rxNode, channel_number)
@@ -421,66 +505,192 @@ def onReceive(packet, interface):
logger.debug(f"System: Error Packet = {packet}")
async def start_rx():
print (CustomFormatter.bold_white + f"\nMeshtastic Autoresponder Bot CTL+C to exit\n" + CustomFormatter.reset)
# Start the receive subscriber using pubsub via meshtastic library
pub.subscribe(onReceive, 'meshtastic.receive')
pub.subscribe(onDisconnect, 'meshtastic.connection.lost')
for i in range(1, 10):
if globals().get(f'interface{i}_enabled', False):
myNodeNum = globals().get(f'myNodeNum{i}', 0)
logger.info(f"System: Autoresponder Started for Device{i} {get_name_from_number(myNodeNum, 'long', i)},"
f"{get_name_from_number(myNodeNum, 'short', i)}. NodeID: {myNodeNum}, {decimal_to_hex(myNodeNum)}")
if useDMForResponse:
logger.debug(f"System: Respond by DM only")
if log_messages_to_file:
logger.debug("System: Logging Messages to disk")
if syslog_to_file:
logger.debug("System: Logging System Logs to disk")
if motd_enabled:
logger.debug(f"System: MOTD Enabled using {MOTD}")
if enableEcho:
logger.debug(f"System: Echo command Enabled")
if sentry_enabled:
logger.debug(f"System: Sentry Mode Enabled {sentry_radius}m radius reporting to channel:{secure_channel}")
if highfly_enabled:
logger.debug(f"System: HighFly Enabled using {highfly_altitude}m limit reporting to channel:{highfly_channel}")
if repeater_enabled and multiple_interface:
logger.debug(f"System: Repeater Enabled for Channels: {repeater_channels}")
if bbs_enabled:
logger.debug(f"System: BBS Enabled, {bbsdb} has {len(bbs_messages)} messages. Direct Mail Messages waiting: {(len(bbs_dm) - 1)}")
if bbs_link_enabled:
if len(bbs_link_whitelist) > 0:
logger.debug(f"System: BBS Link Enabled with {len(bbs_link_whitelist)} peers")
else:
logger.debug(f"System: BBS Link Enabled allowing all")
if scheduler_enabled:
# Examples of using the scheduler, Times here are in 24hr format
# https://schedule.readthedocs.io/en/stable/
# Reminder Scheduler is enabled every Monday at noon send a log message
schedule.every().monday.at("12:00").do(lambda: logger.info("System: Scheduled Broadcast Reminder"))
logger.debug("System: Starting the broadcast scheduler")
await BroadcastScheduler()
logger.debug("System: RX Subscriber started")
# here we go loopty loo
while True:
await asyncio.sleep(0.5)
pass
def handle_boot(mesh=True):
try:
print (CustomFormatter.bold_white + f"\nMeshtastic Autoresponder Bot CTL+C to exit\n" + CustomFormatter.reset)
if mesh:
for i in range(1, 10):
if globals().get(f'interface{i}_enabled', False):
myNodeNum = globals().get(f'myNodeNum{i}', 0)
logger.info(f"System: Autoresponder Started for Device{i} {get_name_from_number(myNodeNum, 'long', i)},"
f"{get_name_from_number(myNodeNum, 'short', i)}. NodeID: {myNodeNum}, {decimal_to_hex(myNodeNum)}")
if llm_enabled:
logger.debug(f"System: Ollama LLM Enabled, loading model {my_settings.llmModel} please wait")
llmLoad = llm_query(" ")
if "trouble" not in llmLoad:
logger.debug(f"System: LLM Model {my_settings.llmModel} loaded")
if my_settings.bbs_enabled:
logger.debug(f"System: BBS Enabled, {bbsdb} has {len(bbs_messages)} messages. Direct Mail Messages waiting: {(len(bbs_dm) - 1)}")
if my_settings.bbs_link_enabled:
if len(bbs_link_whitelist) > 0:
logger.debug(f"System: BBS Link Enabled with {len(bbs_link_whitelist)} peers")
else:
logger.debug(f"System: BBS Link Enabled allowing all")
if my_settings.solar_conditions_enabled:
logger.debug("System: Celestial Telemetry Enabled")
if my_settings.location_enabled:
if my_settings.use_meteo_wxApi:
logger.debug("System: Location Telemetry Enabled using Open-Meteo API")
else:
logger.debug("System: Location Telemetry Enabled using NOAA API")
print("debug my_settings.scheduler_enabled:", my_settings.scheduler_enabled)
if my_settings.dad_jokes_enabled:
logger.debug("System: Dad Jokes Enabled!")
if my_settings.coastalEnabled:
logger.debug("System: Coastal Forecast and Tide Enabled!")
if games_enabled:
logger.debug("System: Games Enabled!")
if my_settings.wikipedia_enabled:
if my_settings.use_kiwix_server:
logger.debug(f"System: Wikipedia search Enabled using Kiwix server at {kiwix_url}")
else:
logger.debug("System: Wikipedia search Enabled")
if my_settings.rssEnable:
logger.debug(f"System: RSS Feed Reader Enabled for feeds: {rssFeedNames}")
if my_settings.radio_detection_enabled:
logger.debug(f"System: Radio Detection Enabled using rigctld at {my_settings.rigControlServerAddress} broadcasting to channels: {my_settings.sigWatchBroadcastCh} for {get_freq_common_name(get_hamlib('f'))}")
if my_settings.file_monitor_enabled:
logger.warning(f"System: File Monitor Enabled for {my_settings.file_monitor_file_path}, broadcasting to channels: {my_settings.file_monitor_broadcastCh}")
if my_settings.enable_runShellCmd:
logger.debug("System: Shell Command monitor enabled")
if my_settings.allowXcmd:
logger.warning("System: File Monitor shell XCMD Enabled")
if my_settings.read_news_enabled:
logger.debug(f"System: File Monitor News Reader Enabled for {my_settings.news_file_path}")
if my_settings.bee_enabled:
logger.debug("System: File Monitor Bee Monitor Enabled for bee.txt")
if my_settings.wxAlertBroadcastEnabled:
logger.debug(f"System: Weather Alert Broadcast Enabled on channels {my_settings.wxAlertBroadcastChannel}")
if my_settings.emergencyAlertBrodcastEnabled:
logger.debug(f"System: Emergency Alert Broadcast Enabled on channels {my_settings.emergencyAlertBroadcastCh} for FIPS codes {my_settings.myStateFIPSList}")
if my_settings.myStateFIPSList == ['']:
logger.warning("System: No FIPS codes set for iPAWS Alerts")
if my_settings.emergency_responder_enabled:
logger.debug(f"System: Emergency Responder Enabled on channels {my_settings.emergency_responder_alert_channel} for interface {my_settings.emergency_responder_alert_interface}")
if my_settings.volcanoAlertBroadcastEnabled:
logger.debug(f"System: Volcano Alert Broadcast Enabled on channels {my_settings.volcanoAlertBroadcastChannel}")
if my_settings.qrz_hello_enabled:
if my_settings.train_qrz:
logger.debug("System: QRZ Welcome/Hello Enabled with training mode")
else:
logger.debug("System: QRZ Welcome/Hello Enabled")
if my_settings.enableSMTP:
if my_settings.enableImap:
logger.debug("System: SMTP Email Alerting Enabled using IMAP")
else:
logger.warning("System: SMTP Email Alerting Enabled")
# Default Options
if my_settings.useDMForResponse:
logger.debug("System: Respond by DM only")
if my_settings.autoBanEnabled:
logger.debug(f"System: Auto-Ban Enabled for {my_settings.autoBanThreshold} messages in {my_settings.autoBanTimeframe} seconds")
load_bbsBanList()
if my_settings.log_messages_to_file:
logger.debug("System: Logging Messages to disk")
if my_settings.syslog_to_file:
logger.debug("System: Logging System Logs to disk")
if my_settings.motd_enabled:
logger.debug(f"System: MOTD Enabled using {my_settings.MOTD} scheduler:{my_settings.schedulerMotd}")
if my_settings.sentry_enabled:
logger.debug(f"System: Sentry Mode Enabled {my_settings.sentry_radius}m radius reporting to channel:{my_settings.secure_channel} requestLOC:{reqLocationEnabled}")
if my_settings.sentryIgnoreList:
logger.debug(f"System: Sentry BlockList Enabled for nodes: {my_settings.sentryIgnoreList}")
if my_settings.sentryWatchList:
logger.debug(f"System: Sentry WatchList Enabled for nodes: {my_settings.sentryWatchList}")
if my_settings.highfly_enabled:
logger.debug(f"System: HighFly Enabled using {my_settings.highfly_altitude}m limit reporting to channel:{my_settings.highfly_channel}")
if my_settings.store_forward_enabled:
logger.debug(f"System: S&F(messages command) Enabled using limit: {storeFlimit} and reverse queue:{my_settings.reverseSF}")
if my_settings.enableEcho:
logger.debug("System: Echo command Enabled")
if my_settings.repeater_enabled and multiple_interface:
logger.debug(f"System: Repeater Enabled for Channels: {my_settings.repeater_channels}")
if my_settings.checklist_enabled:
logger.debug("System: CheckList Module Enabled")
if my_settings.ignoreChannels:
logger.debug(f"System: Ignoring Channels: {my_settings.ignoreChannels}")
if my_settings.noisyNodeLogging:
logger.debug("System: Noisy Node Logging Enabled")
if my_settings.logMetaStats:
logger.debug("System: Logging Metadata Stats Enabled, leaderboard")
if my_settings.scheduler_enabled:
logger.debug("System: Scheduler Enabled")
except Exception as e:
logger.error(f"System: Error during boot: {e}")
# Hello World
async def main():
tasks = []
try:
handle_boot(mesh=False) # pong bot
# Create core tasks
tasks.append(asyncio.create_task(start_rx(), name="pong_rx"))
tasks.append(asyncio.create_task(start_rx(), name="mesh_rx"))
tasks.append(asyncio.create_task(watchdog(), name="watchdog"))
# Add optional tasks
if file_monitor_enabled:
if my_settings.dataPersistence_enabled:
tasks.append(asyncio.create_task(dataPersistenceLoop(), name="data_persistence"))
if my_settings.file_monitor_enabled:
tasks.append(asyncio.create_task(handleFileWatcher(), name="file_monitor"))
if my_settings.radio_detection_enabled:
tasks.append(asyncio.create_task(handleSignalWatcher(), name="hamlib"))
if my_settings.voxDetectionEnabled:
tasks.append(asyncio.create_task(voxMonitor(), name="vox_detection"))
if my_settings.scheduler_enabled:
from modules.scheduler import run_scheduler_loop, setup_scheduler
setup_scheduler(schedulerMotd, MOTD, schedulerMessage, schedulerChannel, schedulerInterface,
schedulerValue, schedulerTime, schedulerInterval)
tasks.append(asyncio.create_task(run_scheduler_loop(), name="scheduler"))
logger.debug(f"System: Starting {len(tasks)} async tasks")
# Wait for all tasks with proper exception handling

View File

@@ -1,12 +1,9 @@
meshtastic
pubsub
datetime
pyephem
PyPubSub
ephem
requests
maidenhead
beautifulsoup4
dadjokes
geopy
schedule
wikipedia
googlesearch-python
schedule

102
script/README.md Normal file
View File

@@ -0,0 +1,102 @@
## script/runShell.sh
**Purpose:**
`runShell.sh` is a demonstration shell script for the Mesh Bot project, showing how to execute shell commands in the project environment.
**Usage:**
Run this script from the terminal to see a basic shell scripting example:
```sh
bash script/runShell.sh
```
**What it does:**
- Changes to the scripts directory.
- Prints the current directory path.
- Displays a message indicating the script is running.
**Note:**
You can use this as a template for your own shell scripts or to automate project-related tasks.
## script/sysEnv.sh
**Purpose:**
`sysEnv.sh` is a shell script that collects and displays system telemetry and environment information, especially useful for monitoring a Raspberry Pi or similar device running the Mesh Bot.
**Usage:**
Run this script from the terminal to view system stats and network info:
```sh
bash script/sysEnv.sh
```
**What it does:**
- Reports disk space, RAM usage, CPU usage, and CPU temperature (in °C and °F).
- Checks for available Git updates if the project is a Git repository.
- Displays the devices public and local IP addresses.
- Designed to work on Linux systems, with special handling for Raspberry Pi hardware.
**Note:**
You can expand or modify this script to include additional telemetry or environment checks as needed for your deployment.
## script/configMerge.py
**Purpose:**
`configMerge.py` is a Python script that merges your user configuration (`config.ini`) with the default template (`config.template`). This helps you keep your settings up to date when the default configuration changes, while preserving your customizations.
**Usage:**
Run this script from the project root or the `script/` directory:
```sh
python3 script/configMerge.py
```
**What it does:**
- Backs up your current `config.ini` to `config.bak`.
- Merges new or updated settings from `config.template` into your `config.ini`.
- Saves the merged result as `config_new.ini`.
- Shows a summary of changes between your config and the merged version.
**Note:**
After reviewing the changes, you can replace your `config.ini` with the merged version:
```sh
cp config_new.ini config.ini
```
This script is useful for safely updating your configuration when new options are added upstream.
## script/addFav.py
**Purpose:**
`addFav.py` is a Python script to help manage and add favorite nodes to all interfaces using data from `config.ini`. It supports both bot and roof (client_base) node workflows, making it easier to retain DM keys and manage node lists across devices.
**Usage:**
Run this script from the main repo directory:
```sh
python3 script/addFav.py
```
- To print the contents of `roofNodeList.pkl` and exit, use:
```sh
# note it is not production ready
python3 script/addFav.py -p
```
**What it does:**
- Interactively asks if you are running on a roof (client_base) node or a bot.
- On the bot:
- Compiles a list of favorite nodes and saves it to `roofNodeList.pkl` for later use on the roof node.
- On the roof node:
- Loads the node list from `roofNodeList.pkl`.
- Shows which favorite nodes will be added and asks for confirmation.
- Adds favorite nodes to the appropriate devices, handling API rate limits.
- Logs actions and errors for troubleshooting.
**Note:**
- Always run this script from the main repo directory to ensure module imports work.
- After running on the bot, copy `roofNodeList.pkl` to the roof node and rerun the script there to complete the process.

View File

@@ -34,8 +34,10 @@ print("---------------------------------------------------------------")
try:
# set the path to import the modules and config.ini
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from modules.log import *
from modules.system import *
from modules.log import logger, getPrettyTime
from modules.system import handleFavoriteNode
from modules.settings import LOGGING_LEVEL
from modules.system import compileFavoriteList
except Exception as e:
print(f"Error importing modules run this program from the main repo directory 'python3 script/addFav.py'")
print(f"if you forgot the rest of it.. git clone https://github.com/spudgunman/meshing-around")

View File

@@ -1,25 +1,21 @@
# How do I use this thing?
This is not a full turnkey setup for Docker yet but gets you most of the way there!
This is not a full turnkey setup for Docker yet?
## Setup New Image
`docker build -t meshing-around .`
`docker pull ghcr.io/spudgunman/meshing-around:main`
there is also [script/docker/docker-install.bat](script/docker/docker-install.bat) which will automate this.
`docker network create meshing-around-network`
## Ollama Image with compose
still a WIP
`docker compose up -d`
`docker compose run meshtasticd`
## Edit the config.ini in the docker
To edit the config.ini in the docker you can
`docker run -it --entrypoint /bin/bash meshing-around -c "nano /app/config.ini"`
`docker compose run meshing-around`
there is also [script/docker/docker-terminal.bat](script/docker/docker-terminal.bat) which will open nano to edit.
ctl+o to write out and exit editor in shell
`docker compose run debug-console`
## other info
1. Ensure your serial port is properly shared.
2. Run the Docker container:
```sh
docker run --rm -it --device=/dev/ttyUSB0 meshing-around
```
`docker compose run ollama`
`docker run -d -p 3000:8080 -e OLLAMA_BASE_URL=http://127.0.0.1:11434 -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:main`
### Other Stuff
A cool tool to use with RAG creation with open-webui
- https://github.com/microsoft/markitdown

View File

@@ -1,52 +0,0 @@
services:
meshing-around:
build:
context: ../..
depends_on:
ollama:
condition: service_healthy
devices:
- /dev/ttyAMA10 # Replace this with your actual device!
configs:
- source: me_config
target: /app/config.ini
extra_hosts:
- "host.docker.internal:host-gateway" # Used to access a local linux meshtasticd device via tcp
ollama:
image: ollama/ollama:0.5.1
volumes:
- ./ollama:/root/.ollama
- ./ollama-entrypoint.sh:./entrypoint.sh
container_name: ollama
pull_policy: always
tty: true
restart: always
entrypoint:
- /usr/bin/bash
- /script/docker/entrypoint.sh
expose:
- 11434
healthcheck:
test: "apt update && apt install curl -y && curl -f http://localhost:11434/api/tags | grep -q llama3.2:3b"
interval: 30s
timeout: 10s
retries: 20
node-exporter:
image: quay.io/prometheus/node-exporter:latest
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- --path.procfs=/host/proc
- --path.rootfs=/rootfs
- --path.sysfs=/host/sys
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)
restart: unless-stopped
expose:
- 9100
network_mode: host
pid: host
configs:
me_config:
file: ./config.ini

View File

24
script/docker/config.yaml Normal file
View File

@@ -0,0 +1,24 @@
### Template config.yaml
---
Lora:
GPIO:
GPS:
I2C:
Display:
Touchscreen:
Input:
Logging:
LogLevel: info # debug, info, warn, error
# TraceFile: /var/log/meshtasticd.json
# AsciiLogs: true # default if not specified is !isatty() on stdout
Webserver:
Port: 443 # Port for Webserver & Webservices
RootPath: /usr/share/meshtasticd/web # Root Dir of WebServer
# SSLKey: /etc/meshtasticd/ssl/private_key.pem # Path to SSL Key, generated if not present
# SSLCert: /etc/meshtasticd/ssl/certificate.pem # Path to SSL Certificate, generated if not present
General:
MaxNodes: 200
MaxMessageQueue: 100
ConfigDirectory: /etc/meshtasticd/config.d/
# MACAddress: AA:BB:CC:DD:EE:FF
MACAddressSource: eth0

View File

@@ -1,6 +0,0 @@
REM batch file to install docker on windows
REM docker compose up -d
cd ../../
docker build -t meshing-around .
REM docker-compose up -d
docker run -it --entrypoint /bin/bash meshing-around -c "nano /app/config.ini"

View File

@@ -1,2 +0,0 @@
REM launch meshing-around container with a terminal
docker run -it --entrypoint /bin/bash meshing-around

View File

@@ -1,6 +1,16 @@
#!/bin/bash
# instruction set the meshing-around docker container entrypoint
# Substitute environment variables in the config file (what is the purpose of this?)
# envsubst < /app/config.ini > /app/config.tmp && mv /app/config.tmp /app/config.ini
# Run the bot
exec python /app/mesh_bot.py
# if no config.ini exists, copy the default one
if [ ! -f /app/config.ini ]; then
cp /app/config.template /app/config.ini
ls -l /app/config.ini
# Set type = tcp in [interface]
sed -i '/^\[interface\]/,/^[^[]/ s/^type = .*/type = tcp/' /app/config.ini
# Remove any commented or uncommented hostname lines in [interface]
sed -i '/^\[interface\]/,/^[^[]/ s/^#\? *hostname = .*$//' /app/config.ini
# Add hostname = meshtasticd:4403 after [interface]
sed -i '/^\[interface\]/a hostname = UPDATE-DOCKER-IP' /app/config.ini
fi
# Run the bot as appuser (if you want to drop privileges)
exec python /app/mesh_bot.py

View File

@@ -1,16 +0,0 @@
#!/bin/bash
# Start Ollama in the background.
/bin/ollama serve &
# Record Process ID.
pid=$!
# Pause for Ollama to start.
sleep 5
echo "🔴 Retrieve llama3.2:3b model..."
ollama pull llama3.2:3b
echo "🟢 Done!"
# Wait for Ollama process to finish.
wait $pid

15
script/game.ini Normal file
View File

@@ -0,0 +1,15 @@
[network]
MCAST_GRP = 224.0.0.69
MCAST_PORT = 4403
CHANNEL_ID = LongFast
KEY = 1PG7OiApB1nwvP+rz05pAQ==
PUBLIC_CHANNEL_IDS = LongFast,ShortSlow,Medium,LongSlow,ShortFast,ShortTurbo
[node]
NODE_ID = !meshbotg
LONG_NAME = Mesh Bot Game Server
SHORT_NAME = MBGS
[game]
SEEN_MESSAGES_MAX = 1000
FULLSCREEN = True

Some files were not shown because too many files have changed in this diff Show More