Compare commits

...

145 Commits

Author SHA1 Message Date
Ninluc e68ffd9b8b Fix
Build, push image, and notify Watchtower / build-image (push) Successful in 43s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-08-25 20:07:38 +02:00
Ninluc 49fd566457 Idk
Build, push image, and notify Watchtower / build-image (push) Successful in 48s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-08-25 20:01:36 +02:00
Ninluc dd37465546 OOpsie
Build, push image, and notify Watchtower / build-image (push) Successful in 50s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-25 19:23:10 +02:00
Ninluc 3753f57041 Small things because fuck sd cards
Build, push image, and notify Watchtower / build-image (push) Successful in 3m22s
Build, push image, and notify Watchtower / notify (push) Successful in 1m24s
2026-08-25 19:16:50 +02:00
Ninluc 688fd26db8 Changed interface 2026-08-25 19:16:41 +02:00
Ninluc 4d7706f8e1 Adde command to setuot thread network 2026-08-25 19:16:13 +02:00
Ninluc efbc147f48 Default debug to false 2026-08-25 19:16:01 +02:00
Ninluc 4085305039 Save Analyzed image 2026-08-25 19:15:52 +02:00
Ninluc fed99772fc Don't override microwave ID
Build, push image, and notify Watchtower / build-image (push) Successful in 46s
Build, push image, and notify Watchtower / notify (push) Successful in 8s
2026-08-24 15:44:22 +02:00
Ninluc 204f271fc2 Fix global
Build, push image, and notify Watchtower / build-image (push) Successful in 42s
Build, push image, and notify Watchtower / notify (push) Successful in 14s
2026-08-23 20:04:26 +02:00
Ninluc 5c60de3f03 Fix buzzer 2026-08-23 20:04:20 +02:00
Ninluc 8a71d4eaa7 :(
Build, push image, and notify Watchtower / build-image (push) Successful in 37s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-23 18:52:05 +02:00
Ninluc eb80d13300 :(
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-23 18:43:16 +02:00
Ninluc 6e2f849ee2 :(
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 19s
2026-08-23 18:38:41 +02:00
Ninluc 68250e8069 IDC
Build, push image, and notify Watchtower / build-image (push) Successful in 51s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-23 18:30:00 +02:00
Ninluc fe31ac978a Dissociate DEBUG variables 2026-08-23 17:48:04 +02:00
Ninluc 3c50fc4d0e Follow the damn prompt CJ
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-08-23 17:45:13 +02:00
Ninluc 3aed0e5f00 Fuck the blur
Build, push image, and notify Watchtower / build-image (push) Successful in 42s
Build, push image, and notify Watchtower / notify (push) Successful in 16s
2026-08-23 17:36:57 +02:00
Ninluc f55336a636 Tried my own
Build, push image, and notify Watchtower / build-image (push) Successful in 47s
Build, push image, and notify Watchtower / notify (push) Successful in 15s
2026-08-23 17:33:24 +02:00
Ninluc c58ae4603e Skip sending implicit new state
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-23 17:07:18 +02:00
Ninluc e7f6374200 Skip food safety alert for webex and sms 2026-08-23 17:06:59 +02:00
Ninluc 9053351674 idk
Build, push image, and notify Watchtower / build-image (push) Successful in 37s
Build, push image, and notify Watchtower / notify (push) Successful in 9s
2026-08-23 16:51:45 +02:00
Ninluc 1f53e7e6c3 Better prompt again ?
Build, push image, and notify Watchtower / build-image (push) Successful in 34s
Build, push image, and notify Watchtower / notify (push) Successful in 15s
2026-08-23 16:51:27 +02:00
Ninluc 7c35b52dbd Precision in prompt + better camera
Build, push image, and notify Watchtower / build-image (push) Successful in 38s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-23 16:48:30 +02:00
Ninluc 0f576467e1 send_reliably if cooking state update
Build, push image, and notify Watchtower / build-image (push) Successful in 40s
Build, push image, and notify Watchtower / notify (push) Successful in 15s
2026-08-23 16:19:09 +02:00
Ninluc 9d1a1f4c78 Try with normal image 2026-08-23 16:18:53 +02:00
Ninluc 701e7f1491 Reapply "Fix move to new messages"
This reverts commit e652831ce5.
2026-08-23 16:14:04 +02:00
Ninluc e652831ce5 Revert "Fix move to new messages"
This reverts commit 3bead59b23.
2026-08-23 16:12:15 +02:00
Ninluc ebc28546bc Better camera settings 2026-08-23 16:10:11 +02:00
Ninluc a51bfc1388 Bigger gaps 2026-08-23 16:10:00 +02:00
Ninluc 7082ea3f8b Better camera settings + better rpompt
Build, push image, and notify Watchtower / build-image (push) Successful in 45s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-23 14:32:17 +02:00
Ninluc e802386adf Set to tidle when error requesting cooking plan 2026-08-23 14:22:59 +02:00
Ninluc 289124b7f8 Change State when microwave state is Idel 2026-08-23 14:22:39 +02:00
Ninluc 3bead59b23 Fix move to new messages 2026-08-23 13:04:16 +02:00
Ninluc 467740ba84 Better prompt
Build, push image, and notify Watchtower / build-image (push) Successful in 40s
Build, push image, and notify Watchtower / notify (push) Successful in 9s
2026-08-23 12:51:55 +02:00
Ninluc 29c943ca5a Smooth image to remove noise
Build, push image, and notify Watchtower / build-image (push) Successful in 38s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-23 12:51:14 +02:00
Ninluc c130566156 Accept microwaves state IDLE 2026-08-23 12:51:05 +02:00
Ninluc 178457e199 Fix imports for micropython 2026-08-23 12:50:50 +02:00
Ninluc c860afc609 Changed IDs 2026-08-23 12:50:27 +02:00
Ninluc 9562bb7315 Forgot to remove that
Build, push image, and notify Watchtower / build-image (push) Successful in 1m19s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-23 12:39:44 +02:00
Ninluc a769efa8e0 Fix import
Build, push image, and notify Watchtower / build-image (push) Successful in 1m7s
Build, push image, and notify Watchtower / notify (push) Successful in 18s
2026-08-23 12:37:06 +02:00
Ninluc 3711928c7d Enhance image before ai processing
Build, push image, and notify Watchtower / build-image (push) Successful in 41s
Build, push image, and notify Watchtower / notify (push) Successful in 14s
2026-08-23 12:33:39 +02:00
Ninluc 6b8c1688a6 Send alerts and reset 2026-08-23 12:33:25 +02:00
Ninluc 755ffdad46 Send as an alert
Build, push image, and notify Watchtower / build-image (push) Successful in 55s
Build, push image, and notify Watchtower / notify (push) Successful in 14s
2026-08-23 11:32:42 +02:00
Ninluc 29fcb26eae No privilegied container 2026-08-23 11:32:34 +02:00
Ninluc 8b3c1d6b63 Added Fake bowl dish name for documentation 2026-08-23 11:18:29 +02:00
Ninluc c39b811a3c Lower noise buffer for ultrasonic ranger
Build, push image, and notify Watchtower / build-image (push) Successful in 41s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-23 11:05:04 +02:00
Ninluc b0a6c4d2ec Bettter safety check 2026-08-23 11:04:38 +02:00
Ninluc 0b1971ed0b Forgot https 2026-08-23 10:00:20 +02:00
Ninluc d6951cbbcf Fix Camera cm per pixels 2026-08-23 10:00:15 +02:00
Ninluc c6cec3dede add to non-authenticated routes
Build, push image, and notify Watchtower / build-image (push) Successful in 34s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-23 09:36:08 +02:00
Ninluc e67a13b4c8 Fix jobs ?
Build, push image, and notify Watchtower / build-image (push) Successful in 36s
Build, push image, and notify Watchtower / notify (push) Successful in 15s
2026-08-23 09:30:31 +02:00
Ninluc 0d394eca1e Only use Segoie UI 2026-08-23 09:27:54 +02:00
Ninluc c76379230b Remove false positives 2026-08-23 09:27:44 +02:00
Ninluc e042162a77 Fix api server host 2026-08-23 09:27:34 +02:00
Ninluc f167f4a2cd Set final product physical properties 2026-08-23 09:27:19 +02:00
Ninluc 7add88f154 Added logo file 2026-08-23 09:24:53 +02:00
Ninluc bb5327fb93 Technician Debug Web Server
Build, push image, and notify Watchtower / build-image (push) Successful in 42s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-19 17:04:33 +02:00
Ninluc 022c45edee Fix API key 2026-08-19 17:04:10 +02:00
Ninluc d7833100a5 RFID reading and authorization
Build, push image, and notify Watchtower / build-image (push) Successful in 43s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-19 15:51:13 +02:00
Ninluc db173af1c9 Only node-red is allowed to run CVE audit 2026-08-19 15:47:44 +02:00
Ninluc d0066a9d6d Authentificate to API
Build, push image, and notify Watchtower / build-image (push) Successful in 40s
Build, push image, and notify Watchtower / notify (push) Successful in 8s
2026-08-18 18:40:32 +02:00
Ninluc f9309c4dbc Authentication and Odata compliance
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-18 17:04:02 +02:00
Ninluc e69bbc6b45 Debug jobs
Build, push image, and notify Watchtower / build-image (push) Successful in 44s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-08-18 15:50:15 +02:00
Ninluc 88c3de6ab7 Shodan and jobs
Build, push image, and notify Watchtower / build-image (push) Successful in 3m25s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-18 15:48:37 +02:00
Ninluc 2c0776bdaa Logs the read GPS line
Build, push image, and notify Watchtower / build-image (push) Successful in 1m3s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-18 13:19:02 +02:00
Ninluc da93eaa604 Added dashboard link 2026-08-18 13:18:50 +02:00
Ninluc 608e07cb92 We rolling back ? 2026-08-17 23:21:21 +02:00
Ninluc 2ee70861cd Better GPS read 2026-08-17 23:14:18 +02:00
Ninluc af556fde58 Wait more than 2 seconds for better DHT sensor reads 2026-08-17 23:14:10 +02:00
Ninluc bcc3670aff Do not timeout if only asked to you
Build, push image, and notify Watchtower / build-image (push) Successful in 40s
Build, push image, and notify Watchtower / notify (push) Successful in 8s
2026-08-17 23:03:34 +02:00
Ninluc ab8199344c Disable temperature alert
Build, push image, and notify Watchtower / build-image (push) Successful in 44s
Build, push image, and notify Watchtower / notify (push) Successful in 14s
2026-08-17 22:20:22 +02:00
Ninluc 5a3f1d94ef Fix datetime function 2026-08-17 22:20:12 +02:00
Ninluc 50eb4fe87c Fix bug
Build, push image, and notify Watchtower / build-image (push) Successful in 48s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-17 17:28:47 +02:00
Ninluc 44a6be9090 Use added alert message 2026-08-17 17:28:41 +02:00
Ninluc 9ef4b584a4 Removed logs and hardcoded strings
Build, push image, and notify Watchtower / build-image (push) Successful in 37s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-17 17:11:46 +02:00
Ninluc 4234e1ed21 Debug
Build, push image, and notify Watchtower / build-image (push) Successful in 1m3s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-17 17:06:01 +02:00
Ninluc d39c5c26e7 Fix auth token
Build, push image, and notify Watchtower / build-image (push) Successful in 1m19s
Build, push image, and notify Watchtower / notify (push) Successful in 18s
2026-08-17 17:03:06 +02:00
Ninluc 9d609a367b Harcode from
Build, push image, and notify Watchtower / build-image (push) Successful in 1m14s
Build, push image, and notify Watchtower / notify (push) Successful in 17s
2026-08-17 17:00:07 +02:00
Ninluc b31d939787 Force phone number
Build, push image, and notify Watchtower / build-image (push) Successful in 35s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-17 16:50:55 +02:00
Ninluc 647e40131c Can't use my own body :(
Build, push image, and notify Watchtower / build-image (push) Successful in 44s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-17 16:47:35 +02:00
Ninluc c049339003 Fix boolean env variables
Build, push image, and notify Watchtower / build-image (push) Successful in 43s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-17 16:17:28 +02:00
Ninluc 3ba65f0106 Removed logging
Build, push image, and notify Watchtower / build-image (push) Successful in 38s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-17 16:07:40 +02:00
Ninluc ffbb350520 twilio small cleanup
Build, push image, and notify Watchtower / build-image (push) Successful in 46s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-17 16:01:33 +02:00
Ninluc 656afa6e2d Fix webex room url
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-17 15:57:16 +02:00
Ninluc 8f768a4d35 Returns and save the room link
Build, push image, and notify Watchtower / build-image (push) Successful in 41s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-17 15:46:36 +02:00
Ninluc 2047b1b522 Forgot dependency
Build, push image, and notify Watchtower / build-image (push) Successful in 4m33s
Build, push image, and notify Watchtower / notify (push) Successful in 14s
2026-08-17 15:26:37 +02:00
Ninluc 3db52f1cad send SMS
Build, push image, and notify Watchtower / build-image (push) Successful in 44s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-17 15:19:35 +02:00
Ninluc 5ae2dc1023 Display webex oauth errors
Build, push image, and notify Watchtower / build-image (push) Successful in 41s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-17 14:53:37 +02:00
Ninluc 44b4c8034f better Webex
Build, push image, and notify Watchtower / build-image (push) Successful in 42s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-17 14:46:35 +02:00
Ninluc 0149c1e5b2 Fix needed import
Build, push image, and notify Watchtower / build-image (push) Successful in 44s
Build, push image, and notify Watchtower / notify (push) Successful in 16s
2026-08-17 14:22:27 +02:00
Ninluc b442d39c18 Webex first draft
Build, push image, and notify Watchtower / build-image (push) Successful in 1m0s
Build, push image, and notify Watchtower / notify (push) Successful in 17s
2026-08-17 14:21:02 +02:00
Ninluc 8b6ab086fd better alerts and connected_components
Build, push image, and notify Watchtower / build-image (push) Successful in 57s
Build, push image, and notify Watchtower / notify (push) Successful in 9s
2026-08-17 13:45:51 +02:00
Ninluc 2d8889cd31 HTTPS url
Build, push image, and notify Watchtower / build-image (push) Successful in 49s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-17 13:14:23 +02:00
Ninluc 83b269cc88 Temperature Alert
Build, push image, and notify Watchtower / build-image (push) Successful in 1m17s
Build, push image, and notify Watchtower / notify (push) Successful in 16s
2026-08-17 13:04:35 +02:00
Ninluc 01eccf47c6 Better LoRa 2026-08-16 16:28:58 +02:00
Ninluc 9b0ecf944a Fix microwave not resetting 2026-08-16 16:28:51 +02:00
Ninluc c6e56b58a0 Send microwave states to lora 2026-08-16 15:50:57 +02:00
Ninluc 1a77794fd3 Moved to shared 2026-08-16 15:42:11 +02:00
Ninluc 0469b3d406 Fixed temphum sensor
Build, push image, and notify Watchtower / build-image (push) Successful in 43s
Build, push image, and notify Watchtower / notify (push) Successful in 17s
2026-08-16 14:23:28 +02:00
Ninluc fd00c9a89d Sending updates to external screen + RGB LCD 2026-08-16 14:23:18 +02:00
Ninluc 2276c1742a Removed wrongly used field 2026-08-16 14:21:50 +02:00
Ninluc 32229de20d ESP's Changed id ? 2026-08-16 14:21:30 +02:00
Ninluc d9b596d465 Fix import error 2026-08-16 14:21:20 +02:00
Ninluc 36abfc8234 Better prompt ?
Build, push image, and notify Watchtower / build-image (push) Successful in 46s
Build, push image, and notify Watchtower / notify (push) Successful in 18s
2026-08-15 18:50:54 +02:00
Ninluc 07ce941950 Better prompt
Build, push image, and notify Watchtower / build-image (push) Successful in 47s
Build, push image, and notify Watchtower / notify (push) Successful in 25s
2026-08-15 18:47:46 +02:00
Ninluc f6c714b1c3 Prompt for more relaxed checks
Build, push image, and notify Watchtower / build-image (push) Successful in 37s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-15 18:45:11 +02:00
Ninluc 86815a9d4d Fake photo when debugging
Build, push image, and notify Watchtower / build-image (push) Successful in 40s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-15 18:36:18 +02:00
Ninluc e132d05a85 Pretty display 2026-08-15 15:00:22 +02:00
Ninluc 2d06293325 ... Defaults to False
Build, push image, and notify Watchtower / build-image (push) Successful in 43s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-15 14:48:33 +02:00
Ninluc f4c5ce9d35 Separate config value for debugging unsafe dish
Build, push image, and notify Watchtower / build-image (push) Successful in 46s
Build, push image, and notify Watchtower / notify (push) Successful in 25s
2026-08-15 14:48:09 +02:00
Ninluc fde1ad6ca0 External Screen working
Build, push image, and notify Watchtower / build-image (push) Successful in 3m48s
Build, push image, and notify Watchtower / notify (push) Successful in 21s
2026-08-15 13:50:15 +02:00
Ninluc 08f6991ef5 Added todo 2026-08-15 13:49:50 +02:00
Ninluc 0ec1535436 Don't copy uneeded files 2026-08-15 13:49:28 +02:00
Ninluc bb25684072 Screen working
Need mqtt connection still
2026-08-13 11:27:42 +02:00
Ninluc 91fa5720f9 MQTT allow ipv6 2026-08-12 15:11:33 +02:00
Ninluc 5175b32266 Added border router container
Build, push image, and notify Watchtower / build-image (push) Successful in 42s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-12 13:48:06 +02:00
Ninluc aa4fd06c94 Rennamed file and V1 of external screen 2026-08-12 13:47:58 +02:00
Ninluc c0daa12f26 Include full edamam api response for saving purpose 2026-08-12 13:47:46 +02:00
Ninluc 547c2df1c9 Better prompt
Build, push image, and notify Watchtower / build-image (push) Successful in 53s
Build, push image, and notify Watchtower / notify (push) Successful in 14s
2026-08-12 12:15:51 +02:00
Ninluc 497c042a2a Max lora payload Exception
Build, push image, and notify Watchtower / build-image (push) Successful in 41s
Build, push image, and notify Watchtower / notify (push) Successful in 12s
2026-08-11 18:17:10 +02:00
Ninluc bfc6a75e66 Better prompt 2026-08-11 18:16:58 +02:00
Ninluc 07051a7255 Alerts 2026-08-11 18:16:49 +02:00
Ninluc 1543dde603 Shorter message 2026-08-11 17:33:51 +02:00
Ninluc 93c1c8e299 Alert endpoint + save dish safety even if danger present
Build, push image, and notify Watchtower / build-image (push) Successful in 44s
Build, push image, and notify Watchtower / notify (push) Successful in 14s
2026-08-11 17:10:42 +02:00
Ninluc 6629dd9472 Details in prompt 2026-08-11 15:54:28 +02:00
Ninluc f0ae0839b0 Shorter AI answer
Build, push image, and notify Watchtower / build-image (push) Successful in 58s
Build, push image, and notify Watchtower / notify (push) Successful in 17s
2026-08-11 14:57:07 +02:00
Ninluc 2572c68ac7 Modified prompt
Build, push image, and notify Watchtower / build-image (push) Successful in 1m12s
Build, push image, and notify Watchtower / notify (push) Successful in 17s
2026-08-11 14:38:35 +02:00
Ninluc f5bea7f636 Fix routes
Build, push image, and notify Watchtower / build-image (push) Successful in 1m2s
Build, push image, and notify Watchtower / notify (push) Successful in 17s
2026-08-11 14:28:20 +02:00
Ninluc 1724c6a06a Added safety checker debug
Build, push image, and notify Watchtower / build-image (push) Successful in 39s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-08-11 14:21:23 +02:00
Ninluc f6b29c4e08 Don't crash if no internet connection
Build, push image, and notify Watchtower / build-image (push) Successful in 3m37s
Build, push image, and notify Watchtower / notify (push) Successful in 13s
2026-08-11 14:15:26 +02:00
Ninluc fe586cf2a5 Try to get structured output from AI 2026-08-11 14:15:14 +02:00
Ninluc dff6cb9d41 Bigger timeout to get the cooking plan 2026-08-10 17:10:32 +02:00
Ninluc 980d79b793 Fix str passed to json
Build, push image, and notify Watchtower / build-image (push) Successful in 38s
Build, push image, and notify Watchtower / notify (push) Successful in 9s
2026-08-10 17:02:41 +02:00
Ninluc 38e10d33e1 Fix error ?
Build, push image, and notify Watchtower / build-image (push) Successful in 33s
Build, push image, and notify Watchtower / notify (push) Successful in 9s
2026-08-10 16:58:29 +02:00
Ninluc 40e91a7d2b better erro logging
Build, push image, and notify Watchtower / build-image (push) Successful in 47s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-08-10 16:56:58 +02:00
Ninluc 4abefa5b29 Added some error logging
Build, push image, and notify Watchtower / build-image (push) Successful in 34s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-08-10 16:43:24 +02:00
Ninluc ddc136a5ca AI safety check
Build, push image, and notify Watchtower / build-image (push) Successful in 1m59s
Build, push image, and notify Watchtower / notify (push) Successful in 10s
2026-08-10 16:36:27 +02:00
Ninluc d4cef30b8f Refactored some logs 2026-08-10 14:33:24 +02:00
Ninluc a6704c3578 Fix image folder
Build, push image, and notify Watchtower / build-image (push) Successful in 1m0s
Build, push image, and notify Watchtower / notify (push) Successful in 15s
2026-08-10 14:25:38 +02:00
Ninluc 1c2c07471e Timeout before sending + fix api
Build, push image, and notify Watchtower / build-image (push) Successful in 43s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-10 14:20:36 +02:00
Ninluc 6af00b7990 Forgot to add paho-mqtt as dependency
Build, push image, and notify Watchtower / build-image (push) Successful in 2m18s
Build, push image, and notify Watchtower / notify (push) Successful in 6s
2026-08-10 14:05:30 +02:00
Ninluc 4d3184cd2f Telemetry
Build, push image, and notify Watchtower / build-image (push) Successful in 1m28s
Build, push image, and notify Watchtower / notify (push) Successful in 19s
2026-08-10 14:00:26 +02:00
Ninluc 0bc1a88e3c Buzzer 2026-08-10 14:00:20 +02:00
Ninluc 75e64a0706 Button fix 2026-08-10 12:51:00 +02:00
58 changed files with 3814 additions and 528 deletions
+3 -1
View File
@@ -1,4 +1,6 @@
`export PYTHONPATH=/home/pi/SmartWave`
`export PYTHONPATH=/home/ninluc/Documents/school/IoT/smartWave`
`export PYTHONPATH=/home/ninluc/Documents/school/IoT/smartWave`
https://red.smartwave.matthiasg.dev/dashboard
+82 -60
View File
@@ -1,102 +1,124 @@
import os
import base64
import json
import urllib.request
import urllib.error
from typing import Any, Union
from openai import OpenAI
from pydantic import BaseModel
API_HOST = os.getenv("OPENAI_API_HOST", "https://chat.matthiasg.dev/ollama")
AI_MODEL = os.getenv("OPENAI_MODEL", "llava:7b-v1.6-mistral-q4_1")
AI_MODEL_THINK = os.getenv("OPENAI_MODEL_THINK", "True").lower() in ("true", "1", "t")
OPENAPI_TOKEN = os.getenv("OPENAI_API_TOKEN", None)
OPENAPI_ENDPOINT = "/api/generate"
# Default base_url pointing to Lemonade's OpenAI-compatible endpoint
API_HOST = os.getenv("OPENAI_API_HOST", "https://lemonade.matthiasg.dev/api/v1")
AI_MODEL = os.getenv("OPENAI_MODEL", "LFM2.5-VL-1.6B-GGUF-Q8_0")
AI_MODEL_THINK = os.getenv("OPENAI_MODEL_THINK", "False").lower() in ("true", "1", "t")
OPENAPI_TOKEN = os.getenv("OPENAI_API_TOKEN", "lemonade")
print(f"Using API Host: {API_HOST}")
print(f"Using API Model: {AI_MODEL}")
print(f"Using API Model Think: {AI_MODEL_THINK}")
print(f"Using API Token: {'Yes' if OPENAPI_TOKEN else 'No'} {OPENAPI_TOKEN[:5] + '...' if OPENAPI_TOKEN else ''}")
def call_api(body: dict, endpoint: str = OPENAPI_ENDPOINT) -> str:
"""Call the API with the given endpoint and body dict."""
url = f"{API_HOST}{endpoint}"
headers = {
"Content-Type": "application/json",
}
if OPENAPI_TOKEN:
headers["Authorization"] = f"Bearer {OPENAPI_TOKEN}"
json_data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(url, data=json_data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req) as response:
return response.read().decode("utf-8")
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8")
raise Exception(f"Error calling API: HTTP {e.code} - {error_body}")
except urllib.error.URLError as e:
raise Exception(f"Failed to reach server: {e.reason}")
# Initialize OpenAI Client for Lemonade
client = OpenAI(
base_url=API_HOST,
api_key=OPENAPI_TOKEN if OPENAPI_TOKEN else "lemonade"
)
def generate(
model: str = AI_MODEL,
prompt: str = "",
images: list[str] = None,
output_format: str = None,
output_format: Union[str, dict, type[BaseModel]] = None,
system_message: str = None,
keep_alive: bool = True,
should_think: bool = AI_MODEL_THINK,
) -> str:
"""
Generate a response for a given prompt with a provided model via the Ollama/OpenAI API.
Handles base64 encoding for local image file paths and structures the request body.
Generate a completion response using the OpenAI SDK against Lemonade / OpenAI compatible APIs.
Handles multimodal image content, structured outputs, and custom Lemonade parameters.
"""
if images is None:
images = []
# Transform image file paths to base64 strings
encoded_images = []
messages = []
# 1. Add System Message if present
if system_message:
messages.append({"role": "system", "content": system_message})
# 2. Build User Content Payload (Text + Multimodal Images)
user_content = []
if prompt:
user_content.append({"type": "text", "text": prompt})
for img_path in images:
if os.path.isfile(img_path):
with open(img_path, "rb") as image_file:
encoded_images.append(base64.b64encode(image_file.read()).decode("utf-8"))
b64_str = base64.b64encode(image_file.read()).decode("utf-8")
else:
# If it's already a base64 string or an invalid path, keep as-is
encoded_images.append(img_path)
b64_str = img_path
body = {
"model": model,
"prompt": prompt,
"images": encoded_images,
# Ensure base64 string includes Data URI prefix for OpenAI vision format
if not b64_str.startswith("data:"):
image_url = f"data:image/jpeg;base64,{b64_str}"
else:
image_url = b64_str
user_content.append({
"type": "image_url",
"image_url": {"url": image_url}
})
# Simplify content payload if text-only
if len(user_content) == 1 and user_content[0]["type"] == "text":
messages.append({"role": "user", "content": prompt})
else:
messages.append({"role": "user", "content": user_content})
# 3. Prepare parameters and custom body flags
extra_body = {
"think": should_think,
"stream": False,
}
if not keep_alive:
extra_body["keep_alive"] = "0m"
request_kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
"extra_body": extra_body,
}
if system_message is not None:
body["system"] = system_message
# 4. Handle Structured Outputs / JSON mode
if output_format is not None:
try:
body["format"] = json.loads(output_format)
except json.JSONDecodeError:
body["format"] = output_format
if isinstance(output_format, type) and issubclass(output_format, BaseModel):
# Pydantic Model -> Use structured output parsing API
response = client.beta.chat.completions.parse(
response_format=output_format,
**request_kwargs
)
return response.choices[0].message.content or ""
elif isinstance(output_format, dict):
# JSON Schema dictionary
request_kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "structured_response",
"strict": True,
"schema": output_format,
}
}
elif isinstance(output_format, str) and output_format.lower() == "json":
# Standard JSON Mode
request_kwargs["response_format"] = {"type": "json_object"}
if not keep_alive:
body["keep_alive"] = "0m"
response_text = call_api(body)
try:
decoded_response = json.loads(response_text)
except json.JSONDecodeError as e:
raise Exception(f"Error decoding JSON response: {e}")
return decoded_response.get("response", "")
# Standard completion request
response = client.chat.completions.create(**request_kwargs)
return response.choices[0].message.content or ""
if __name__ == "__main__":
# Example usage:
result = generate(
prompt="Explain what you see in the image or answer this prompt.",
prompt="Explain what you see or answer this prompt.",
should_think=AI_MODEL_THINK,
)
print(result)
+10 -10
View File
@@ -6,7 +6,7 @@ import json
EDAMAM_APP_ID__FOOD = os.getenv("EDAMAM_APP_ID__FOOD", None)
EDAMAM_APP_KEY__FOOD = os.getenv("EDAMAM_APP_KEY__FOOD", None)
SAVE_EDAMAM_API_TOKEN = os.getenv("SAVE_EDAMAM_API_TOKEN", False)
SAVEUP_EDAMAM_API_TOKEN = os.getenv("SAVEUP_EDAMAM_API_TOKEN", "false").lower() == "true"
class EdamamAPI:
"""Wrapper around Edamam Food Database API 2.0 (Vision & Nutrients)"""
@@ -17,7 +17,7 @@ class EdamamAPI:
pass
def analyze_dish_image(self, image_file_path: str):
if SAVE_EDAMAM_API_TOKEN:
if SAVEUP_EDAMAM_API_TOKEN:
time.sleep(3)
return json.loads("""
{
@@ -911,15 +911,15 @@ class EdamamAPI:
""")
url = "https://api.edamam.com/api/vision/v1/nutrients"
url = "https://api.edamam.com/api/vision/v1/nutrients?app_id=09cb5bd7&app_key=8b2730f3c7f4f075cc939da3fa0c49fb&beta=true&servings=1"
# Query parameters as specified by the YAML schema
params = {
"app_id": EDAMAM_APP_ID__FOOD,
"app_key": EDAMAM_APP_KEY__FOOD,
"beta": "true" # Recommended since vision is in Beta
}
print("params:", params)
# params = {
# "app_id": EDAMAM_APP_ID__FOOD,
# "app_key": EDAMAM_APP_KEY__FOOD,
# "beta": "true" # Recommended since vision is in Beta
# }
# print("params:", params)
# Specify the media type in the header
headers = {
@@ -932,7 +932,7 @@ class EdamamAPI:
response = requests.post(
url,
params=params,
# params=params,
headers=headers,
data=raw_image_bytes
)
+36
View File
@@ -0,0 +1,36 @@
import json
import os
import paho.mqtt.publish as publish
import sys
# Retrieve broker parameters from shared config or environment variables
MQTT_HOST = os.getenv("MQTT_HOST", "localhost")
MQTT_PORT = int(os.getenv("MQTT_PORT", 8884))
MQTT_USER = os.getenv("MQTT_USER", None)
MQTT_PASS = os.getenv("MQTT_PASS", None)
def send_command(topic: str = "cmd/all", payload: dict | str = None):
"""
Publishes an MQTT command payload to a given topic.
"""
if payload is None:
payload = {}
if isinstance(payload, dict):
payload_str = json.dumps(payload)
else:
payload_str = str(payload)
auth = None
if MQTT_USER:
auth = {"username": MQTT_USER, "password": MQTT_PASS or ""}
publish.single(
topic=topic,
payload=payload_str,
hostname=MQTT_HOST,
port=MQTT_PORT,
auth=auth
)
print(f"[MQTT] Published command to '{topic}': {payload_str}")
+134
View File
@@ -0,0 +1,134 @@
import os
import requests
from typing import Dict, Any, Optional
class ShodanAuditor:
"""Service class to handle Shodan API security audits and IP profiling."""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.getenv("SHODAN_API_KEY", "")
self.base_url = "https://api.shodan.io"
def get_public_ip(self) -> Optional[str]:
"""Utility method to discover the server's public IPv4 address."""
try:
res = requests.get("https://api.ipify.org?format=json", timeout=5)
return res.json().get("ip")
except Exception:
return None
def _query_host(self, ip: str) -> Dict[str, Any]:
"""Internal helper to query Shodan's Host API endpoint."""
if not self.api_key:
return {"error": "Missing Shodan API key"}
url = f"{self.base_url}/shodan/host/{ip}?key={self.api_key}"
try:
response = requests.get(url, timeout=8)
if response.status_code == 404:
return {"indexed": False, "ip": ip}
if response.status_code != 200:
return {"error": f"Shodan API error HTTP {response.status_code}"}
data = response.json()
data["indexed"] = True
return data
except requests.RequestException as e:
return {"error": str(e)}
def audit_server_vulnerabilities(self, target_ip: Optional[str] = None) -> Dict[str, Any]:
"""
Audits the server for reported CVEs, exposed services, and SSL details.
:param target_ip: IP to scan (defaults to server's own public IP)
"""
ip = target_ip or self.get_public_ip()
if not ip:
return {"status": "error", "message": "Unable to resolve target IP"}
data = self._query_host(ip)
if "error" in data:
return {"status": "error", "message": data["error"], "target_ip": ip}
if not data.get("indexed"):
return {
"status": "PASS",
"target_ip": ip,
"message": "IP is not indexed by Shodan (low public exposure)",
"vulnerabilities": [],
"open_ports": []
}
# Extract CVEs
vulns = data.get("vulns", [])
# Extract SSL/TLS info from HTTP services if available
ssl_info = []
for service in data.get("data", []):
if "ssl" in service:
ssl_details = service["ssl"]
ssl_info.append({
"port": service.get("port"),
"cert_expired": ssl_details.get("cert", {}).get("expired", False),
"cipher": ssl_details.get("cipher", {}).get("name"),
"version": ssl_details.get("versions")
})
return {
"status": "FAIL" if vulns else "PASS",
"target_ip": ip,
"hostnames": data.get("hostnames", []),
"open_ports": data.get("ports", []),
"vulnerabilities": vulns,
"vuln_count": len(vulns),
"ssl_certificates": ssl_info,
"last_shodan_update": data.get("last_update")
}
def profile_client_ip(self, client_ip: str) -> Dict[str, Any]:
"""
Profiles a incoming client (microwave) public IP address.
Checks if the client network has exposed admin panels, Telnet, or CVEs.
:param client_ip: Public IP of the connecting device/router
"""
# Skip local/private network IPs
if client_ip in ["127.0.0.1", "localhost"] or client_ip.startswith(("10.", "192.168.", "172.16.")):
return {"status": "skipped", "reason": "Local/Private IP"}
data = self._query_host(client_ip)
if "error" in data or not data.get("indexed"):
return {
"client_ip": client_ip,
"risk_level": "LOW",
"indexed": False,
"summary": "Client IP not indexed on Shodan"
}
ports = data.get("ports", [])
vulns = data.get("vulns", [])
# Check for high-risk exposed router/device ports
high_risk_ports = [23, 80, 8080, 8443, 7547] # Telnet, Web UI, TR-069
flagged_ports = [p for p in ports if p in high_risk_ports]
# Calculate basic risk level
if vulns or 23 in ports:
risk_level = "HIGH"
elif flagged_ports:
risk_level = "MEDIUM"
else:
risk_level = "LOW"
return {
"client_ip": client_ip,
"risk_level": risk_level,
"indexed": True,
"open_ports": ports,
"flagged_router_ports": flagged_ports,
"vulnerabilities": vulns,
"org": data.get("org", "Unknown ISP")
}
+47
View File
@@ -0,0 +1,47 @@
import os
from twilio.rest import Client
def send_alert_sms(to_phone: str, room_title: str, room_link: str = None, client_email: str = None) -> bool:
"""
Sends an SMS alert using Twilio notifying the client of an issue and support room creation.
"""
account_sid = os.environ.get("TWILIO_SID")
auth_token = os.environ.get("TWILIO_AUTH_TOKEN")
from_phone = os.environ.get("TWILIO_PHONE_NUMBER") # Requires your Twilio sender phone number
if not account_sid or not auth_token or not from_phone:
print("Twilio environment variables (TWILIO_SID, TWILIO_ACCOUNT_SECRET, TWILIO_PHONE_NUMBER) missing.")
return False
if not to_phone:
print("No recipient phone number provided for SMS.")
return False
# --- BUILD SMS CONTENT ---
body = (
f"Alert: A technical issue occurred with your SmartWave equipment/microwave. "
f"A support chat room '{room_title}' has been created to assist you.\n\n"
)
if room_link:
body += f"Join chat room: {room_link}\n"
if client_email:
body += f"An invitation link has also been sent to your email ({client_email})."
else:
body += "An invitation link has been sent to your inbox."
print(f"Sending SMS to {to_phone} : \"{body}\"")
try:
client = Client(account_sid, auth_token)
message = client.messages.create(
to=to_phone,
from_=from_phone,
body="sms_internal_alerts",
)
print(f"SMS sent successfully to {to_phone}. Message SID: {message.sid}")
return True
except Exception as e:
print(f"Failed to send SMS via Twilio to {to_phone}: {e}")
return False
+147
View File
@@ -0,0 +1,147 @@
import time
import datetime
import requests
from flask import current_app
class WebexManager:
def __init__(self, db_collection, client_id, client_secret, redirect_uri, team_id, user_id):
self.db = db_collection
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.team_id = team_id
self.user_id = user_id
self.base_url = "https://webexapis.com/v1"
def save_tokens(self, token_data: dict):
"""Saves or updates Webex OAuth tokens in MongoDB."""
now = time.time()
doc = {
"_id": "webex_credentials",
"access_token": token_data.get("access_token"),
"refresh_token": token_data.get("refresh_token"),
"expires_at": now + token_data.get("expires_in", 43200) - 300, # Subtract 5 mins buffer
"refresh_token_expires_at": now + token_data.get("refresh_token_expires_in", 7776000),
"updated_at": datetime.datetime.now(datetime.timezone.utc).isoformat()
}
self.db.replace_one({"_id": "webex_credentials"}, doc, upsert=True)
return doc
def get_access_token(self) -> str:
"""Retrieves a valid access token from MongoDB, auto-refreshing if expired."""
token_doc = self.db.find_one({"_id": "webex_credentials"})
if not token_doc:
raise Exception("No Webex OAuth tokens found. Please complete login at /oauth/login.")
# Check if access token is expired
if time.time() >= token_doc.get("expires_at", 0):
current_app.logger.info("Webex access token expired. Refreshing...")
return self.refresh_token(token_doc.get("refresh_token"))
return token_doc.get("access_token")
def exchange_code(self, code: str) -> dict:
"""Exchanges OAuth code for access and refresh tokens."""
payload = {
"grant_type": "authorization_code",
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"redirect_uri": self.redirect_uri
}
res = requests.post(f"{self.base_url}/access_token", data=payload)
if not res.ok:
# Print or log the exact Webex error response
print(f"Webex Error Details ({res.status_code}): {res.text}")
res.raise_for_status()
return self.save_tokens(res.json())
def refresh_token(self, refresh_token: str) -> str:
"""Refreshes the Webex access token using the stored refresh_token."""
payload = {
"grant_type": "refresh_token",
"client_id": self.client_id,
"client_secret": self.client_secret,
"refresh_token": refresh_token
}
res = requests.post(f"{self.base_url}/access_token", data=payload)
res.raise_for_status()
new_doc = self.save_tokens(res.json())
return new_doc["access_token"]
def create_support_room(self, client_name: str, client_email: str, alert_type: str, alert_message: str, alert_id: str) -> str:
"""Creates a team support room and invites the client and yourself."""
access_token = self.get_access_token()
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
# 1. Create room under team
room_res = requests.post(
f"{self.base_url}/rooms",
headers=headers,
json={
"title": f"Smartwave Support: {client_name} - {alert_type} ({alert_id})",
"teamId": self.team_id
}
)
room_res.raise_for_status()
room_details = room_res.json()
room_id = room_details["id"]
# 2. Add Client via email (Webex sends invite if user does not exist)
if client_email:
requests.post(
f"{self.base_url}/memberships",
headers=headers,
json={"roomId": room_id, "personEmail": client_email}
)
# 3. Add yourself (WEBEX_NINLUC_ID)
if self.user_id:
requests.post(
f"{self.base_url}/memberships",
headers=headers,
json={"roomId": room_id, "personId": self.user_id}
)
# 4. Optional: Send initial welcome message in the room
self.send_message(
room_id,
message=f"Support room initialized for {client_name}. An agent will be with you shortly.",
access_token=access_token
)
self.send_alert_info_message(room_id, alert_type, alert_message, alert_id, access_token=access_token)
return room_details
def send_message(self, room_id: str, message: str | None = None, markdown: str | None = None, access_token: str | None = None):
# Fetch the token so it auto-refreshes if needed
if not access_token:
access_token = self.get_access_token()
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
requests.post(
f"{self.base_url}/messages",
headers=headers,
json={
"roomId": room_id,
"text": message,
"markdown": markdown
}
).raise_for_status()
def send_alert_info_message(self, room_id: str, alert_type: str, alert_message: str, alert_id: str, added_alert_info: bool = False, access_token: str | None = None):
"""Sends a structured alert info message to the specified Webex room."""
if added_alert_info:
message = f"Additional Alert Details:\n- Type: {alert_type}\n- Message: {alert_message}\n- Alert ID: {alert_id}"
else:
message = f"Alert Details:\n- Type: {alert_type}\n- Message: {alert_message}\n- Alert ID: {alert_id}"
self.send_message(room_id, message=message, access_token=access_token)
+41 -1
View File
@@ -1,4 +1,44 @@
in `/cloud` folder :
`flask run --debug`
`flask run --debug`
# Oauth Webex
[webexapis.com](https://webexapis.com/v1/authorize?client_id=C2a4f544bb4f65503e60b6dceded0de194323a873360b2d06eff5a2232ff46176&response_type=code&redirect_uri=https%3A%2F%2Fsmartwave.matthiasg.dev%2Foauth%2Fcallback&scope=spark-admin%3Abroadworks_subscribers_write%20meeting%3Aadmin_participants_read%20spark%3Awebhooks_read%20spark%3Apeople_write%20spark-admin%3Awholesale_customers_write%20meeting%3Asummaries_write%20spark-admin%3Aworkspace_metrics_read%20cjp%3Atask_read%20spark-admin%3Awholesale_billing_reports_read%20meeting%3Asummaries_read%20spark-admin%3Atelephony_pstn_write%20spark-admin%3Avideo_mesh_api_webhook_write%20spark%3Aplaces_read%20cjp%3Aconfig_write%20meeting%3Aadmin_config_write%20spark-admin%3Arecordings_read%20spark-compliance%3Amessages_read%20cjds%3Aadmin_org_read%20spark-admin%3Aworkspaces_write%20spark-compliance%3Ameetings_write%20spark-admin%3Alocations_write%20meeting%3Aadmin_schedule_write%20identity%3Aplaceonetimepassword_create%20spark-admin%3Aorganizations_write%20spark-admin%3Atelephony_pstn_read%20spark%3Adevices_write%20spark-admin%3Awholesale_sub_partners_write%20cjp%3Aconfig_read%20spark-admin%3Abroadworks_billing_reports_write%20spark-admin%3Acall_qualities_read%20spark%3Aapplications_token%20spark%3Akms%20spark-admin%3Awholesale_sub_partners_read%20spark%3Awebrtc_calling%20spark-admin%3Amessages_read%20application%3Awebhooks_write%20spark-admin%3Awholesale_subscribers_read%20spark-admin%3Acalls_write%20meeting%3Aadmin_config_read%20spark%3Atelephony_config_write%20meeting%3Aparticipants_read%20Identity%3Acontact%20spark-admin%3Awholesale_billing_reports_write%20spark-admin%3Avideo_mesh_api_read%20Identity%3AConfig%20spark-compliance%3Awebhooks_write%20spark-admin%3Aorganizations_read%20cjds%3Aadmin_org_write%20spark-compliance%3Ateam_memberships_read%20meeting%3Aschedules_write%20spark-admin%3Avideo_mesh_api_write%20spark-admin%3Arecordings_write%20identity%3Aorganizations_rw%20identity%3Aorganizations_read%20cloud-contact-center%3Apod_conv%20spark-admin%3Adevices_read%20cjp%3Auser%20cjp%3Aconfig%20spark-admin%3Atelephony_config_write%20spark%3Arecordings_write%20spark-admin%3Abroadworks_enterprises_read%20spark%3Acalls_read%20meeting%3Arecordings_write%20spark%3Adevices_read%20spark-compliance%3Aevents_read%20spark-admin%3Aresource_group_memberships_read%20identity%3Apeople_rw%20spark-admin%3Aresource_group_memberships_write%20application%3Awebhooks_read%20spark-admin%3Ahybrid_connectors_read%20spark-compliance%3Ateams_read%20spark-admin%3Aplaces_write%20spark%3Amcp%20spark-admin%3Alicenses_read%20spark%3Aplaces_write%20webexsquare%3Aadmin%20spark%3Awebhooks_write%20meeting%3Aadmin_preferences_write%20spark%3Aall%20meeting%3Aadmin_preferences_read%20analytics%3Aread_all%20spark-admin%3Apeople_write%20spark%3Aorganizations_read%20spark-admin%3Aplaces_read%20spark-admin%3Awholesale_workspace_write%20spark-compliance%3Ateam_memberships_write%20identity%3Agroups_read%20identity%3Atokens_read%20wxc-dedicateduc%3Aadmin_perfmon_read%20spark-compliance%3Arecordings_read%20spark-admin%3Adevices_write%20spark%3Acalls_write%20Identity%3Aone_time_password%20spark-admin%3Aworkspace_locations_read%20spark-compliance%3Arecordings_write%20spark%3Axapi_commands%20spark-compliance%3Awebhooks_read%20spark-compliance%3Amessages_write%20spark-admin%3Awholesale_customers_read%20spark%3Axsi%20spark-admin%3Areports_read%20meeting%3Aparticipants_write%20spark-admin%3Apeople_read%20meeting%3Aadmin_transcripts_read%20spark-compliance%3Amemberships_read%20spark-admin%3Aresource_groups_read%20meeting%3Arecordings_read%20spark-admin%3Alocations_read%20meeting%3Apreferences_write%20meeting%3Aadmin_recordings_read%20identity%3Atokens_write%20meeting%3Atranscripts_read%20spark-admin%3Amessages_write%20spark%3Axapi_statuses%20spark-admin%3Acalls_read%20spark-admin%3Awholesale_subscribers_write%20spark-admin%3Acalling_cdr_read%20spark%3Arecordings_read%20cjp%3Atask_write%20spark-admin%3Ahybrid_clusters_read%20meeting%3Acontrols_read%20spark-admin%3Aworkspace_locations_write%20spark-admin%3Abroadworks_billing_reports_read%20spark-admin%3Atelephony_config_read%20spark-admin%3Abroadworks_enterprises_write%20meeting%3Aadmin_schedule_read%20spark-compliance%3Amemberships_write%20meeting%3Aschedules_read%20Identity%3AOrganization%20spark-admin%3Aroles_read%20spark-compliance%3Ameetings_read%20meeting%3Apreferences_read%20identity%3Apeople_read%20spark-admin%3Aworkspaces_read%20spark%3Atelephony_config_read%20spark-compliance%3Arooms_read%20spark-admin%3Abroadworks_subscribers_read%20identity%3Agroups_rw%20spark-admin%3Areports_write%20meeting%3Acontrols_write%20audit%3Aevents_read%20meeting%3Aadmin_recordings_write%20spark-compliance%3Arooms_write&state=set_state_here)
# Tests
## Curl alerte
```bash
curl -X POST "https://smartwave.matthiasg.dev/alert" \
-H "Content-Type: application/json" \
-d '{
"orchestrator_id": "1",
"alert": {
"type": "TEST",
"message": "C’est un test",
"timestamp": 12345
},
"logs": []
}'
```
## Jobs
### Trigger Server CVE Audit
`curl https://smartwave.matthiasg.dev/debug/run-job/server_cve_audit_job`
### Trigger Client IP Audit
`curl https://smartwave.matthiasg.dev/debug/run-job/client_ip_audit_job`
### Trigger Telemetry Request
`curl https://smartwave.matthiasg.dev/debug/run-job/request_telemetry_job`
# Faux bols
- Riz
- Pot Beef Burgundy
+609 -73
View File
@@ -1,11 +1,27 @@
import os
import base64
import uuid
from flask import Flask, request, jsonify
from pymongo import MongoClient
from APIs import generate, EdamamAPI
import sys
import json
import uuid
import base64
import asyncio
import fcntl
import atexit
import re
from datetime import datetime, timedelta, timezone
from flask import Flask, Response, request, jsonify, current_app, abort, g
from pymongo import MongoClient, ASCENDING, DESCENDING
from apscheduler.schedulers.background import BackgroundScheduler
from APIs import generate
from APIs.mqtt import send_command
from APIs.webex import WebexManager
from APIs.shodan import ShodanAuditor
from APIs.twilio import send_alert_sms
from image_enhancer import enhance_image_for_ai
from microwaveCookPlanner import MicrowaveCookPlanner
import safety_checker
from jobs import job_server_cve_audit, job_client_ip_audit, job_request_telemetry
sys.path.insert(0, '..')
try:
from shared import config
@@ -18,114 +34,634 @@ app = Flask(__name__)
# Configuration & Setup
# ---------------------------------------------------------
# Configure MongoDB connection (adjust the URI as needed for your environment)
# Configure MongoDB connection
MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/")
client = MongoClient(MONGO_URI)
db = client["microwave_network_db"]
# Database Collections
client_collection = db["client_data"]
cooking_collection = db["cooking_parameters"]
device_network_collection = db["device_network"]
telemetry_collection = db["telemetry_data"]
alert_collection = db["alert_data"]
webex_tokens_collection = db["webex_tokens"]
device_ip_collection = db["device_ips"]
cve_audit_results = db["cve_audit_results"]
rfid_cards_collection = db["rfid_cards"]
# Initialize Database Indexes
def init_db_indexes():
device_ip_collection.create_index([("received_at", DESCENDING)])
device_ip_collection.create_index([("client_ip", ASCENDING)])
alert_collection.create_index([("orchestrator_id", ASCENDING), ("created_at", DESCENDING)])
client_collection.create_index([("devices.device_id", ASCENDING)])
init_db_indexes()
# Environment Configurations
WEBEX_CLIENT_ID = os.getenv("WEBEX_CLIENT_ID", "YOUR_WEBEX_CLIENT_ID")
WEBEX_CLIENT_SECRET = os.getenv("WEBEX_CLIENT_SECRET", "YOUR_WEBEX_CLIENT_SECRET")
WEBEX_REDIRECT_URI = os.getenv("WEBEX_REDIRECT_URI", "https://smartwave.matthiasg.dev/oauth/callback")
WEBEX_TEAM_ID = os.getenv("WEBEX_TEAM_ID", "YOUR_WEBEX_TEAM_ID")
WEBEX_NINLUC_ID = os.getenv("WEBEX_NINLUC_ID", "YOUR_WEBEX_NINLUC_ID")
SAVEUP_TWILIO_API_TOKEN = os.getenv("SAVEUP_TWILIO_API_TOKEN", "false").lower() == "true"
# Ensure the camera image storage directory exists when the app starts
CAMERA_IMAGE_DIR = "storage/dishCameraImages"
CAMERA_IMAGE_DIR = "storage/dishPhotos"
os.makedirs(CAMERA_IMAGE_DIR, exist_ok=True)
# ---------------------------------------------------------
# Classes
# Integrations Setup
# ---------------------------------------------------------
microwave_cook_planner = MicrowaveCookPlanner()
webex_manager = WebexManager(
db_collection=webex_tokens_collection,
client_id=WEBEX_CLIENT_ID,
client_secret=WEBEX_CLIENT_SECRET,
redirect_uri=WEBEX_REDIRECT_URI,
team_id=WEBEX_TEAM_ID,
user_id=WEBEX_NINLUC_ID
)
shodan_auditor = ShodanAuditor()
# ---------------------------------------------------------
# Routes
# Cron Scheduler (Multi-Worker Safe)
# ---------------------------------------------------------
scheduler = BackgroundScheduler(daemon=True)
def start_scheduler_once():
"""Ensures only ONE Gunicorn worker process runs the cron scheduler."""
lock_file_path = "/tmp/scheduler.lock"
# Open or create a lock file
lock_file = open(lock_file_path, "wb")
try:
# Request a non-blocking exclusive lock
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
# Register job schedules
scheduler.add_job(
func=job_server_cve_audit,
args=[shodan_auditor, db],
trigger="cron",
hour=0,
minute=0,
id="server_cve_audit_job",
replace_existing=True
)
scheduler.add_job(
func=job_client_ip_audit,
args=[shodan_auditor, db],
trigger="cron",
hour="*/6",
minute=15,
id="client_ip_audit_job",
replace_existing=True
)
scheduler.add_job(
func=job_request_telemetry,
args=[send_command],
trigger="cron",
hour="1,13",
minute=0,
id="request_telemetry_job",
replace_existing=True
)
scheduler.start()
print(f"[Cron] Scheduler started successfully in Worker PID {os.getpid()}")
# Ensure lock release on shutdown
def cleanup():
try:
fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close()
except Exception:
pass
atexit.register(cleanup)
except (IOError, OSError):
# Lock acquired by another worker - skip starting scheduler
print(f"[Cron] Worker PID {os.getpid()} skipped scheduler (already running in another worker).")
# Initialize single-instance scheduler
start_scheduler_once()
# ---------------------------------------------------------
# Authentication Middleware
# ---------------------------------------------------------
EXEMPT_ROUTES = {'hello_world', 'oauth_callback', 'odata_metadata', 'debug_telemetryrequest', 'trigger_job_manually', 'debug_unsafe_dish', 'debug_safe_dish'}
@app.before_request
def authenticate_request():
if request.endpoint in EXEMPT_ROUTES or request.method == 'OPTIONS':
return
# Extract API Key from Header (X-API-Key or Bearer token)
api_key = request.headers.get("X-API-Key")
if not api_key:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
api_key = auth_header.split(" ")[1]
if not api_key:
return jsonify({
"@odata.error": {
"code": "401",
"message": "Unauthorized: Missing API Key header"
}
}), 401
# Validate key against 'devices_authentication' collection
device_auth = db.devices_authentication.find_one({"api_key": api_key})
if not device_auth:
return jsonify({
"@odata.error": {
"code": "401",
"message": "Unauthorized: Invalid API Key"
}
}), 401
# Attach device context (works for both Microwaves and Node-RED)
g.device_id = device_auth.get("device_id")
# ---------------------------------------------------------
# Application Routes
# ---------------------------------------------------------
@app.route("/")
def hello_world():
gen = generate(prompt="Say Hello, to the user !")
print(gen)
return f"<p>{gen}</p>"
@app.route("/oauth/callback")
def oauth_callback():
code = request.args.get("code")
if not code:
return jsonify({"error": "Missing code parameter"}), 400
try:
webex_manager.exchange_code(code)
return jsonify({"status": "success", "message": "Webex tokens stored successfully!"}), 200
except Exception as e:
current_app.logger.exception("Failed to exchange OAuth code")
return jsonify({"error": "OAuth exchange failed"}), 500
@app.route("/cooking-params", methods=["POST"])
def cooking_params():
data = request.get_json()
if not data:
return jsonify({"error": "Invalid or missing JSON payload"}), 400
async def cooking_params():
try:
data = request.get_json()
if not data or not isinstance(data, dict):
return jsonify({"@odata.error": {"code": "400", "message": "Invalid payload"}}), 400
# Extract user or device parameters (with fallback defaults)
height_cm = float(data.get("dish_height", 4.0))
initial_temp_c = float(data.get("ir_initial_temp", 20.0)) # e.g., 4.0 for fridge, -18.0 for freezer
microwave_wattage = int(data.get("microwave_wattage", 900)) # e.g., 900W
defrost_mode = bool(data.get("defrost_mode", False)) # True for defrost, False for cook/reheat
print("Parsed parameters - Height (cm):", height_cm, "Initial Temp (C):", initial_temp_c, "Microwave Wattage:", microwave_wattage, "Defrost Mode:", defrost_mode)
# data["microwave_id"] = g.device_id
forwarded_for = request.headers.get('X-Forwarded-For')
client_ip = forwarded_for.split(',')[0].strip() if forwarded_for else request.remote_addr
device_ip_collection.insert_one({
"microwave_id": g.device_id,
"client_ip": client_ip,
"received_at": datetime.now(timezone.utc)
})
height_cm = float(data.get("dish_height", 4.0))
initial_temp_c = float(data.get("ir_initial_temp", 20.0))
microwave_wattage = int(data.get("microwave_wattage", 900))
defrost_mode = bool(data.get("defrost_mode", False))
camera_image_b64 = data.get("camera_image")
if not camera_image_b64:
return jsonify({"@odata.error": {"code": "400", "message": "Missing camera_image"}}), 400
# 1. Handle the Camera Image
camera_image_b64 = data.get("camera_image")
filepath = None
if camera_image_b64:
filename = f"dish_{uuid.uuid4().hex}.jpg"
filepath = os.path.join(CAMERA_IMAGE_DIR, filename)
try:
with open(filepath, "wb") as f:
f.write(base64.b64decode(camera_image_b64))
data["camera_image"] = filepath
except Exception as e:
return jsonify({"error": f"Failed to save camera image: {str(e)}"}), 500
else:
return jsonify({"error": "Missing required field 'camera_image'"}), 400
with open(filepath, "wb") as f:
f.write(base64.b64decode(camera_image_b64))
data["camera_image"] = filepath
enhanced_filepath = enhance_image_for_ai(filepath)
# 2. Run the Cook Planning Engine
try:
cook_plan = microwave_cook_planner.generate_plan(
image_path=filepath,
# Concurrent AI Execution
safety_task = asyncio.to_thread(safety_checker.check_dish_safety, filepath)
planner_task = asyncio.to_thread(
microwave_cook_planner.generate_plan,
image_path=enhanced_filepath,
height_cm=height_cm,
initial_temp_c=initial_temp_c,
microwave_wattage=microwave_wattage,
defrost_mode=defrost_mode
)
safety_result, cook_plan = await asyncio.gather(safety_task, planner_task)
data["safety_check"] = safety_result
data["analysis_results"] = cook_plan
inserted = cooking_collection.insert_one(data)
doc_id = str(inserted.inserted_id)
# Base OData v4 response wrapper
odata_response = {
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#CookingParams/$entity",
"@odata.id": f"{request.host_url.rstrip('/')}/cooking-params('{doc_id}')",
"id": doc_id,
"plan": cook_plan
}
if not safety_result.get("is_safe", True):
odata_response.update({
"error": "Unsafe cooking area",
"is_safe": False,
"warning_message": safety_result.get("warning_message", "Unsafe materials detected."),
"detected_hazards": safety_result.get("detected_hazards", [])
})
return jsonify(odata_response), 200
return jsonify(odata_response), 201
except Exception as e:
return jsonify({"error": f"Failed to compute cooking plan: {str(e)}"}), 500
current_app.logger.exception("Error in /cooking-params")
return jsonify({"@odata.error": {"code": "500", "message": "Internal processing error"}}), 500
# 3. Attach cooking parameters to database record
data["analysis_results"] = cook_plan
# 4. Save to MongoDB
try:
cooking_collection.insert_one(data)
data.pop("_id", None)
except Exception as e:
return jsonify({"error": f"Database error: {str(e)}"}), 500
# 5. Return complete output
return jsonify(cook_plan), 201
@app.route("/device-network", methods=["POST"])
def device_network():
@app.route("/alert", methods=["POST"])
def alert():
data = request.get_json()
if not data:
return jsonify({"error": "Invalid or missing JSON payload"}), 400
if data is None or not isinstance(data, dict):
return jsonify({
"@odata.error": {
"code": "400",
"message": "Invalid or missing JSON payload"
}
}), 400
# Auto-populate or override orchestrator_id using authenticated device context
orchestrator_id = data.get("orchestrator_id") or getattr(g, "device_id", None)
data["orchestrator_id"] = orchestrator_id
data["created_at"] = datetime.now(timezone.utc)
# 2. Save to MongoDB
try:
# Insert the dictionary directly into Mongo (it will retain your exact JSON keys)
device_network_collection.insert_one(data)
return "", 200
saved_alert = alert_collection.insert_one(data)
doc_id = str(saved_alert.inserted_id)
except Exception as e:
return jsonify({"error": f"Database error: {str(e)}"}), 500
current_app.logger.exception("Failed to insert alert into database")
return jsonify({
"@odata.error": {
"code": "500",
"message": "Database write failed"
}
}), 500
alert_info = data.get("alert", {})
alert_type = alert_info.get("type", "Unknown")
alert_message = alert_info.get("message", "No message provided")
webex_room_id = None
room_link = None
room_title = None
webex_status = "skipped"
sms_sent = False
@app.route("/debug", methods=["GET"])
def debug():
image_path = "microwaveDish.jpg"
edamam = EdamamAPI()
return edamam.analyze_dish_image(image_path)
if alert_type != "COOKING_SAFETY":
client_name = f"Unknown Client (Orchestrator {orchestrator_id})" if orchestrator_id else "Unknown Client"
client_email = None
client_phone = None
if orchestrator_id:
client_doc = client_collection.find_one({
"devices": {
"$elemMatch": {
"device_type": "orchestrator",
"device_id": str(orchestrator_id)
}
}
})
if client_doc:
client_name = client_doc.get("client_name", client_name)
contact_info = client_doc.get("contact_info", {})
client_email = contact_info.get("email")
client_phone = contact_info.get("phone")
room_title = f"Support - {client_name}"
three_minutes_ago = datetime.now(timezone.utc) - timedelta(minutes=3)
recent_alert = alert_collection.find_one({
"orchestrator_id": orchestrator_id,
"webex_room_id": {"$exists": True, "$ne": None},
"created_at": {"$gte": three_minutes_ago},
"_id": {"$ne": saved_alert.inserted_id}
}, sort=[("created_at", DESCENDING)])
if recent_alert:
webex_room_id = recent_alert["webex_room_id"]
room_title = recent_alert.get("webex_room_title", room_title)
try:
webex_manager.send_alert_info_message(
room_id=webex_room_id,
alert_type=alert_type,
alert_message=alert_message,
alert_id=doc_id,
added_alert_info=True
)
webex_status = "reused_room"
room_link = recent_alert.get("webex_room_link") or f"https://web.webex.com/spaces/{webex_room_id}"
except Exception as e:
current_app.logger.exception("Failed to send follow-up message to Webex room")
webex_status = f"message_failed: {str(e)}"
else:
try:
room_details = webex_manager.create_support_room(
client_name,
client_email,
alert_type,
alert_message,
doc_id
)
if isinstance(room_details, dict):
webex_room_id = room_details.get("id")
room_title = room_details.get("title", room_title)
room_link = room_details.get("meetingLink") or f"https://web.webex.com/spaces/{webex_room_id}"
else:
webex_room_id = room_details
room_link = f"https://web.webex.com/spaces/{webex_room_id}"
webex_status = "created_new_room"
except Exception as e:
current_app.logger.exception("Failed to create Webex support room")
webex_status = f"creation_failed: {str(e)}"
if webex_room_id and client_phone and not SAVEUP_TWILIO_API_TOKEN:
sms_sent = send_alert_sms(
to_phone=client_phone,
room_title=room_title,
room_link=room_link,
client_email=client_email
)
update_fields = {"sms_sent": sms_sent}
if webex_room_id:
update_fields.update({
"webex_room_id": webex_room_id,
"webex_room_title": room_title,
"webex_room_link": room_link
})
alert_collection.update_one({"_id": saved_alert.inserted_id}, {"$set": update_fields})
# OData v4 Created Response Payload
return jsonify({
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#Alerts/$entity",
"@odata.id": f"{request.host_url.rstrip('/')}/alert('{doc_id}')",
"id": doc_id,
"status": "success",
"message": "Alert saved",
"orchestrator_id": orchestrator_id,
"webex_room_id": webex_room_id,
"webex_room_url": room_link,
"webex_status": webex_status,
"sms_sent": sms_sent
}), 201
@app.route("/telemetry", methods=["POST"])
def telemetry():
data = request.get_json()
if data is None:
return jsonify({"@odata.error": {"code": "400", "message": "Invalid JSON payload"}}), 400
if isinstance(data, str):
try:
data = json.loads(data)
except (json.JSONDecodeError, TypeError):
return jsonify({"@odata.error": {"code": "400", "message": "String payload invalid"}}), 400
data["device_id"] = g.device_id
data["received_at"] = datetime.now(timezone.utc).isoformat()
try:
inserted = telemetry_collection.insert_one(data)
doc_id = str(inserted.inserted_id)
# OData v4 Created Response Envelope
return jsonify({
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#Telemetry/$entity",
"@odata.id": f"{request.host_url.rstrip('/')}/telemetry('{doc_id}')",
"id": doc_id,
"status": "success",
"message": "Telemetry saved",
"device_id": g.device_id,
"received_at": data["received_at"]
}), 201
except Exception as e:
current_app.logger.exception("Database write failed")
return jsonify({"@odata.error": {"code": "500", "message": "Database write failed"}}), 500
@app.route('/api/security/cve-audit', methods=['POST', 'GET'])
def run_cve_audit():
"""Protected endpoint triggered by Node-RED using REST API + API Key."""
# Only node-red is allowed in this endpoint
if g.device_id != "node-red":
return jsonify({
"@odata.error": {
"code": "403",
"message": "Forbidden: Not allowed to trigger CVE audit"
}
}), 403
audit_results = shodan_auditor.audit_server_vulnerabilities()
inserted = cve_audit_results.insert_one({
"triggered_by": g.device_id,
"audit_results": audit_results,
"timestamp": datetime.now(timezone.utc)
})
doc_id = str(inserted.inserted_id)
status_code = 200 if audit_results.get("status") == "PASS" else 409
return jsonify({
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#CveAudit/$entity",
"@odata.id": f"{request.host_url.rstrip('/')}/api/security/cve-audit('{doc_id}')",
"id": doc_id,
"status": audit_results.get("status"),
"audit_results": audit_results,
"timestamp": datetime.now(timezone.utc).isoformat()
}), status_code
@app.route("/rfid-card", methods=["GET"])
def get_rfid_cards():
"""OData compliant endpoint for technician RFID card validation."""
filter_str = request.args.get("$filter", "")
card_id = request.args.get("card_id")
mongo_query = {}
# Extract card_id from OData $filter string if not supplied directly
if not card_id and filter_str:
match = re.search(r"(?:card_id|cardId)\s+eq\s+['\"]([^'\"]+)['\"]", filter_str, re.IGNORECASE)
if match:
card_id = match.group(1)
else:
# Generic fallback to extract quoted strings
generic_match = re.search(r"['\"]([a-zA-Z0-9]+)['\"]", filter_str)
if generic_match:
card_id = generic_match.group(1)
if card_id:
mongo_query["card_id"] = card_id
# Filter for active/valid cards only
mongo_query["valid"] = True
try:
cards = list(rfid_cards_collection.find(mongo_query))
result_value = [
{
"id": str(card["_id"]),
"card_id": card.get("card_id"),
"valid": card.get("valid", False)
}
for card in cards
]
return jsonify({
"@odata.context": f"{request.host_url.rstrip('/')}/$metadata#RfidCards",
"value": result_value
}), 200
except Exception as e:
current_app.logger.exception("Error querying rfid_cards collection")
return jsonify({"@odata.error": {"code": "500", "message": "Database query error"}}), 500
# ---------------------------------------------------------
# Debug Endpoints (Protected)
# ---------------------------------------------------------
@app.route("/debug/telemetryrequest", methods=["GET"])
def debug_telemetryrequest():
# Construct external HTTP endpoint dynamically based on incoming request host
telemetry_url = f"{request.host_url.rstrip('/').replace('http://', 'https://')}/telemetry"
cmd_payload = {
"action": "request_telemetry",
"endpoint": telemetry_url
}
try:
send_command(topic="cmd/all", payload=cmd_payload)
return jsonify({
"status": "Telemetry command sent to cmd/all",
"published_payload": cmd_payload
}), 200
except Exception as e:
return jsonify({"error": f"Failed to publish MQTT command: {str(e)}"}), 500
@app.route('/debug/run-job/<job_id>', methods=['POST', 'GET'])
def trigger_job_manually(job_id):
"""Manually triggers any scheduled job immediately by its ID."""
job = scheduler.get_job(job_id)
if not job:
return jsonify({
"error": f"Job '{job_id}' not found",
"available_jobs": [j.id for j in scheduler.get_jobs()]
}), 404
try:
job.func(*job.args)
return jsonify({
"status": "success",
"message": f"Job '{job_id}' executed successfully."
}), 200
except Exception as e:
return jsonify({
"status": "error",
"message": f"Job execution failed: {str(e)}"
}), 500
@app.route("/debug/dishsafety/not_safe", methods=["GET"])
def debug_unsafe_dish():
"""Debug endpoint to simulate an unsafe dish scenario."""
# Simulated unsafe dish data
unsafe_dish = "storage/dishPhotos/dish_78827473286c4f67855f3fbd0ccb9bb4.jpg"
# Call the cooking_params endpoint logic directly
return safety_checker.check_dish_safety(unsafe_dish)
@app.route("/debug/dishsafety/safe", methods=["GET"])
def debug_safe_dish():
"""Debug endpoint to simulate a safe dish scenario."""
# Simulated safe dish data
unsafe_dish = "storage/dishPhotos/dish_af60c26a08a74ce894d453394f417c22.jpg"
# Call the cooking_params endpoint logic directly
return safety_checker.check_dish_safety(unsafe_dish)
# ---------------------------------------------------------
# OData Metadata Definition
# ---------------------------------------------------------
@app.route("/$metadata", methods=["GET"])
def odata_metadata():
"""Provides mandatory OData schema definition for validation."""
xml_metadata = """<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
<edmx:DataServices>
<Schema Namespace="MicrowaveNetwork" xmlns="http://docs.oasis-open.org/odata/ns/edm">
<EntityType Name="Telemetry">
<Key><PropertyRef Name="id"/></Key>
<Property Name="id" Type="Edm.String" Nullable="false"/>
<Property Name="device_id" Type="Edm.String"/>
<Property Name="received_at" Type="Edm.DateTimeOffset"/>
</EntityType>
<EntityType Name="CookingParams">
<Key><PropertyRef Name="id"/></Key>
<Property Name="id" Type="Edm.String" Nullable="false"/>
<Property Name="microwave_id" Type="Edm.String"/>
</EntityType>
<EntityType Name="CveAudit">
<Key><PropertyRef Name="id"/></Key>
<Property Name="id" Type="Edm.String" Nullable="false"/>
<Property Name="status" Type="Edm.String"/>
</EntityType>
<EntityType Name="Alert">
<Key><PropertyRef Name="id"/></Key>
<Property Name="id" Type="Edm.String" Nullable="false"/>
<Property Name="orchestrator_id" Type="Edm.String"/>
<Property Name="webex_room_id" Type="Edm.String"/>
<Property Name="webex_status" Type="Edm.String"/>
<Property Name="sms_sent" Type="Edm.Boolean"/>
</EntityType>
<EntityType Name="RfidCard">
<Key><PropertyRef Name="id"/></Key>
<Property Name="id" Type="Edm.String" Nullable="false"/>
<Property Name="card_id" Type="Edm.String" Nullable="false"/>
<Property Name="valid" Type="Edm.Boolean" Nullable="false"/>
</EntityType>
<EntityContainer Name="Container">
<EntitySet Name="Telemetry" EntityType="MicrowaveNetwork.Telemetry"/>
<EntitySet Name="CookingParams" EntityType="MicrowaveNetwork.CookingParams"/>
<EntitySet Name="CveAudit" EntityType="MicrowaveNetwork.CveAudit"/>
<EntitySet Name="Alerts" EntityType="MicrowaveNetwork.Alert"/>
<EntitySet Name="RfidCards" EntityType="MicrowaveNetwork.RfidCard"/>
</EntityContainer>
</Schema>
</edmx:DataServices>
</edmx:Edmx>"""
return Response(xml_metadata, mimetype="application/xml")
if __name__ == "__main__":
app.run(debug=config.DEBUG)
app.run(debug=getattr(config, "DEBUG", False))
+26
View File
@@ -0,0 +1,26 @@
import cv2
def enhance_image_for_ai(input_path: str) -> str:
"""Smooths camera sensor noise and brightens dark areas for vision LLMs."""
img = cv2.imread(input_path)
if img is None:
return input_path
# 1. Remove ISO sensor noise (grain)
denoised = cv2.fastNlMeansDenoisingColored(img, None, h=7, hColor=7, templateWindowSize=7, searchWindowSize=21)
# 2. Convert to LAB color space for luminance enhancement
lab = cv2.cvtColor(denoised, cv2.COLOR_BGR2LAB)
l, a, b = cv2.split(lab)
# 3. Apply softer CLAHE (lower clipLimit prevents noise amplification)
clahe = cv2.createCLAHE(clipLimit=1.8, tileGridSize=(8, 8))
cl = clahe.apply(l)
# 4. Merge channels back
limg = cv2.merge((cl, a, b))
enhanced = cv2.cvtColor(limg, cv2.COLOR_LAB2BGR)
enhanced_path = input_path.replace(".jpg", "_enhanced.jpg")
cv2.imwrite(enhanced_path, enhanced, [cv2.IMWRITE_JPEG_QUALITY, 90])
return enhanced_path
+84
View File
@@ -0,0 +1,84 @@
import os
import time
from datetime import datetime, timezone, timedelta
# Fallback base URL since 'request.host_url' is not available in background threads
SERVER_BASE_URL = os.getenv("SERVER_BASE_URL", "https://smartwave.matthiasg.dev")
def job_server_cve_audit(shodan_auditor, db):
"""Job 1: Audits Flask server public IP for CVEs and saves to MongoDB."""
print("[Cron] Starting Server CVE Audit...")
audit_results = shodan_auditor.audit_server_vulnerabilities()
db.cve_audit_results.insert_one({
"audit_results": audit_results,
"timestamp": datetime.now(timezone.utc)
})
print(f"[Cron] Server CVE Audit finished. Status: {audit_results.get('status')}")
def job_client_ip_audit(shodan_auditor, db):
"""
Job 2: Fetches distinct client IPs logged in the last 24h and audits them.
Deduplicates IPs and applies rate limits to protect Shodan API credits.
"""
print("[Cron] Starting Client IP Security Audit...")
pipeline = [
{"$group": {
"_id": "$client_ip",
"microwave_ids": {"$addToSet": "$microwave_id"},
"last_seen": {"$max": "$received_at"}
}}
]
recent_clients = list(db.device_ips.aggregate(pipeline))
for entry in recent_clients:
client_ip = entry["_id"]
# Skip local/private IPs
if not client_ip or client_ip in ["127.0.0.1", "localhost"] or client_ip.startswith(("10.", "192.168.", "172.16.")):
continue
profile = shodan_auditor.profile_client_ip(client_ip)
# Upsert client profile in a separate collection
db.client_audit_results.update_one(
{"client_ip": client_ip},
{
"$set": {
"client_ip": client_ip,
"microwave_ids": entry["microwave_ids"],
"last_seen": entry["last_seen"],
"profile": profile,
"audited_at": datetime.now(timezone.utc)
}
},
upsert=True
)
# Pause 1 second to respect Shodan API rate limits (1 req/sec)
time.sleep(1)
print(f"[Cron] Client IP Security Audit completed for {len(recent_clients)} IPs.")
def job_request_telemetry(send_command_func):
"""Job 3: Triggers MQTT command to request telemetry from all microwaves."""
print("[Cron] Sending MQTT Telemetry Request to cmd/all...")
telemetry_url = f"{SERVER_BASE_URL.rstrip('/')}/telemetry"
cmd_payload = {
"action": "request_telemetry",
"endpoint": telemetry_url
}
try:
send_command_func(topic="cmd/all", payload=cmd_payload)
print("[Cron] Telemetry request successfully published.")
except Exception as e:
print(f"[Cron Error] Failed to publish MQTT command: {str(e)}")
+2 -1
View File
@@ -96,5 +96,6 @@ class MicrowaveCookPlanner:
"reconciled_mass_g": final_mass_g,
"mass_validation_status": mass_data["status"],
"cook_plan": cook_plan,
"geometry": vol_data
"geometry": vol_data,
"edaman_response": edamam_resp
}
+18 -2
View File
@@ -1,6 +1,12 @@
import cv2
import numpy as np
from typing import Dict, Any, Optional
import sys
sys.path.insert(0, '..')
try:
from shared import config
except ImportError:
from ..shared import config
class MicrowaveDishAnalyzer:
@@ -10,8 +16,7 @@ class MicrowaveDishAnalyzer:
"""
# Constant scale ratio: Centimeters per Pixel.
# TODO : Replace this value once your camera calibration is complete.
CM_PER_PIXEL: float = 0.05 # Example: 1 pixel = 0.05 cm
CM_PER_PIXEL: float = 0.005925926
def __init__(self, cm_per_pixel: Optional[float] = None):
if cm_per_pixel is not None:
@@ -43,6 +48,17 @@ class MicrowaveDishAnalyzer:
largest_contour = max(contours, key=cv2.contourArea)
area_pixels = cv2.contourArea(largest_contour)
# DEBUG CONDITION: Draw and save contour image if DEBUG_DISHANALYZER is set
if getattr(config, "DEBUG_DISHANALYZER", False):
annotated_image = image.copy()
# Draw the contour in red (BGR: 0, 0, 255) with thickness of 2
cv2.drawContours(annotated_image, [largest_contour], -1, (0, 0, 255), 2)
# Handle file extensions gracefully
analyzed_path = image_path.replace(".jpg", "_analyzed.jpg")
cv2.imwrite(analyzed_path, annotated_image)
# 5. Convert pixels² to cm² using scale ratio squared
area_cm2 = area_pixels * (self.cm_per_pixel ** 2)
return float(area_cm2)
+41
View File
@@ -0,0 +1,41 @@
def parse_odata_query(request_args):
"""
Translates standard OData query parameters ($top, $skip, $orderby, $filter)
into MongoDB query parameters.
"""
limit = int(request_args.get('$top', 50))
skip = int(request_args.get('$skip', 0))
# Parse $orderby (e.g., $orderby=received_at desc)
sort = []
orderby_str = request_args.get('$orderby')
if orderby_str:
parts = orderby_str.split()
field = parts[0]
direction = -1 if len(parts) > 1 and parts[1].lower() == 'desc' else 1
sort.append((field, direction))
# Parse basic $filter (e.g., $filter=device_id eq '1')
mongo_filter = {}
filter_str = request_args.get('$filter')
if filter_str and ' eq ' in filter_str:
field, val = filter_str.split(' eq ')
field = field.strip()
val = val.strip().strip("'").strip('"')
# Cast data types
if val.isdigit():
val = int(val)
elif val.lower() == 'true':
val = True
elif val.lower() == 'false':
val = False
mongo_filter[field] = val
return {
"filter": mongo_filter,
"limit": limit,
"skip": skip,
"sort": sort or [("_id", -1)]
}
+7 -2
View File
@@ -1,5 +1,10 @@
Flask==3.0.2
Flask[async]==3.0.2
pymongo==4.6.1
gunicorn==21.2.0
opencv-python-headless
requests==2.32.3
requests==2.32.3
paho-mqtt>=1.6,<3
openai>=1.0.0
pydantic>=2.0.0
twilio>=9.0.0
APScheduler>=3.10.4,<4.0.0
+52
View File
@@ -0,0 +1,52 @@
import json
from pydantic import BaseModel, Field
from APIs.aichat import generate
import shared.config as config
class DishSafetyResult(BaseModel):
utensil_or_foreign_object_detected: bool = Field(
description="Set to True if ANY spoon, fork, knife, handle, or foil is inside or touching the bowl. Set to False ONLY if the bowl contains ONLY 100% food."
)
is_safe: bool = Field(
description="MUST be False if utensil_or_foreign_object_detected is True. Set to True ONLY if pure food/bowl with ZERO utensils."
)
detected_hazards: list[str] = Field(
description="List any items found (e.g., ['spoon']). Empty list [] if safe."
)
warning_message: str = Field(
description="Warning statement if unsafe, otherwise empty string ''."
)
def check_dish_safety(image_path: str) -> dict:
prompt = (
"Inspect this top-down photo of a food dish inside a microwave.\n\n"
"STRICT MICROWAVE RULES:\n"
"1. Check if ANY utensil (spoon, fork, knife, or handle) is present in or on the bowl.\n"
"2. If ANY spoon or utensil is present (metal, plastic, or ceramic), you MUST set `utensil_or_foreign_object_detected = True` and `is_safe = False`.\n"
"3. `is_safe` can ONLY be True if the dish contains strictly food and nothing else."
)
response_raw = generate(
prompt=prompt,
images=[image_path],
output_format=DishSafetyResult,
should_think=False,
)
if config.DEBUG_MESSAGES:
print(f"[Debug] Raw response from AI generator: {response_raw}")
if isinstance(response_raw, str):
try:
validated_result = DishSafetyResult.model_validate_json(response_raw)
return validated_result.model_dump()
except Exception:
return json.loads(response_raw)
if isinstance(response_raw, DishSafetyResult):
return response_raw.model_dump()
if isinstance(response_raw, dict):
return response_raw
raise ValueError(f"Unexpected response type from AI generator: {type(response_raw)}")
+3 -2
View File
@@ -2,8 +2,8 @@
# Configuration des ports (À remplacer par tes propres chemins by-path)
# Pour trouver tes chemins : ls -l /dev/serial/by-path/
PORT_ESP_WIFI="/dev/serial/by-path/pci-0000:00:14.0-usbv2-0:6.2:1.0-port0"
PORT_ESP_LORA="/dev/serial/by-path/pci-0000:00:14.0-usbv2-0:6.1:1.0-port0"
PORT_ESP_WIFI="/dev/serial/by-path/pci-0000:00:14.0-usb-0:2.1:1.0-port0"
PORT_ESP_LORA="/dev/serial/by-path/pci-0000:00:14.0-usb-0:2.2:1.0-port0"
# Ajoute les autres si besoin...
# Configuration Raspberry Pi
@@ -65,6 +65,7 @@ deploy_to_rpi() {
--exclude '__pycache__' \
--exclude '*.pyc' \
--exclude '.mypy_cache' \
--exclude 'ecran_externe' \
./ "${RPI_USER}@${RPI_HOST}:${RPI_DEST}/"
echo "🔄 Redémarrage du service systemd ${RPI_SYSTEMD_SERVICE}..."
+5
View File
@@ -0,0 +1,5 @@
# Gitignore settings for ESPHome
# This is an example and may include too much for your use-case.
# You can modify this file to suit your needs.
/.esphome/
/secrets.yaml
+19
View File
@@ -0,0 +1,19 @@
`esphome run epaper_thread_node.yaml`
`esphome logs epaper_thread_node.yaml`
# Config OTBR
## 1. Stop the active Thread interface
docker exec -it otbr ot-ctl thread stop
docker exec -it otbr ot-ctl ifconfig down
## 2. Inject your ESP32-H2 Active Dataset TLV
docker exec -it otbr ot-ctl dataset set active 00030000184a0300000b35060004001fffe00208f7754c0c4a4dea9a0708fd1adbbe7435e95405103f543281275a135e38982974aa71e987030f4f70656e5468726561642d3766343101027f410410a500c86abfe7ef17888c2232feac2dc60c0402a0f7f80e080000000000010000
## 3. Commit the dataset as active
docker exec -it otbr ot-ctl dataset commit active
## 4. Bring up the interface and start Thread
docker exec -it otbr ot-ctl ifconfig up
docker exec -it otbr ot-ctl thread start
+1
View File
@@ -0,0 +1 @@
3
+86
View File
@@ -0,0 +1,86 @@
#pragma once
#include "esphome.h"
#include "esphome/components/web_server_base/web_server_base.h"
#include "esphome/components/json/json_util.h"
#include <functional>
#include <string>
#include <vector>
struct MicrowaveData {
std::string id;
std::string name;
int state;
bool paused;
int remaining_time;
int progress;
};
// Global states parsed from the API
static std::vector<MicrowaveData> g_microwaves;
static bool g_cloud_alert = true;
static std::string g_update_time = "--:--";
static bool g_received_update = false;
// Callback signature for refreshing UI
using DisplayUpdateCallback = std::function<void()>;
static DisplayUpdateCallback g_display_callback = nullptr;
class DisplayApiHandler : public esphome::web_server_idf::AsyncWebHandler {
public:
bool canHandle(esphome::web_server_idf::AsyncWebServerRequest *request) const override {
// Provide the 513-byte buffer expected by ESPHome's new memory-safe API
char url_buf[513];
return request->url_to(url_buf) == "/api/display";
}
void handleRequest(esphome::web_server_idf::AsyncWebServerRequest *request) override {
request->send(200, "application/json", "{\"status\":\"ok\"}");
}
void handleBody(esphome::web_server_idf::AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) override {
std::string body((char *)data, len);
esphome::json::parse_json(body, [](JsonObject root) -> bool {
g_cloud_alert = root["cloud_alert"] | false;
g_update_time = root["update_time"] | "--:--";
g_microwaves.clear();
JsonArray mw_array = root["microwaves"];
for (JsonObject mw : mw_array) {
MicrowaveData md;
md.id = mw["id"] | "unknown";
md.name = mw["name"] | "Microwave";
md.state = mw["state"] | 4;
md.paused = mw["paused"] | false;
md.remaining_time = mw["remaining_time"] | 0;
md.progress = mw["progress"] | 0;
g_microwaves.push_back(md);
}
g_received_update = true;
ESP_LOGI("custom_api", "Received update. Cloud Alert: %d, Microwaves: %d",
g_cloud_alert, g_microwaves.size());
if (g_display_callback != nullptr) {
g_display_callback();
}
return true;
});
request->send(200, "application/json", "{\"status\":\"ok\"}");
}
};
inline void setup_display_api(DisplayUpdateCallback callback) {
g_display_callback = callback;
if (esphome::web_server_base::global_web_server_base != nullptr) {
esphome::web_server_base::global_web_server_base->add_handler(new DisplayApiHandler());
ESP_LOGI("custom_api", "Successfully registered /api/display POST handler");
} else {
ESP_LOGE("custom_api", "Failed to register endpoint: global_web_server_base is null");
}
}
View File
+208
View File
@@ -0,0 +1,208 @@
substitutions:
device_id: !include device_id.txt
esphome:
name: "smartwave-epaper-${device_id}"
friendly_name: "SmartWave ePaper ExternalScreen ${device_id}"
includes:
- display_api.h
on_boot:
priority: -100.0
then:
- lambda: |-
setup_display_api([]() {
// Force immediate e-Paper refresh!
id(my_display).update();
});
web_server:
port: 5000
mdns:
disabled: false
services:
- service: "_displaytcp"
protocol: "_tcp"
port: 5000
txt:
version: "1.0"
type: "epaper_2.13"
esp32:
variant: esp32h2
framework:
type: esp-idf
log_level: INFO
advanced:
loop_task_stack_size: 32768
sdkconfig_options:
CONFIG_ESP_MAIN_TASK_STACK_SIZE: "16384"
CONFIG_OPENTHREAD_TASK_STACK_SIZE: "8192"
CONFIG_ESP_WIFI_ENABLED: "n"
CONFIG_OPENTHREAD_ENABLED: "y"
CONFIG_MBEDTLS_DYNAMIC_BUFFER: "y"
CONFIG_MBEDTLS_DYNAMIC_FREE_CONFIG_DATA: "y"
logger:
level: VERBOSE
hardware_uart: UART0
logs:
openthread: INFO
network:
enable_ipv6: true
openthread:
force_dataset: true
tlv: 00030000184a0300000b35060004001fffe00208f7754c0c4a4dea9a0708fd1adbbe7435e95405103f543281275a135e38982974aa71e987030f4f70656e5468726561642d3766343101027f410410a500c86abfe7ef17888c2232feac2dc60c0402a0f7f80e080000000000010000
# -------------------------------------------------------------------
# MQTT Connection over Thread IPv6
# -------------------------------------------------------------------
# mqtt:
# broker: "mqtt.thread.smartwave.matthiasg.dev"
# # broker: "mqtt://[fd36:5102:8d0:1:f1cd:3499:e546:dbec]"
# # port: 1883
# topic_prefix: "microwave/display"
# keepalive: 60s
# on_connect:
# - mqtt.publish:
# topic: smartwave/hello
# payload: !lambda |-
# return std::string("{\"id_microwave\":\"") + "${device_id}" + "\",\"type\":\"externalDisplay\"}";
# on_message:
# - topic: microwave/display/status
# then:
# - text_sensor.template.publish:
# id: status_message
# state: !lambda 'return x;'
# - component.update: my_display
# -------------------------------------------------------------------
# Template Text Sensor
# -------------------------------------------------------------------
text_sensor:
- platform: openthread_info
role:
name: "Thread Role"
id: thread_role
channel:
name: "Thread Channel"
id: thread_channel
# -------------------------------------------------------------------
# Hardware SPI Pin Mapping (ESP32-H2)
# -------------------------------------------------------------------
spi:
clk_pin: GPIO10
mosi_pin: GPIO11
# -------------------------------------------------------------------
# Fonts
# -------------------------------------------------------------------
font:
- file:
type: gfonts
family: Open+Sans
weight: 700
size: 20
id: font_header
- file:
type: gfonts
family: Overpass
weight: 600
id: font_body
size: 14
- file:
type: gfonts
family: Overpass
weight: 500
id: font_small
size: 12
# -------------------------------------------------------------------
# Waveshare 2.13-inch e-Paper Display (250x122 standard resolution)
# -------------------------------------------------------------------
display:
- platform: waveshare_epaper
cs_pin: GPIO4
dc_pin: GPIO1
reset_pin: GPIO2
busy_pin: GPIO3
model: 2.13inv3
rotation: 90°
update_interval: 30min
id: my_display
lambda: |-
// --- STARTUP SCREEN ---
if (!g_received_update) {
int center_x = it.get_width() / 2;
int center_y = it.get_height() / 2;
it.printf(center_x, center_y - 10, id(font_header), TextAlign::CENTER, "SmartWave");
it.printf(center_x, center_y + 15, id(font_body), TextAlign::CENTER, "Waiting for updates...");
return;
}
// --- MICROWAVE LAYOUT ---
int y_offset = 0;
for (const auto& mw : g_microwaves) {
// Name
it.print(0, y_offset, id(font_header), mw.name.c_str());
// Decode State Text
std::string state_str;
switch(mw.state) {
case 0: state_str = mw.paused ? "PAUSED" : "COOKING"; break;
case 1: state_str = "STIRRING REQ."; break;
case 2: state_str = "DONE"; break;
case 3: state_str = "ALERT"; break;
case 4: state_str = "IDLE"; break;
default: state_str = "UNKNOWN"; break;
}
// Print Status (Top Right Align)
it.printf(it.get_width(), y_offset + 5, id(font_body), TextAlign::TOP_RIGHT, "%s", state_str.c_str());
// Process active/cooking visualisations
if (mw.state == 0) {
int bar_y = y_offset + 28;
int bar_w = 175;
int bar_h = 16;
// Draw progress bar outline & fill
it.rectangle(0, bar_y, bar_w, bar_h);
if (mw.progress > 0) {
int fill_w = (mw.progress * bar_w) / 100;
it.filled_rectangle(0, bar_y, fill_w, bar_h);
}
// Draw time remaining (or paused state)
if (mw.paused) {
it.printf(185, bar_y + 1, id(font_body), "PAUSED");
} else {
int mins = mw.remaining_time / 60;
int secs = mw.remaining_time % 60;
it.printf(185, bar_y + 1, id(font_body), "%02d:%02d", mins, secs);
}
}
else if (mw.state == 1) { // Stirring Required
it.filled_rectangle(0, y_offset + 28, 250, 20);
it.printf(125, y_offset + 28, id(font_body), COLOR_OFF, TextAlign::TOP_CENTER, "ACTION REQUIRED: STIR");
}
// Move cursor down (if we have more than 1 microwave on future larger screens)
y_offset += 60;
}
// --- SYSTEM FOOTER ---
// Line separator right above footer
it.line(0, 102, it.get_width(), 102);
// Bottom Left: Cloud Reachability & Last Update Time
std::string cloud_status = g_cloud_alert ? "Cloud: ERR" : "Cloud: OK";
it.printf(0, 105, id(font_small), "%s | %s", cloud_status.c_str(), g_update_time.c_str());
// Bottom Right: OpenThread Diagnostics
std::string role = id(thread_role).state;
it.printf(it.get_width(), 105, id(font_small), TextAlign::TOP_RIGHT,
"Th: %s (Ch %s)", role.c_str(), id(thread_channel).state.c_str());
+29
View File
@@ -0,0 +1,29 @@
esphome:
name: h2-test-node
esp32:
board: esp32-h2-devkitm-1
variant: esp32h2
framework:
type: esp-idf
advanced:
loop_task_stack_size: 16384
sdkconfig_options:
CONFIG_ESP_MAIN_TASK_STACK_SIZE: "16384"
CONFIG_OPENTHREAD_TASK_STACK_SIZE: "8192"
CONFIG_ESP_WIFI_ENABLED: "n"
CONFIG_OPENTHREAD_ENABLED: "y"
CONFIG_ESP_CONSOLE_UART_DEFAULT: "y"
logger:
level: INFO
hardware_uart: UART0
network:
enable_ipv6: true
openthread:
network_name: "OpenThread-7f41"
pan_id: 0x7f41
channel: 24
network_key: "0x3f543281275a135e38982974aa71e987"
BIN
View File
Binary file not shown.
+38 -18
View File
@@ -2,6 +2,7 @@ import time
from shared.cookingState import CookingState, CookingStates
from shared import config
import framebuf
from shared.microwave_state import MicrowaveState
# Icon and bitmaps
image_weather_frost_bits = bytearray(b'\x01\x00\x13\x901\x18s\x9c\x09 \x05@S\x94\xfe\xfeS\x94\x05@\x09 s\x9c1\x18\x13\x90\x01\x00\x00\x00')
@@ -10,12 +11,16 @@ image_CoolHiSmall_bits = bytearray(b'\x1f\xff\xc0?\xff\xe0\x7f\xff\xf0\xff\xdf\x
image_CoolLoSmall_bits = bytearray(b'\x1f\xff\xc0 \x00 @\x00\x10\x80 \x08\x80\xa8\x08\x82r\x08\x86#\x08\x81$\x08\x88\xa8\x88\x84q\x08\x9f\xff\xc8\x84q\x08\x88\xa8\x88\x81$\x08\x86#\x08\x82r\x08\x80\xa8\x08\x80 \x08@\x00\x10 \x00 \x1f\xff\xc0')
image_music_sound_wave_bits = bytearray(b'\x00\x00\x00\x02\x00\x00\x02 \x00\x02 \x00\x02 \x00\x0a(\x00\x0a\xa8\x00*\xa8\x00\xaa\xaa\x80\x0a\xaa\x00\x0a\xa8\x00\x0a \x00\x02 \x00\x02 \x00\x02\x00\x00\x00\x00\x00')
image_weather_temperature_bits = bytearray(b'\x1c\x00\x22\x00+\x00*\x00+\x00*\x00+\x00*\x00*\x00I\x00\x9c\x80\xae\x80\xbe\x80\x9c\x80A\x00>\x00')
image_camera_bits = bytearray(b'\x00x\x000\x84\x00\x7f{\x00\xc0\x00\x80\x91\xc6\x80\x860\x80\x85\xd0\x80\x8a(\x80\x8a(\x80\x8a(\x80\x85\xd0\x80\x860\x80\x91\xc6\x80\xc0\x00\x80\x7f\xff\x00\x00\x00\x00')
image_cloud_sync_bits = bytearray(b'\x00\x00\x00\x07\xc0\x00\x08 \x00\x10\x10\x000\x08\x00@\x0e\x00\x80\x01\x00\x82 \x80G \x80/\xaf\x00\x02 \x00\x02\xf8\x00\x02p\x00\x02 \x00\x00\x00\x00\x00\x00\x00')
fb_image_weather_frost_bits = framebuf.FrameBuffer(image_weather_frost_bits, 15, 16, framebuf.MONO_HLSB)
fb_image_CoolHiSmall_bits = framebuf.FrameBuffer(image_CoolHiSmall_bits, 21, 21, framebuf.MONO_HLSB)
fb_image_weather_temperature_bits = framebuf.FrameBuffer(image_weather_temperature_bits, 9, 16, framebuf.MONO_HLSB)
fb_image_Alert_bits = framebuf.FrameBuffer(image_Alert_bits, 9, 8, framebuf.MONO_HLSB)
fb_image_music_sound_wave_bits = framebuf.FrameBuffer(image_music_sound_wave_bits, 17, 16, framebuf.MONO_HLSB)
fb_image_CoolLoSmall_bits = framebuf.FrameBuffer(image_CoolLoSmall_bits, 21, 21, framebuf.MONO_HLSB)
fb_image_camera_bits = framebuf.FrameBuffer(image_camera_bits, 17, 16, framebuf.MONO_HLSB)
fb_image_cloud_sync_bits = framebuf.FrameBuffer(image_cloud_sync_bits, 17, 16, framebuf.MONO_HLSB)
# Constants
CHARACTER_WIDTH = 6
@@ -70,44 +75,54 @@ class MicrowaveScreen:
self.display.fill_rect(x, y, fill_width, height, color)
def update(self, cooking_state: CookingState | None, defrost_mode: bool = False):
def update(self, cooking_state: CookingState | None, defrost_mode: bool = False, microwave_state: str | None = None):
self.flick()
# self.lastUpdateTime = time.time() # Not Used right now
if not cooking_state or cooking_state.state == CookingStates.IDLE: # Is not currently cooking
if (not microwave_state or microwave_state in (MicrowaveState.DONE, MicrowaveState.IDLE)) and (not cooking_state or cooking_state.state == CookingStates.IDLE): # Is not currently cooking
self.h_centered_text("Ready !", 28, 1)
else:
# Screen center
if cooking_state.state == CookingStates.STIRRING_REQUIRED:
if cooking_state and cooking_state.state == CookingStates.STIRRING_REQUIRED:
self._message("Pls Stir", True)
elif cooking_state.state == CookingStates.ALERT:
self._message("Alert !", True)
elif cooking_state.state == CookingStates.DONE:
elif cooking_state and cooking_state.state == CookingStates.ALERT:
# nothing to do, the alert will be displayed by the alert manager
return
elif cooking_state and cooking_state.state == CookingStates.DONE:
self._message("Done !", False)
else: # Progress bar
elif microwave_state and microwave_state == MicrowaveState.ANALYZING:
self.display.blit(fb_image_camera_bits, 56, 24)
self._secondary_message("Analyzing...", False)
elif microwave_state and microwave_state == MicrowaveState.WAITING_FOR_CLOUD:
self.display.blit(fb_image_cloud_sync_bits, 55, 24)
self._secondary_message("Receiving...", False)
elif microwave_state and microwave_state == MicrowaveState.ALERT:
# nothing to do, the alert will be displayed by the alert manager
pass
elif cooking_state: # Progress bar
elapsed_time = cooking_state.get_elapsed_time()
estimated_progress = elapsed_time / (elapsed_time + cooking_state.estimated_remaining_time)
self._progressBar(estimated_progress, 21, 25)
# Estimated remaining time
if cooking_state.paused:
if cooking_state and cooking_state.paused:
self.h_centered_text("Paused", 40, 1)
elif cooking_state.state != CookingStates.DONE:
elif cooking_state and cooking_state.state != CookingStates.DONE:
remaining_time = int(cooking_state.estimated_remaining_time)
minutes = remaining_time // 60
seconds = remaining_time % 60
self.h_centered_text(f"{minutes:02}:{seconds:02}", 40, 1)
# Current dish temperature
if cooking_state.current_dish_temp is not None and cooking_state.current_dish_temp is not 0.0:
if cooking_state and cooking_state.current_dish_temp is not None and cooking_state.current_dish_temp is not 0.0:
self.display.blit(fb_image_weather_temperature_bits, 27, 2)
self.display.text(str(int(cooking_state.current_dish_temp)), 37, 6, 1)
self.display.ellipse(54, 6, 1, 1, 1, False)
self.display.text("C", 56, 6, 1)
# Power level
if cooking_state.power_level is not None:
if cooking_state and cooking_state.power_level is not None:
self.display.blit(fb_image_music_sound_wave_bits, 74, 2)
self.display.text(f"{cooking_state.power_level}W", 92, 6, 1)
if defrost_mode:
@@ -118,15 +133,20 @@ class MicrowaveScreen:
self.show()
pass
def _message(self, text, alert):
self.display.fill_rect(0, 27, self.width, CHARACTER_HEIGHT + 4, 1)
def _message_at(self, y, text, alert):
self.display.fill_rect(0, y, self.width, CHARACTER_HEIGHT + 4, 1)
if alert:
self.display.blit(fb_image_Alert_bits, 10, 29)
self.display.blit(fb_image_Alert_bits, self.width - 10, 29)
self.h_centered_text(text, 29, 0)
self.display.blit(fb_image_Alert_bits, 1, y+2)
self.display.blit(fb_image_Alert_bits, self.width - 10, y+2)
self.h_centered_text(text, y+2, 0)
def _message(self, text, alert):
self._message_at(27, text, alert)
def _secondary_message(self, text, alert):
self._message_at(40, text, alert)
def message(self, text, alert):
self.flick()
# self.flick()
self._message(text, alert)
self.show()
pass
+162 -47
View File
@@ -1,8 +1,6 @@
import gc
import sys
import time
import _thread
from lib.microwaveScreen import MicrowaveScreen
import uasyncio as asyncio
from machine import Pin, SoftI2C
import ssd1306
@@ -17,6 +15,10 @@ from shared.uart_comm import UARTCommand, UARTCommandType
from shared.sensors import RGBLED
from shared.logging import log
from shared.lora_device import LoraCommands
from shared.alerts import Alert, AlertType, AlertManager
from shared.microwave_state import MicrowaveState
from shared.payloads import lora_new_alert
from lib.microwaveScreen import MicrowaveScreen
# --- READ DEVICE ID ---
try:
@@ -27,6 +29,8 @@ except Exception:
# --- GLOBAL VARIABLES ---
cooking_state = None
cooking_start_time = None # Track start timestamp
microwave_state: str = None
data_queue = SafeQueue()
lora = None
uart_device = None
@@ -37,6 +41,7 @@ current_temp = [None, None]
last_temp = [None, None]
temperature_asked = False
last_temp_request_time = 0
alert_manager = None
PING_PAYLOAD = {
@@ -44,6 +49,46 @@ PING_PAYLOAD = {
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
}
last_cooking_update = {}
def send_cooking_update(state=None, start_time=None, estimated_remaining_time=None, paused=None):
"""Sends a flexible COOKING_UPDATE payload containing only populated fields."""
global last_cooking_update
if not lora:
return
send_reliably = False
payload = {
"id": DEVICE_ID,
"action": LoraCommands.COOKING_UPDATE
}
if state is not None and state != last_cooking_update.get("cooking_state"):
payload["cooking_state"] = state
send_reliably = True
if estimated_remaining_time is not None:
payload["estimated_remaining_time"] = estimated_remaining_time
if paused is not None:
payload["paused"] = paused
# Only transmit if at least one field beyond id and action was provided
if len(payload) > 2:
log(f"[LoRa] Sending COOKING_UPDATE payload: {payload}")
last_cooking_update = payload.copy()
if send_reliably:
lora.send_reliable(payload)
else:
lora.send(payload)
def on_new_alert(alert: Alert):
global microwave_screen
print(f"[Alert Manager] New alert received: {alert.to_dict()}")
microwave_screen.message(alert.small_message if alert.small_message else alert.message, True)
def init_hardware():
"""Initializes all hardware components."""
global lora, uart_device, magnetron_led, microwave_screen
@@ -77,10 +122,9 @@ def init_hardware():
print(f"[Main] ESP32 initialized with ID: '{DEVICE_ID}' (Type: {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
# --- DEDICATED LORA HARDWARE THREAD ---
def lora_hardware_thread():
"""Runs in a separate OS thread to keep the LoRa radio in continuous RX mode."""
# --- ASYNC LORA TASK (REPLACES _THREAD) ---
async def lora_rx_and_heartbeat_task():
"""Replaces the hardware OS thread with a non-blocking async task to avoid SPI collisions."""
last_heartbeat_time = 0
while True:
@@ -89,19 +133,22 @@ def lora_hardware_thread():
# 1. Send periodic heartbeat
if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
last_heartbeat_time = now
log("\n[LoRa Thread] Sending Heartbeat...")
log("\n[LoRa Task] Sending Heartbeat...")
if lora:
lora.send(PING_PAYLOAD)
# CRITICAL: Force the radio chip out of Standby mode and back into RX mode
if hasattr(lora, 'receive'):
lora.receive()
# 2. Blocking 300ms RX listen window (keeps radio actively listening)
# 2. Listen for incoming packets (Must be longer than LoRa Time-on-Air)
if lora:
paquet = lora.receive_reliable(timeout_ms=300)
paquet = lora.receive_reliable(timeout_ms=500)
if paquet is not None:
log(f"[LoRa Thread] New Packet Received: {paquet}")
log(f"[LoRa Task] New Packet Received: {paquet}")
data_queue.put(paquet)
time.sleep_ms(10)
# Give control back to event loop
await asyncio.sleep_ms(10)
# --- COOKING STATE CALLBACKS ---
def cooking_state_temperature_provider():
@@ -131,10 +178,23 @@ def cooking_state_temperature_provider():
# 3. Handle fresh incoming reading
if not temp_is_invalid(current_temp):
temps = [float(current_temp[0]), float(current_temp[1])]
last_temp = [temps[0], temps[1]] # Keep a safe reference copy
# Reset current_temp buffer to consume the value
last_temp = [temps[0], temps[1]]
current_temp = [None, None]
if config.DEBUG_TEMPERATURE_ALERT or temps[0] > config.MAX_ALLOWED_TEMPERATURE_COOKING_COMPARTMENT or temps[1] > config.MAX_ALLOWED_TEMPERATURE_TECHNICAL_COMPARTMENT:
print(f"[CookingState] ALERT: Unsafe temperature detected! Dish: {temps[0]}°C, Ambient: {temps[1]}°C")
# Send alert to orchestrator via LoRa
alert = Alert(
AlertType.TEMPERATURE_ALERT,
f"Unsafe temperature detected! Dish: {temps[0]}/{config.MAX_ALLOWED_TEMPERATURE_COOKING_COMPARTMENT}°C, Ambient: {temps[1]}/{config.MAX_ALLOWED_TEMPERATURE_TECHNICAL_COMPARTMENT}°C",
small_message=f"Temp too high"
)
alert_manager.add_alert(alert)
if lora:
lora.send_reliable(lora_new_alert(alert, with_message=True), max_retries=5)
cooking_state.set_state(cookingState.CookingStates.ALERT)
cooking_state.pause()
return temps
# 4. Fallback: Use last valid reading
@@ -145,38 +205,57 @@ def cooking_state_temperature_provider():
return (0.0, 0.0)
def cooking_state_on_state_change(state):
global cooking_start_time, uart_device
print(f"[CookingState] State changed to: {state.state}")
if state.paused or state.state == cookingState.CookingStates.DONE or state.state == cookingState.CookingStates.IDLE:
if state.paused or state.state in (cookingState.CookingStates.DONE, cookingState.CookingStates.IDLE, cookingState.CookingStates.ALERT):
magnetron_led.off()
else:
magnetron_led.on()
# Send state updates to WiFi board and Orchestrator
if uart_device:
if uart_device != None:
uart_device.send_as_command(UARTCommand(UARTCommandType.COOKING_STATE_UPDATE, {"state": state.state}))
if lora:
lora.send_reliable({"id": DEVICE_ID, "new_cooking_state": state.state})
# Send LoRa update with start time and remaining time if beginning cooking process
send_cooking_update(
state=state.state,
start_time=cooking_start_time if state.state == cookingState.CookingStates.COOKING else None,
estimated_remaining_time=state.get_remaining_time() if hasattr(state, "get_remaining_time") else None,
paused=state.paused
)
microwave_screen.update(cooking_state, defrost_mode)
update_screen()
def cooking_state_on_refresh(state):
# Update OLED display
if microwave_screen:
microwave_screen.update(state, defrost_mode)
update_screen()
def cooking_state_on_pause(state):
# If the cooking is unpaused and was in STIRRING_REQUIRED or ALERT state, we set the state back to COOKING.
if not state.paused and (state.state == cookingState.CookingStates.STIRRING_REQUIRED or state.state == cookingState.CookingStates.ALERT):
state.set_state(cookingState.CookingStates.COOKING)
# TODO: send_reliable lora message to orchestrator about pause/resume state
# Send update on pause/resume toggle
send_cooking_update(
state=state.state,
paused=state.paused,
estimated_remaining_time=state.get_remaining_time()
)
# --- SCREEN UPDATE ---
def update_screen():
global microwave_screen, cooking_state, defrost_mode, microwave_state
if microwave_screen:
microwave_screen.update(cooking_state, defrost_mode, microwave_state)
# --- ASYNC TASKS ---
async def uart_polling_task():
"""Polls UART for incoming messages from the WiFi board."""
global cooking_state, temperature_asked
global cooking_state, cooking_start_time, temperature_asked, microwave_state
while True:
if uart_device and uart_device.any():
@@ -189,6 +268,9 @@ async def uart_polling_task():
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_REQUEST, {}))
# Track start time timestamp
cooking_start_time = time.time()
cooking_state = cookingState.CookingState(
cook_time=params["cook_time"],
power_level=params["power_level"],
@@ -201,6 +283,7 @@ async def uart_polling_task():
await asyncio.sleep_ms(200)
cooking_state_on_state_change(cooking_state)
microwave_state = MicrowaveState.COOKING
elif command.command_type == UARTCommandType.TEMPERATURE_RESPONSE:
try:
# Check if payload is already a dict or needs JSON decoding
@@ -219,7 +302,7 @@ async def uart_polling_task():
async def lora_process_task():
"""Consumes packets pushed to data_queue by the LoRa hardware thread."""
global cooking_state, defrost_mode, microwave_screen
global cooking_state, defrost_mode, microwave_screen, microwave_state
while True:
while not data_queue.empty():
@@ -227,28 +310,59 @@ async def lora_process_task():
if paquet and not paquet.get("raw"):
data = paquet.get("data", {})
# RECIPIENT FILTERING: Drop packets not addressed to this ESP32 or ALL
target = data.get("target_id") or data.get("recipient")
if target and target not in [DEVICE_ID, "ALL", "BROADCAST"]:
log(f"[LoRa Process] Ignoring packet intended for {target}")
continue
if "action" in data:
if data["action"] == LoraCommands.TOGGLE_PAUSE:
action = data["action"]
if action == LoraCommands.TOGGLE_PAUSE:
if cooking_state is not None:
if cooking_state.state == cookingState.CookingStates.DONE:
print("[LoRa Process] Cooking is done. Resetting microwave for the next session.")
print("[LoRa Process] Cooking is done. Resetting microwave for next session.")
cooking_state.set_state(cookingState.CookingStates.IDLE)
await asyncio.sleep_ms(20)
cooking_state = None
else:
cooking_state.toggle_pause()
if cooking_state.paused:
print("[LoRa Process] Cooking paused via orchestrator command.")
else:
print("[LoRa Process] Cooking resumed via orchestrator command.")
print(f"[LoRa Process] Cooking paused state toggled to {cooking_state.paused}")
else:
log("[LoRa Process] No active cooking state to toggle pause/resume.")
elif data["action"] == LoraCommands.TOGGLE_DEFROST:
elif action == LoraCommands.TOGGLE_DEFROST:
print("[LoRa Process] Toggling defrost mode via orchestrator command.")
defrost_mode = data["defrost_state"]
microwave_screen.update(cooking_state, defrost_mode)
defrost_mode = data.get("defrost_state", False)
update_screen()
elif action == LoraCommands.NEW_ALERT:
alert: Alert | None = None
if data.get("alert_type") == AlertType.TEMPERATURE_ALERT:
alert = Alert(data.get("alert_type"), data.get("alert_message", "Temp too high"), redistributed=True)
elif data.get("alert_type") == AlertType.COOKING_SAFETY:
alert = Alert(data.get("alert_type"), data.get("alert_message", "Unsafe area"), redistributed=True)
await asyncio.sleep_ms(50)
if alert:
alert.timestamp = data.get("timestamp", time.time())
alert_manager.add_alert(alert)
microwave_state = MicrowaveState.ALERT
if cooking_state is not None:
cooking_state.set_state(cookingState.CookingStates.ALERT)
elif action == LoraCommands.MICROVAVE_STATE_UPDATE:
new_state = data.get("new_microwave_state")
if new_state:
print(f"[LoRa Process] Microwave state update received: {new_state}")
microwave_state = new_state
if new_state not in (MicrowaveState.ALERT): # Not alert so we can show the error
update_screen()
if new_state == MicrowaveState.IDLE:
if cooking_state is not None:
cooking_state.set_state(cookingState.CookingStates.IDLE)
await asyncio.sleep_ms(20)
await asyncio.sleep_ms(100)
async def cooking_loop_task():
@@ -259,10 +373,16 @@ async def cooking_loop_task():
cooking_state.update_tick()
log_counter += 1
# Print log output every 5 seconds (10 ticks x 500ms)
# Print log output and send periodic updates every 5 seconds (10 ticks x 500ms)
if log_counter % 10 == 0:
print(f"[Cooking Task] State: {cooking_state.state}, Temp: {cooking_state.current_dish_temp}, "
f"Paused: {cooking_state.paused}, Remaining: {cooking_state.get_remaining_time():.2f}s")
# Send periodic time remaining update while actively cooking
if not cooking_state.paused and cooking_state.state == cookingState.CookingStates.COOKING:
send_cooking_update(
estimated_remaining_time=cooking_state.get_remaining_time()
)
await asyncio.sleep_ms(500)
@@ -276,20 +396,16 @@ async def memory_cleanup_task():
# --- BOOTSTRAP ---
async def main():
global cooking_state, defrost_mode, microwave_screen
global alert_manager
print("[Main] Starting application...")
init_hardware()
# Launch dedicated hardware thread for LoRa RX
try:
_thread.stack_size(16 * 1024)
except Exception:
pass
_thread.start_new_thread(lora_hardware_thread, ())
print("[Main] LoRa hardware background thread started.")
alert_manager = AlertManager()
alert_manager.set_on_alert_callback(on_new_alert)
# Launch background async tasks
# Launch all tasks within the same single-threaded uasyncio event loop
asyncio.create_task(lora_rx_and_heartbeat_task())
asyncio.create_task(uart_polling_task())
asyncio.create_task(lora_process_task())
asyncio.create_task(cooking_loop_task())
@@ -297,8 +413,7 @@ async def main():
print("[Main] All async tasks running concurrently!")
microwave_screen.update(None, defrost_mode)
update_screen()
# Keep main task alive indefinitely
while True:
await asyncio.sleep(3600)
+1 -1
View File
@@ -223,7 +223,7 @@ async def connect_mqtt_async():
mqtt_client = get_mqtt_client(
host="192.168.50.1", # TODO : Use config.MQTT_BROKER_HOST instead of hardcoding
port=8884,
client_id="smartwave-esp32-demo",
client_id="smartwave-esp32-demo", # TODO : Use DEVICE_ID instead of hardcoding
use_tls=True,
cafile=MQTT_CA_FILE,
keepalive=30,
+8 -1
View File
@@ -2,4 +2,11 @@
# IP Adresses
- AP (dynamic) : `10.110.0.190`
- Generated Wifi : `192.168.50.1`
- Generated Wifi : `192.168.50.1`
# RFID
## Cards
- 2700423026
- 270042302B
+26 -5
View File
@@ -1,11 +1,32 @@
services:
otbr:
image: openthread/border-router:latest
container_name: otbr
restart: unless-stopped
# privileged: true
network_mode: host
cap_add:
- NET_ADMIN
environment:
- OT_RCP_DEVICE=spinel+hdlc+uart:///dev/ttyUSB1?uart-baudrate=460800
- OT_INFRA_IF=wlan1
- OT_THREAD_IF=wpan0
- OT_LOG_LEVEL=6
- FIREWALL=0
- NAT64=1
devices:
- /dev/serial/by-id/usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_Controller_ba78c1181566ee11b42a8d6293cd958c-if00-port0:/dev/ttyUSB1
- /dev/net/tun:/dev/net/tun
volumes:
- otbr-data:/data
mqtt-broker:
image: eclipse-mosquitto:2.0
container_name: mqtt-broker
restart: unless-stopped
network_mode: host
environment:
MQTT_TLS_ENABLED: ${MQTT_TLS_ENABLED:-true}
# restart: unless-stopped
ports:
- "192.168.50.1:8884:8884"
volumes:
- ./mqtt/mosquitto-tls.conf:/mosquitto/config/mosquitto-tls.conf:ro
- ./mqtt/mosquitto-plain.conf:/mosquitto/config/mosquitto-plain.conf:ro
@@ -14,8 +35,8 @@ services:
- mqtt-data:/mosquitto/data
- mqtt-log:/mosquitto/log
command: ["/bin/sh", "/scripts/start-broker.sh"]
# command: ["tail", "-f", "/dev/null"] # Do nothing
volumes:
otbr-data:
mqtt-data:
mqtt-log:
mqtt-log:
+1
View File
@@ -0,0 +1 @@
zeJlYcaW0dji4A88Q3OGfvqgqXUuUAm0
+135
View File
@@ -0,0 +1,135 @@
import asyncio
import time
import requests
import datetime
from typing import Dict
from zeroconf import ServiceStateChange
from zeroconf.asyncio import AsyncZeroconf, AsyncServiceBrowser, AsyncServiceInfo
SERVICE_TYPE = "_displaytcp._tcp.local."
class DisplayManager:
def __init__(self, endpoint_path: str = "/api/display"):
self.endpoint_path = endpoint_path
self.screens: Dict[str, str] = {} # Map: service_name -> URL
self.aiozc: AsyncZeroconf | None = None
self.browser: AsyncServiceBrowser | None = None
self._on_screens_change_callback = None # Callback for screen changes
async def start(self):
"""Starts dynamic mDNS discovery for e-Paper screens."""
self.aiozc = AsyncZeroconf()
self.browser = AsyncServiceBrowser(
self.aiozc.zeroconf,
SERVICE_TYPE,
handlers=[self._on_service_state_change]
)
print(f"[DisplayManager] 🔍 Listening for mDNS screens ({SERVICE_TYPE})...")
def _on_service_state_change(self, zeroconf, service_type, name, state_change):
asyncio.create_task(self._update_service(name, state_change))
async def _update_service(self, name: str, state_change: ServiceStateChange):
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
info = AsyncServiceInfo(SERVICE_TYPE, name)
if await info.async_request(self.aiozc.zeroconf, 3000):
addresses = info.parsed_addresses()
if addresses:
# Prefer IPv4 if available, fallback to IPv6 (Thread)
ipv4_addrs = [a for a in addresses if ":" not in a]
if ipv4_addrs:
ip_str = ipv4_addrs[0]
else:
raw_ip = addresses[0]
# Wrap IPv6 addresses in square brackets for valid HTTP URLs
ip_str = f"[{raw_ip}]" if not raw_ip.startswith("[") else raw_ip
port = info.port or 5000
url = f"http://{ip_str}:{port}{self.endpoint_path}"
self.screens[name] = url
if self._on_screens_change_callback:
self._on_screens_change_callback(self.screens, is_removed=False, modified_name=name)
print(f"[DisplayManager] 📺 Screen registered: {name} -> {url}")
elif state_change == ServiceStateChange.Removed:
if name in self.screens:
print(f"[DisplayManager] ❌ Screen disconnected: {name}")
self.screens.pop(name, None)
if self._on_screens_change_callback:
self._on_screens_change_callback(self.screens, is_removed=True, modified_name=name)
def set_on_screens_change_callback(self, callback):
"""Set a callback function to be called when screens are added or removed."""
self._on_screens_change_callback = callback
async def broadcast_state(self, microwave_states: dict, cloud_alert: bool):
"""Broadcasts the system state JSON to all discovered screens concurrently."""
if not self.screens:
return
now_time = time.time()
update_time_str = datetime.datetime.now().strftime("%H:%M")
microwaves_list = []
for mw_id, state_info in microwave_states.items():
# Extract state properties safely
state_val = state_info.cooking_state
paused = state_info.paused
rem_time = state_info.cooking_estimated_remaining_time
start_t = state_info.cooking_start_time
name = state_info.friendly_name
# Calculate progress percentage (0 to 100)
progress = 0
if start_t > 0 and rem_time > 0:
elapsed = now_time - start_t
total = elapsed + rem_time
if total > 0:
progress = int((elapsed / total) * 100)
progress = max(0, min(100, progress))
microwaves_list.append({
"id": mw_id,
"name": name,
"state": state_val,
"paused": paused,
"remaining_time": rem_time,
"progress": progress
})
payload = {
"cloud_alert": cloud_alert,
"update_time": update_time_str,
"microwaves": microwaves_list
}
# Dispatch POST requests in parallel without blocking the main loop
tasks = [
self._post_to_screen(name, url, payload)
for name, url in list(self.screens.items())
]
await asyncio.gather(*tasks, return_exceptions=True)
async def _post_to_screen(self, name: str, url: str, payload: dict):
try:
response = await asyncio.to_thread(
requests.post,
url,
json=payload,
headers={"Content-Type": "application/json"},
timeout=3.0
)
response.raise_for_status()
print(f"[DisplayManager] Updated {name}")
except Exception as e:
print(f"[DisplayManager] Error pushing state to {name}: {e}")
async def stop(self):
"""Clean up mDNS browser and Zeroconf instance."""
if self.browser:
await self.browser.async_cancel()
if self.aiozc:
await self.aiozc.async_close()
print("[DisplayManager] Stopped service browser.")
+17 -12
View File
@@ -3,6 +3,7 @@ set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(dirname -- "$SCRIPT_DIR")
VENV_DIR="$SCRIPT_DIR/venv"
PYTHON_BIN="${PYTHON_BIN:-python3}"
PYTHON_SCRIPT="${1:-$SCRIPT_DIR/main.py}"
REQUIREMENTS_FILE="$SCRIPT_DIR/requirements.txt"
@@ -19,24 +20,28 @@ cd "$SCRIPT_DIR"
# 2. On lance Docker en arrière-plan
echo "Démarrage des conteneurs Docker..."
# docker compose pull
docker compose up -d --remove-orphans
# 3. Installation des dépendances (sans '--user' si on est déjà root sous systemd)
# 3. Gestion propre de l'environnement virtuel Python (contourne PEP 668)
if [ ! -d "$VENV_DIR" ]; then
echo "Création de l'environnement virtuel Python..."
"$PYTHON_BIN" -m venv "$VENV_DIR"
fi
# Utiliser le pip/python du venv
VENV_PYTHON="$VENV_DIR/bin/python"
VENV_PIP="$VENV_DIR/bin/pip"
if [ -f "$REQUIREMENTS_FILE" ]; then
echo "Vérification des dépendances Python..."
if [ "$(id -u)" -eq 0 ]; then
"$PYTHON_BIN" -m pip install -r "$REQUIREMENTS_FILE"
else
"$PYTHON_BIN" -m pip install --user -r "$REQUIREMENTS_FILE"
fi
echo "Vérification et installation des dépendances dans le venv..."
"$VENV_PIP" install --upgrade pip >/dev/null 2>&1 || true
"$VENV_PIP" install -r "$REQUIREMENTS_FILE"
fi
# 4. Sécurité anti-conflit : On vérifie si Docker n'a pas verrouillé le port USB de la LA66
PORT="/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"
if [ -e "$PORT" ]; then
echo "Vérification de la disponibilité du port série..."
# Si le port est occupé, on force sa libération en tuant le processus docker/screen persistant
if lsof "$PORT" >/dev/null 2>&1; then
echo "Le port série est occupé ! Libération en cours..."
fuser -k "$PORT" || true
@@ -44,7 +49,7 @@ if [ -e "$PORT" ]; then
fi
fi
# 5. Lancement propre du script Python
# 5. Lancement propre du script Python via le venv
echo "Launching Python script: $PYTHON_SCRIPT"
cd "$REPO_ROOT"
exec "$PYTHON_BIN" "$PYTHON_SCRIPT"
cd "$SCRIPT_DIR"
exec "$VENV_PYTHON" "$PYTHON_SCRIPT"
+61
View File
@@ -0,0 +1,61 @@
from datetime import datetime
import asyncio
import json
async def get_systemd_logs(lines: int = 200, service_name: str = "smartwave") -> list[dict]:
"""
Asynchronously fetches the last N log entries from a systemd service
using native asyncio subprocess execution.
"""
cmd = (
"journalctl",
"-u", service_name,
"-n", str(lines),
"-o", "json",
"--no-pager"
)
try:
# Create non-blocking child process
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
# Await completion and read output non-blockingly
stdout_bytes, stderr_bytes = await proc.communicate()
if proc.returncode != 0:
print(f"[Logs] journalctl error (code {proc.returncode}): {stderr_bytes.decode('utf-8', errors='replace')}")
return []
structured_logs = []
stdout_text = stdout_bytes.decode("utf-8", errors="replace")
for line in stdout_text.strip().split("\n"):
if not line:
continue
try:
entry = json.loads(line)
raw_ts = entry.get("__REALTIME_TIMESTAMP")
timestamp_iso = None
if raw_ts:
timestamp_iso = datetime.fromtimestamp(int(raw_ts) / 1_000_000).isoformat()
structured_logs.append({
"timestamp": timestamp_iso,
"timestamp_us": int(raw_ts) if raw_ts else None,
"message": entry.get("MESSAGE", ""),
"priority": entry.get("PRIORITY"),
"pid": entry.get("_PID"),
})
except (json.JSONDecodeError, ValueError):
continue
return structured_logs
except Exception as e:
print(f"[Logs] Failed to read systemd logs for '{service_name}': {e}")
return []
+583 -109
View File
@@ -1,19 +1,34 @@
import base64
import json
import time
import traceback
import asyncio
import requests
import os
from orchestrateur.sensors import gps
from shared.microwave_state import MicrowaveState, MicrowaveStateFields
from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads, db
from shared.logging import log
from shared.cookingState import CookingStates
from shared.lora_device import LoraCommands
from sensors import ultrasonicRanger, temp_hum, button, camera
from shared.alerts import AlertManager, Alert, AlertType
from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer, rgb_lcd, gps, rfid_reader
from lib.systemd_logs import get_systemd_logs
from external_display_manager import DisplayManager
from rgb_lcd_manager import RGBLCDManager
from web_server import start_technician_web_server
rgb_lcd.setRGB(255, 255, 255, brightness=0.8)
rgb_lcd.setText("SmartWave\nOrchestrateur")
# --- CONFIGURATION CONSTANTS ---
DISPLAY_DEBOUNCE_SECONDS = 3 # Seconds to wait after first change before broadcasting
TECHNICIAN_MODE = False
def is_technician_mode_active() -> bool:
return TECHNICIAN_MODE
# --- DB SETUP ---
DB_PATH = "orchestrateur/db.sqlite"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, "db.sqlite")
def init_db():
"""Ensures the connected_components table exists on startup."""
@@ -39,6 +54,18 @@ def save_connected_component(component_id: str, component_type: str):
"""
db.execute(DB_PATH, sql, (str(component_id), str(component_type), current_time))
print(f"[DB] Component saved/updated -> ID: {component_id}, Type: {component_type}, Timestamp: {current_time}")
def remove_connected_component(component_id: str):
"""Removes a component from the database (blocking sync worker)."""
sql = "DELETE FROM connected_components WHERE id = ?;"
db.execute(DB_PATH, sql, (str(component_id),))
print(f"[DB] Component removed -> ID: {component_id}")
def get_connected_components():
"""Returns a list of all connected components from the database."""
sql = "SELECT id, type, timestamp FROM connected_components;"
rows = db.fetchall(DB_PATH, sql)
return [{"id": row[0], "type": row[1], "timestamp": row[2]} for row in rows]
# --- Read Unique Device ID ---
def get_device_id():
@@ -50,31 +77,77 @@ def get_device_id():
pass
return "RPI_Orchestrateur_Default"
DEVICE_ID = get_device_id()
def get_api_key():
for path in ["device_api_key.txt", "/home/pi/SmartWave/orchestrateur/device_api_key.txt"]:
try:
with open(path, "r") as f:
return f.read().strip()
except Exception:
pass
return ""
# --- STATE MACHINE DEFINITIONS ---
class MicrowaveState:
IDLE = "IDLE" # Microwave is empty
ANALYZING = "ANALYZING" # Reading sensors & waiting for IR
WAITING_FOR_CLOUD = "WAITING_FOR_CLOUD" # Waiting for API parameters
COOKING = "COOKING" # Microwave is active
DONE = "DONE" # Finished/Stopped, waiting for dish removal
DEVICE_ID = get_device_id()
API_KEY = get_api_key()
# --- DISPLAY QUEUE & NOTIFICATION HELPERS ---
display_update_queue = None
def notify_display_update():
"""Safely adds an update event timestamp to the display update queue."""
if display_update_queue is not None:
try:
loop = asyncio.get_running_loop()
loop.call_soon_threadsafe(display_update_queue.put_nowait, time.time())
except RuntimeError:
pass
def on_microwave_change(state: MicrowaveState, field: MicrowaveStateFields):
if field != MicrowaveStateFields.STATE: # No external screen or lcd uses this field, so we can skip updates for it
notify_display_update()
if field == MicrowaveStateFields.STATE and state.state in (MicrowaveState.IDLE, MicrowaveState.DONE, MicrowaveState.ANALYZING, MicrowaveState.WAITING_FOR_CLOUD):
# Send to LoRa device if state changed
lora.send_reliable(payloads.lora_microwave_state(state.state), max_retries=5)
if field == MicrowaveStateFields.COOKING_STATE:
update_lcd_microwave_count()
if state.cooking_state in (CookingStates.DONE, CookingStates.STIRRING_REQUIRED, CookingStates.ALERT):
# Trigger buzzer for user attention
beats = 5 if state.cooking_state == CookingStates.ALERT else (1 if state.cooking_state == CookingStates.STIRRING_REQUIRED else 3)
buzzer.buzzer_siren(beats_nb=beats)
elif state.cooking_state == CookingStates.DONE and state.state == MicrowaveState.COOKING:
state.set_state(MicrowaveState.DONE)
print(f"[{state.microwave_id}] Cooking finished. Waiting for user to remove dish.")
def set_cloud_alert(state: bool):
"""Setter for cloud_alert that triggers a display update when changed."""
global cloud_alert
if cloud_alert != state:
cloud_alert = state
notify_display_update()
update_lcd_cloud_alert()
# Global state trackers
microwave_states = {"2": MicrowaveState.IDLE}
microwave_states = {"2": MicrowaveState("2", on_change_callback=on_microwave_change)}
button_state = False
cloud_alert = False # Global status flag for screen / UI display
cloud_alert = None # Global status flag for screen / UI display
async_event_queue = None
# Display Manager instance
display_manager = DisplayManager(endpoint_path="/api/display")
rgb_lcd_manager = RGBLCDManager(rgb_lcd, brightness=0.8, interval=5.0)
# Async synchronization trackers for MQTT IR sensors responses
ir_data_cache = {} # mw_id -> dict of IR readings
ir_data_events = {} # mw_id -> asyncio.Event()
# --- HARDWARE SETUP ---
# --- HARDWARE & MQTT SETUP ---
lora = get_lora()
lora.configure()
# Local MQTT Broker (ESP32 IR & Sensors)
mqtt_client = get_mqtt_client(
host="192.168.50.1",
client_id="smartwave-orchestrateur-" + DEVICE_ID,
@@ -86,11 +159,39 @@ mqtt_client.connect()
mqtt_client.subscribe(config.MQTT_TOPIC_SENSOR, qos=config.MQTT_QOS)
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, qos=config.MQTT_QOS)
# RFID reader
rfid = rfid_reader.RFIDReader(rx_pin=17)
# DHT sensor warmup
time.sleep(2) # Allow GrovePi MCU and DHT sensor circuits to settle post-service start
temp_hum.get_temperature_and_humidity() # Dummy read to flush initial NaN state
if hasattr(mqtt_client._client, "loop_start"):
mqtt_client._client.loop_start()
print("[MQTT] Paho background loop started.")
print("[MQTT Local] Paho background loop started.")
# --- BACKGROUND TASKS (PRODUCERS) ---
# Cloud MQTT Broker (Remote Commands)
cloud_mqtt_client = get_mqtt_client(
host="mqtt.matthiasg.dev",
client_id=DEVICE_ID,
use_tls=False,
username="microwave_device",
password="myMicrowaveVerySecret",
keepalive=config.MQTT_KEEPALIVE,
)
try:
cloud_mqtt_client.connect()
cloud_mqtt_client.subscribe("cmd/all", qos=config.CLOUD_MQTT_QOS)
cloud_mqtt_client.subscribe(f"cmd/{DEVICE_ID}", qos=config.CLOUD_MQTT_QOS)
except Exception as e:
print(f"[MQTT Cloud] Error connecting to cloud MQTT broker: {e}")
if hasattr(cloud_mqtt_client._client, "loop_start"):
cloud_mqtt_client._client.loop_start()
print("[MQTT Cloud] Paho background loop started.")
# --- BACKGROUND TASKS ---
async def lora_listener_task():
"""Polls LoRa and pushes to the async queue."""
print("[LoRa] Async listener started.")
@@ -102,8 +203,8 @@ async def lora_listener_task():
await asyncio.sleep(0.05)
async def mqtt_listener_task():
"""Polls MQTT cache and pushes to the async queue."""
print("[MQTT] Async listener started.")
"""Polls local MQTT cache and pushes to the async queue."""
print("[MQTT Local] Async listener started.")
while True:
message = mqtt_client.get_message()
if message:
@@ -124,20 +225,264 @@ async def mqtt_listener_task():
})
await asyncio.sleep(0.1)
async def cloud_mqtt_listener_task():
"""Polls Cloud MQTT cache and pushes commands to the async queue."""
print("[MQTT Cloud] Async listener started.")
while True:
message = cloud_mqtt_client.get_message()
if message:
try:
payload = json.loads(message['payload'])
except Exception:
payload = message['payload']
topic = message['topic']
if isinstance(topic, bytes):
topic = topic.decode('utf-8')
await async_event_queue.put({
"source": "CloudMQTT",
"topic": topic,
"data": payload
})
await asyncio.sleep(0.1)
async def rfid_listener_task():
"""Polls RFID reader, verifies cards via OData API, and toggles TECHNICIAN_MODE."""
global TECHNICIAN_MODE
print("[RFID] Async task started on GPIO 17.")
last_scanned_tag = None
last_scan_time = 0
while True:
try:
# Quick non-blocking read via pigpio IPC
tag_id = await asyncio.to_thread(rfid.read_tag)
current_time = time.time()
if tag_id and tag_id != "0000000000":
# Debounce same tag within 3 seconds to avoid spamming the API
if tag_id == last_scanned_tag and (current_time - last_scan_time) < 3.0:
await asyncio.sleep(0.1)
continue
last_scanned_tag = tag_id
last_scan_time = current_time
print(f"[RFID] Tag detected: {tag_id}. Verifying with API...")
is_valid = await check_rfid_card_api(tag_id)
if is_valid:
TECHNICIAN_MODE = True
print(f"[RFID] Card {tag_id} VALIDATED! -> TECHNICIAN_MODE = TRUE")
# Optional: Sound positive feedback beep
buzzer.buzzer_siren(beats_nb=2)
else:
print(f"[RFID] Card {tag_id} REJECTED / Invalid.")
# Optional: Sound error feedback beep
buzzer.buzzer_siren(beats_nb=4)
except Exception as e:
print(f"[RFID Task] Error reading RFID tag: {e}")
# Sleep 100ms between checks to keep CPU usage near zero
await asyncio.sleep(0.2)
async def display_broadcast_worker_task():
"""
Debounced display broadcast task.
Waits until DISPLAY_DEBOUNCE_SECONDS have passed since the first queued update
before sending state to all connected screens.
"""
global display_update_queue, microwave_states, cloud_alert
print("[Display Worker] Started debounced display broadcast worker.")
while True:
# Block until the FIRST update reminder arrives in the queue
first_event_time = await display_update_queue.get()
# Compute wait time relative to the first event's timestamp
elapsed = time.time() - first_event_time
remaining_wait = DISPLAY_DEBOUNCE_SECONDS - elapsed
if remaining_wait > 0:
await asyncio.sleep(remaining_wait)
# Clear any subsequent events that arrived during the wait period
while not display_update_queue.empty():
try:
display_update_queue.get_nowait()
except asyncio.QueueEmpty:
break
# Broadcast state ONCE
await display_manager.broadcast_state(
microwave_states=microwave_states,
cloud_alert=cloud_alert
)
async def process_messages_task():
"""Consumes the unified event queue."""
# Helper to normalize config topics to str
def to_str(val):
return val.decode('utf-8') if isinstance(val, bytes) else val
while True:
msg = await async_event_queue.get()
source = msg["source"]
data = msg["data"]
if source == "LoRa":
if "action" in data.get("data", {}):
if type(data["data"]) != dict:
print(f"[LoRa] Invalid data format received: {data['data']}")
continue
action = data["data"]["action"]
if action == LoraCommands.COOKING_UPDATE:
mw_id = data["data"].get("id")
if mw_id in microwave_states:
state_info = data["data"]
microwave_states[mw_id].set_cooking_state(state_info.get("cooking_state", microwave_states[mw_id].cooking_state))
microwave_states[mw_id].set_cooking_estimated_remaining_time(state_info.get("estimated_remaining_time", microwave_states[mw_id].cooking_estimated_remaining_time))
microwave_states[mw_id].set_paused(state_info.get("paused", microwave_states[mw_id].paused))
elif action == LoraCommands.NEW_ALERT:
alert_type = data["data"].get("alert_type")
alert_message = data["data"].get("alert_message")
alert = Alert(alert_type, alert_message, redistributed=True)
alert_manager.add_alert(alert)
print(f"[LoRa] New alert received: {alert.to_dict()}")
else:
print(f"[LoRa] Unhandled action received: {action} with data: {data['data']}")
elif source == "MQTT":
topic = msg["topic"]
hello_topic = to_str(config.MQTT_TOPIC_HELLO)
sensor_topic = to_str(config.MQTT_TOPIC_SENSOR)
if topic == hello_topic:
if data.get("id_orchestrator") != DEVICE_ID:
component_id = data.get("id_microwave")
component_type = data.get("type", deviceTypes.DEVICE_TYPES["MICROWAVE"])
if component_id:
print(f"[MQTT] Hello received from '{component_id}' ({component_type}). Updating DB & sending ACK.")
# Offload DB insertion to async thread execution pool
await asyncio.to_thread(save_connected_component, component_id, component_type)
# Pass change callback to newly connected microwave
microwave_states[component_id] = MicrowaveState(
component_id,
on_change_callback=on_microwave_change
)
notify_display_update() # Update the external screens
update_lcd_microwave_count() # Update the LCD with new microwave count
mqtt_client.publish(
config.MQTT_TOPIC_HELLO,
payloads.mqtt_hello_ack(DEVICE_ID, component_id),
qos=config.MQTT_QOS
)
elif topic == sensor_topic:
mw_id = str(data.get("id_microwave"))
print(f"[MQTT] Sensor data received for microwave {mw_id}: {data}")
# Store IR data and notify the waiting dish handler
ir_data_cache[mw_id] = data
if mw_id in ir_data_events:
ir_data_events[mw_id].set()
elif source == "CloudMQTT":
topic = msg["topic"]
print(f"[CloudMQTT] Command received on '{topic}': {data}")
action = data.get("action")
if action == "request_telemetry":
endpoint = data.get("endpoint")
if endpoint:
# Schedule as a background task so processing the queue isn't stalled
do_timeout = topic == "cmd/all"
asyncio.create_task(handle_telemetry_request(endpoint, do_timeout=do_timeout))
else:
print("[CloudMQTT] Received 'request_telemetry' but missing 'endpoint' field.")
else:
print(f"[CloudMQTT] Unknown action: {action}")
# === LCD DISPLAY ===
def on_screens_change(screens, is_removed: bool, modified_name: str):
"""Callback for when screens are added or removed."""
log(f"[DisplayManager] Screens changed. Current screens: {list(screens.keys())}")
rgb_lcd_manager.set_external_screen_count(len(screens))
# Add Or remove from database of connected components
# 1. Extract id from name (assuming format "smartwave-epaper-<id>._displaytcp._tcp.local")
display_id = modified_name.split("-")[-1].split(".")[0]
if is_removed:
remove_connected_component(display_id)
else:
save_connected_component(display_id, "external_display")
display_manager.set_on_screens_change_callback(on_screens_change)
def update_lcd_microwave_count():
"""Updates the LCD with the current number of connected microwaves."""
global microwave_states
count = len(microwave_states)
rgb_lcd_manager.set_microwave_count(count)
def update_lcd_cloud_alert():
"""Updates the LCD with the current cloud connectivity status."""
global cloud_alert
rgb_lcd_manager.set_cloud_alert(cloud_alert)
# --- Button ---
def button_callback():
"""Button physical interrupt callback."""
global button_state
if microwave_states.get("2") == MicrowaveState.COOKING or microwave_states.get("2") == MicrowaveState.DONE:
if microwave_states.get("2").cooking_state in (CookingStates.COOKING, CookingStates.DONE, CookingStates.STIRRING_REQUIRED, CookingStates.ALERT):
print("[Button] Toggling pause/resume for microwave '2'.")
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE})
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_PAUSE}, max_retries=6)
else:
button_state = not button_state
print(f"[Button] Defrost state toggled to: {button_state}")
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_DEFROST, "defrost_state": button_state})
lora.send_reliable({"id": DEVICE_ID, "microwave_id": "2", "action": LoraCommands.TOGGLE_DEFROST, "defrost_state": button_state}, max_retries=5)
button.set_callback(button_callback)
button.start_button_monitoring_thread()
# --- Alert manager ---
alert_manager = None
async def on_new_alert(alert: Alert):
# Sends to cloud server
alert_dict = alert.to_dict()
logs_list = await get_systemd_logs(lines=200, service_name="smartwave")
payload = {
"orchestrator_id": DEVICE_ID,
"alert": alert_dict,
"logs": logs_list
}
response = requests.post(
"https://smartwave.matthiasg.dev/alert",
headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
json=payload,
)
response.raise_for_status()
res_json = response.json()
log(f"[Alert Handling] Alert sent to cloud: {alert_dict}, Response: {res_json}")
if not alert.redistributed: # If not redistributed from microwaves
# Send the alert to the lora device
payload = payloads.lora_new_alert(alert)
print(f"[Alert Handling] Sending alert to LoRa device: {payload}")
if lora.send_reliable(payload, max_retries=7):
microwave_states["2"].set_cooking_state(CookingStates.ALERT)
# --- HARDWARE CONTROLLERS ---
def _stop_hardware(microwave_id: str):
print(f"[{microwave_id}] /!\\ Emergency stop issued to hardware.")
@@ -159,6 +504,16 @@ def read_local_sensors(microwave_id, initial_dish_height):
if temp is not None:
sensor_data["temperature"] = temp
sensor_data["humidity"] = hum
if config.DEBUG_TEMPERATURE_ALERT or temp > config.MAX_ALLOWED_TEMPERATURE_TECHNICAL_COMPARTMENT:
print(f"[{microwave_id}] ALERT: Unsafe temperature in technical compartment detected! Temp: {temp}°C, Humidity: {hum}%")
alert = Alert(
AlertType.TEMPERATURE_ALERT,
f"Unsafe temperature in technical compartment detected! Temp: {temp}/{config.MAX_ALLOWED_TEMPERATURE_COOKING_COMPARTMENT}°C, Humidity: {hum}%",
redistributed=False
)
# lora.send_reliable(payloads.lora_new_alert(alert), max_retries=5)
alert_manager.add_alert(alert)
return None # Abort further processing if unsafe temperature detected
except Exception as e:
log(f"[{microwave_id}] DHT read warning: {e}")
@@ -173,7 +528,7 @@ def read_local_sensors(microwave_id, initial_dish_height):
async def handle_new_dish(microwave_id, detected_height):
"""Triggered when a new dish is placed inside."""
microwave_states[microwave_id] = MicrowaveState.ANALYZING
microwave_states[microwave_id].set_state(MicrowaveState.ANALYZING)
print(f"\n[{microwave_id}] 🍽️ Dish detected at {detected_height:.1f} cm! Requesting IR from microwave...")
# 1. Setup synchronization event and clear previous cache for this microwave
@@ -193,9 +548,14 @@ async def handle_new_dish(microwave_id, detected_height):
# 4. Wait for local sensors to finish reading
sensors_data = await sensor_task
if sensors_data is None:
print(f"[{microwave_id}] Aborting cooking plan because of empty sensor data")
microwave_states[microwave_id].set_state(MicrowaveState.ALERT)
return
# Check if dish was removed while reading sensors
if microwave_states.get(microwave_id) != MicrowaveState.ANALYZING:
if microwave_states.get(microwave_id).state != MicrowaveState.ANALYZING:
print(f"[{microwave_id}] Dish removed during sensor read. Aborting.")
ir_data_events.pop(microwave_id, None)
return
@@ -210,16 +570,56 @@ async def handle_new_dish(microwave_id, detected_height):
# 6. Dispatch cloud request task
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
except asyncio.TimeoutError:
print(f"[{microwave_id}] ⚠️ Timeout waiting for MQTT IR data from ESP32.")
print(f"[{microwave_id}] Timeout waiting for MQTT IR data from ESP32.")
sensors_data["ir_initial_temp"] = None
sensors_data["ir_ambient_temp"] = None
finally:
ir_data_events.pop(microwave_id, None)
# === API CALLS ===
async def check_rfid_card_api(card_id: str) -> bool:
"""Queries the OData API asynchronously to verify if card_id is valid."""
url = "https://smartwave.matthiasg.dev/rfid-card"
params = {
"$filter": f"card_id eq '{card_id}'"
}
headers = {"Content-Type": "application/json"}
if API_KEY:
headers["X-API-Key"] = API_KEY
try:
response = await asyncio.to_thread(
requests.get,
url,
params=params,
headers=headers,
timeout=5
)
if response.status_code == 200:
set_cloud_alert(False) # Clear cloud alert on success
data = response.json()
if isinstance(data, dict):
items = data.get("value", data.get("data", []))
return len(items) > 0 if isinstance(items, list) else bool(data)
elif isinstance(data, list):
return len(data) > 0
else:
print(f"[RFID Task] OData API returned status {response.status_code}: {response.json()}")
set_cloud_alert(True) # Set cloud alert on API connection failure
return False
except Exception as e:
print(f"[RFID Task] OData API check error: {e}")
set_cloud_alert(True) # Set cloud alert on API connection failure
return False
async def request_cloud_cooking_plan(microwave_id, sensors_data):
"""Sends all data to the cloud with up to 3 retries (30s interval)."""
global cloud_alert
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
"""Sends all data to the cloud with up to 3 retries (330s timeout for AI generation)."""
global cloud_alert, alert_manager
microwave_states[microwave_id].set_state(MicrowaveState.WAITING_FOR_CLOUD)
URL = "https://smartwave.matthiasg.dev/cooking-params"
# Format image
@@ -229,114 +629,159 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
print(f"[{microwave_id}] Requesting cooking plan from cloud app...")
max_retries = 3
retry_delay_seconds = 30
retry_delay_seconds = 10 # Reduced delay since each request already waits up to 5 mins
# Tuple format: (connect_timeout_seconds, read_timeout_seconds)
HTTP_TIMEOUT = (10, 330)
for attempt in range(1, max_retries + 1):
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
if microwave_states.get(microwave_id).state != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] Dish removed or state changed. Aborting API request.")
microwave_states[microwave_id].set_state(MicrowaveState.IDLE)
return
try:
print(f"[{microwave_id}] Connection attempt {attempt}/{max_retries}...")
response = await asyncio.to_thread(requests.post, URL, json=sensors_data, timeout=30)
# Abort if state changed (e.g. user removed dish while waiting for wifi)
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] Connection attempt {attempt}/{max_retries} (Waiting up to 5.5 min)...")
response = await asyncio.to_thread(
requests.post,
URL,
json=sensors_data,
headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
timeout=HTTP_TIMEOUT
)
# Abort if state changed while waiting for cloud AI response
if microwave_states.get(microwave_id).state != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
microwave_states[microwave_id].set_state(MicrowaveState.IDLE)
return
response.raise_for_status()
plan = response.json().get("cook_plan", {})
set_cloud_alert(False) # Clear any previous cloud alert
# JSON extraction
res_json = response.json()
# Error handling
if "error" in res_json:
print(f"[{microwave_id}] Cloud API returned error: {res_json['error']}")
microwave_states[microwave_id].set_state(MicrowaveState.DONE)
if not res_json['is_safe']: # The cooking area is not safe, raise an alert
alert = Alert(AlertType.COOKING_SAFETY, res_json['warning_message'], redistributed=False)
alert_manager.add_alert(alert)
else:
alert = Alert(AlertType.ERROR, f"Cloud API error for microwave {microwave_id}: {res_json['error']}")
return
plan = res_json.get("plan", {}).get("cook_plan", res_json)
c_time = plan.get("cook_time_seconds")
c_power = plan.get("effective_power_watts")
c_temp = plan.get("target_temp")
if c_time is None or c_power is None or c_temp is None:
print(f"[{microwave_id}] ❌ Invalid plan received: {response.json()}")
microwave_states[microwave_id] = MicrowaveState.DONE
print(f"[{microwave_id}] Invalid plan received: {res_json}")
microwave_states[microwave_id].set_state(MicrowaveState.DONE)
return
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
cloud_alert = False # Reset alert flag on success
microwave_states[microwave_id] = MicrowaveState.COOKING
if config.DEBUG:
microwave_states[microwave_id].set_state(MicrowaveState.COOKING)
if config.DEBUG_SHORTER_COOKING_PLAN:
c_time = 20 # Set to 20s for debug
c_temp = 50 # Set to 50°C for debug
mqtt_client.publish(
config.MQTT_TOPIC_COOKING,
payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
qos=config.MQTT_QOS
)
microwave_states[microwave_id].set_cooking_start_time(time.time())
return
except Exception as e:
# Print stacktrace
import traceback
traceback.print_exc()
print(f"[{microwave_id}] Cloud API Error (Attempt {attempt}/{max_retries}): {e}")
if attempt < max_retries:
print(f"[{microwave_id}] Retrying in {retry_delay_seconds} seconds...")
# Interruptible wait loop in case user removes the dish mid-wait
# Interruptible wait loop in case state changes mid-wait
for _ in range(retry_delay_seconds):
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD:
if microwave_states.get(microwave_id).state != MicrowaveState.WAITING_FOR_CLOUD:
print(f"[{microwave_id}] State changed during retry wait. Aborting retries.")
return
await asyncio.sleep(1)
# Executed only if all 3 retries failed
print(f"[{microwave_id}] All 3 cloud retries failed. Setting global alert flag.")
cloud_alert = True
microwave_states[microwave_id] = MicrowaveState.DONE
print(f"[{microwave_id}] All cloud retries failed. Setting global alert flag.")
set_cloud_alert(True)
microwave_states[microwave_id].set_state(MicrowaveState.DONE)
# --- MAIN LOGIC TASKS ---
async def process_messages_task():
"""Consumes the unified event queue."""
while True:
msg = await async_event_queue.get()
source = msg["source"]
data = msg["data"]
if source == "LoRa":
if "new_cooking_state" in data.get("data", {}):
mw_id = data["data"].get("id")
n_state = data["data"].get("new_cooking_state")
print(f"[LoRa] Microwave {mw_id} state changed to: {n_state}")
if n_state == CookingStates.IDLE and microwave_states.get(mw_id) == MicrowaveState.COOKING:
microwave_states[mw_id] = MicrowaveState.DONE
print(f"[{mw_id}] Cooking finished. Waiting for user to remove dish.")
elif source == "MQTT":
topic = msg["topic"]
# Helper to normalize config topics to str
def to_str(val):
return val.decode('utf-8') if isinstance(val, bytes) else val
hello_topic = to_str(config.MQTT_TOPIC_HELLO)
sensor_topic = to_str(config.MQTT_TOPIC_SENSOR)
if topic == hello_topic:
if data.get("id_orchestrator") != DEVICE_ID:
component_id = data.get("id_microwave")
component_type = data.get("type", deviceTypes.DEVICE_TYPES["MICROWAVE"])
if component_id:
print(f"[MQTT] Hello received from '{component_id}' ({component_type}). Updating DB & sending ACK.")
# Offload DB insertion to async thread execution pool
await asyncio.to_thread(save_connected_component, component_id, component_type)
mqtt_client.publish(
config.MQTT_TOPIC_HELLO,
payloads.mqtt_hello_ack(DEVICE_ID, component_id),
qos=config.MQTT_QOS
)
elif topic == sensor_topic:
mw_id = str(data.get("id_microwave"))
print(f"[MQTT] Sensor data received for microwave {mw_id}: {data}")
async def handle_telemetry_request(endpoint: str, do_timeout=True):
"""
Task to gather telemetry data (including structured systemd logs)
and post it to the target HTTP endpoint.
"""
print(f"[Telemetry] Starting telemetry collection for endpoint: {endpoint}")
try:
# 1. Fetch systemd logs asynchronously
logs_list = await get_systemd_logs(lines=200, service_name="smartwave")
# 2. Unpack temperature and humidity
ambient_temp, ambient_humidity = temp_hum.get_temperature_and_humidity_with_retry()
# Unpack microwave states
microwave_states_snapshot = {mw_id: mw_state.to_dict() for mw_id, mw_state in microwave_states.items()}
# 3. Build the telemetry payload (returns a JSON string)
telemetry_payload = payloads.telemetry_payload(
device_id=DEVICE_ID,
microwave_states=microwave_states_snapshot,
button_state=button_state,
cloud_alert=cloud_alert,
gps_data=gps.get_gps_data(),
ambient_temp=ambient_temp,
ambient_humidity=ambient_humidity,
connected_components=get_connected_components(),
logs=logs_list
)
# Store IR data and notify the waiting dish handler
ir_data_cache[mw_id] = data
if mw_id in ir_data_events:
ir_data_events[mw_id].set()
# 4. Wait for he microwave turn to send the telemetry data to the cloud endpoint
if do_timeout:
do_timeout = int(DEVICE_ID.split("_")[-1]) * config.TELEMETRY_SEND_INTERVAL
print(f"[Telemetry] Waiting for {do_timeout}s before sending telemetry to avoid collisions...")
await asyncio.sleep(do_timeout)
# 5. Offload blocking HTTP POST to thread pool
print(f"[Telemetry] Sending payload with {len(logs_list)} log entries...")
response = await asyncio.to_thread(
requests.post,
endpoint,
data=telemetry_payload, # <-- Changed from json=telemetry_payload
headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
timeout=15
)
response.raise_for_status()
set_cloud_alert(False) # Clear any previous cloud alert on success
print(f"[Telemetry] Successfully dispatched telemetry (HTTP {response.status_code}).")
except requests.exceptions.RequestException as req_err:
print(f"[Telemetry] HTTP Request failed to {endpoint}: {req_err}")
set_cloud_alert(True) # Set cloud alert on HTTP failure
except Exception as e:
print(f"[Telemetry] Error handling telemetry request: {e}")
async def get_filtered_dish_height(samples=3, delay=0.04):
"""Reads ultrasonic sensor multiple times and returns the median, discarding invalid zeros."""
@@ -364,20 +809,23 @@ async def monitor_dish_height_task():
while True:
dist = await get_filtered_dish_height()
current_state = microwave_states.get(mw_id, MicrowaveState.IDLE)
try:
current_state = microwave_states.get(mw_id).state
except AttributeError:
current_state = MicrowaveState.IDLE
if dist is not None:
# Hysteresis Thresholds:
# - Must be > 2.5 cm to detect dish insertion
# - Must be < 1.2 cm to detect dish removal
if dist > 2.5:
# - Must be > 1.5 cm to detect dish insertion
# - Must be < 0.8 cm to detect dish removal
if dist > 2.0:
consecutive_present += 1
consecutive_absent = 0
elif dist < 1.2:
elif dist < 1.0:
consecutive_absent += 1
consecutive_present = 0
else:
# Dead-zone (1.2cm to 2.5cm) -> Noise buffer
# Dead-zone (2.0cm to 1.0cm) -> Noise buffer
consecutive_present = 0
consecutive_absent = 0
@@ -390,8 +838,8 @@ async def monitor_dish_height_task():
elif consecutive_absent >= REQUIRED_STABLE_READS and current_state != MicrowaveState.IDLE:
consecutive_absent = 0
print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.")
microwave_states[mw_id] = MicrowaveState.IDLE
cloud_alert = False # Reset error alert on dish removal
microwave_states[mw_id].set_state(MicrowaveState.IDLE)
set_cloud_alert(False)
if current_state == MicrowaveState.COOKING:
_stop_hardware(mw_id)
@@ -399,23 +847,44 @@ async def monitor_dish_height_task():
ir_data_cache.pop(mw_id, None)
ir_data_events.pop(mw_id, None)
await asyncio.sleep(0.3)
await asyncio.sleep(0.7)
# --- BOOTSTRAP ---
async def main():
global async_event_queue
print("🚀 Orchestrateur Asyncio prêt. Lancement des tâches...")
global async_event_queue, display_update_queue
print("Orchestrateur prêt. Lancement des tâches...")
# Initialize the alert manager and set the callback
global alert_manager
alert_manager = AlertManager()
alert_manager.set_on_alert_callback(on_new_alert)
# Initialize SQLite database table
init_db()
# Start mDNS Display discovery
await display_manager.start()
save_connected_component(DEVICE_ID, "orchestrateur")
async_event_queue = asyncio.Queue()
display_update_queue = asyncio.Queue()
await asyncio.gather(
lora_listener_task(),
mqtt_listener_task(),
cloud_mqtt_listener_task(),
process_messages_task(),
monitor_dish_height_task()
monitor_dish_height_task(),
display_broadcast_worker_task(),
rfid_listener_task(),
start_technician_web_server(
get_logs_cb=get_systemd_logs,
telemetry_cb=handle_telemetry_request,
is_tech_mode_cb=is_technician_mode_active,
host=config.TECHNICIAN_WEB_SERVER_HOST,
port=config.TECHNICIAN_WEB_SERVER_PORT
)
)
if __name__ == "__main__":
@@ -424,6 +893,11 @@ if __name__ == "__main__":
except KeyboardInterrupt:
print("\nArrêt manuel.")
finally:
rfid.close() # <-- ADDED CLEANUP HERE
asyncio.run(display_manager.stop())
if hasattr(mqtt_client._client, "loop_stop"):
mqtt_client._client.loop_stop()
mqtt_client.close()
if hasattr(cloud_mqtt_client._client, "loop_stop"):
cloud_mqtt_client._client.loop_stop()
mqtt_client.close()
cloud_mqtt_client.close()
+7 -1
View File
@@ -9,4 +9,10 @@ log_type information
allow_anonymous true
listener 8884
protocol mqtt
protocol mqtt
# Unencrypted listener for local Thread nodes
listener 1883
protocol mqtt
socket_domain ipv6
allow_anonymous true
+8 -1
View File
@@ -14,4 +14,11 @@ cafile /mosquitto/certs/ca.crt
certfile /mosquitto/certs/server.crt
keyfile /mosquitto/certs/server.key
require_certificate false
tls_version tlsv1.2
tls_version tlsv1.2
# Unencrypted listener for local Thread nodes
# Thread is already encrypted, so we don't need to use TLS for this listener
listener 1883 ::
protocol mqtt
socket_domain ipv6
allow_anonymous true
+11 -1
View File
@@ -1,6 +1,16 @@
paho-mqtt>=1.6,<3
pyserial>=3.5,<4
zeroconf>=0.131.0
fastapi>=0.100.0,<1.0.0
uvicorn>=0.20.0,<1.0.0
python-multipart>=0.0.6,<1.0.0
requests==2.34.2
numpy>=1.20.0
# picamera2>=0.3.36,<4 # → Installed with apt install python3-picamera2
# OpenCV
# sudo apt install -y python3-opencv
# sudo apt install -y opencv-data
# sudo apt install -y opencv-data
# sudo apt install -y swig python3-dev liblgpio-dev
# ... Replaces this ?
# sudo apt install pigpio python3-pigpio
# sudo systemctl enable pigpiod --now
+138
View File
@@ -0,0 +1,138 @@
"""
Manages the RGB LCD display with the current state of the microwaves and cloud connectivity.
The display switches content every x seconds between microwaves, external screens,
and cloud connectivity state using a background thread.
"""
import threading
import time
SCREEN_SWITCH_INTERVAL = 5 # seconds
DEFAULT_BRIGHTNESS = 0.3 # Constant brightness (range 0.0 to 1.0)
class RGBLCDManager:
def __init__(self, rgb_lcd, interval=SCREEN_SWITCH_INTERVAL, brightness=DEFAULT_BRIGHTNESS, auto_start=True):
self.rgb_lcd = rgb_lcd
self.interval = interval
self.brightness = max(0.0, min(1.0, float(brightness)))
# Internal state store
self._microwave_count = "N/A"
self._external_screen_count = "N/A"
self._cloud_alert = None
# Screen rotation sequence
self.screens = [
self.display_microwave_state,
self.display_external_screens,
self.display_cloud_connectivity,
]
self.current_screen_idx = 0
# Threading controls
self._thread = None
self._running = False
self._stop_event = threading.Event()
if auto_start:
self.start()
# --- SETTER METHODS ---
def set_brightness(self, brightness):
"""Dynamically adjust backlight brightness (0.0 to 1.0)."""
self.brightness = max(0.0, min(1.0, float(brightness)))
def set_microwave_count(self, count):
"""Set the number of connected microwaves."""
self._microwave_count = count
def set_external_screen_count(self, count):
"""Set the number of connected external screens."""
self._external_screen_count = count
def set_cloud_alert(self, status: bool):
"""Set cloud connectivity status."""
self._cloud_alert = status
def set_data(self, microwave_count=None, external_screen_count=None, cloud_alert=None):
"""Convenience method to update all state variables at once."""
if microwave_count is not None:
self.set_microwave_count(microwave_count)
if external_screen_count is not None:
self.set_external_screen_count(external_screen_count)
if cloud_alert is not None:
self.set_cloud_alert(cloud_alert)
# --- DISPLAY RENDERERS ---
def display_microwave_state(self):
"""Display the number of connected microwaves."""
text = f"Microwaves:\nConnected: {self._microwave_count}"
self.rgb_lcd.setText(text)
self.rgb_lcd.setRGB(255, 255, 255, brightness=self.brightness)
def display_external_screens(self):
"""Display the number of connected external screens."""
text = f"Screens:\nConnected: {self._external_screen_count}"
self.rgb_lcd.setText(text)
self.rgb_lcd.setRGB(255, 255, 255, brightness=self.brightness)
def display_cloud_connectivity(self):
"""Display cloud connectivity status with brightness-scaled backlight color."""
status_str = None
# Base RGB colors
if self._cloud_alert is True:
rgb = (255, 0, 0) # Red
status_str = "Disconnected"
elif self._cloud_alert is False:
rgb = (0, 255, 0) # Green
status_str = "Connected"
else:
rgb = (255, 165, 0) # Orange
status_str = "N/A"
self.rgb_lcd.setRGB(rgb[0], rgb[1], rgb[2], brightness=self.brightness)
self.rgb_lcd.setText(f"Cloud Status:\n{status_str}")
# --- BACKGROUND THREAD LOGIC ---
def _display_loop(self):
"""Background loop that switches screens at regular intervals."""
while not self._stop_event.is_set():
try:
self.screens[self.current_screen_idx]()
self.current_screen_idx = (self.current_screen_idx + 1) % len(self.screens)
except Exception as e:
print(f"[RGBLCDManager] Error updating LCD: {e}")
sleep_ticks = int(self.interval * 10)
for _ in range(sleep_ticks):
if self._stop_event.is_set():
break
time.sleep(0.1)
def start(self):
"""Start the display background thread."""
if not self._running:
self._running = True
self._stop_event.clear()
self._thread = threading.Thread(target=self._display_loop, daemon=True)
self._thread.start()
def stop(self):
"""Stop the display loop and turn off the LCD backlight."""
if self._running:
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=2.0)
self._running = False
try:
self.rgb_lcd.setText("Bye !")
self.rgb_lcd.setRGB(0, 0, 0)
except Exception as e:
print(f"[RGBLCDManager] Error clearing LCD: {e}")
+4 -1
View File
@@ -4,4 +4,7 @@ import sensors.ultrasonicRanger as ultrasonicRanger
import sensors.temp_hum as temp_hum
import sensors.button as button
import sensors.gps as gps
import sensors.camera as camera
import sensors.camera as camera
import sensors.buzzer as buzzer
import sensors.rgb_lcd as rgb_lcd
import sensors.rfid_reader as rfid_reader
+42 -21
View File
@@ -1,43 +1,64 @@
import grovepi
import time
import threading
from sensors.lock import grove_lock
from sensors.lock import safe_grove_access
from shared.logging import log
button = 2
button_switch_state = 0
grovepi.pinMode(button, "INPUT")
button_callback = None
def read_button_state():
# Increase timeout slightly so the button thread can wait for long I2C sensor reads to finish
if not grove_lock.acquire(timeout=0.2):
return None
try:
return grovepi.digitalRead(button)
except Exception as e:
log(f"BTN Error: {e}")
return None
finally:
grove_lock.release()
# Attempt to acquire the lock with a 0.2s timeout
with safe_grove_access(timeout=0.2) as acquired:
if not acquired:
return None
try:
val = grovepi.digitalRead(button)
# Force strict binary output (0 or 1)
return 1 if val == 1 else 0
except Exception as e:
log(f"BTN Error: {e}")
return None
def monitor_button():
global button_switch_state
last_button_state = button_switch_state
last_stable_state = 0
candidate_state = 0
consecutive_count = 0
REQUIRED_CONSECUTIVE_READS = 3
DEBOUNCE_COOLDOWN = 0.3
last_trigger_time = 0
while True:
current_state = read_button_state()
if current_state is not None:
# Rising edge detection (0 -> 1 transition)
if current_state == 1 and last_button_state == 0:
if button_callback:
button_callback()
last_button_state = current_state
time.sleep(0.02) # Fast 20ms poll when lock is clear
if current_state == candidate_state:
consecutive_count += 1
else:
candidate_state = current_state
consecutive_count = 1
if consecutive_count >= REQUIRED_CONSECUTIVE_READS:
now = time.time()
if candidate_state == 1 and last_stable_state == 0:
if (now - last_trigger_time) > DEBOUNCE_COOLDOWN:
last_trigger_time = now
if button_callback:
try:
button_callback()
except Exception as e:
log(f"[Button] Callback exception: {e}")
last_stable_state = candidate_state
time.sleep(0.02) # 20ms poll interval
else:
# Lock was busy; retry quickly without updating last_button_state
consecutive_count = 0
time.sleep(0.01)
def start_button_monitoring_thread():
+54
View File
@@ -0,0 +1,54 @@
import time
import threading
import grovepi
from sensors.lock import safe_grove_access
from shared import config
BUZZER_PIN = 8 # Must be a PWM pin on GrovePi (D3, D5, D6, D8 support analogWrite)
# Initialize pin mode safely
try:
with safe_grove_access(timeout=1.0) as acquired:
if acquired:
grovepi.pinMode(BUZZER_PIN, "OUTPUT")
except Exception as e:
print(f"[BUZZER] Init error: {e}")
def _stop_sound():
"""Helper to ensure sound is silenced safely under lock."""
with safe_grove_access(timeout=1.0) as acquired:
if acquired:
try:
grovepi.analogWrite(BUZZER_PIN, 0)
grovepi.digitalWrite(BUZZER_PIN, 0)
except Exception:
pass
def _siren_worker(beats_nb: int, delay: float):
if not config.BUZZER_ACTIVATED:
return
try:
for _ in range(beats_nb):
# 1. Turn sound ON (only acquire lock briefly for the I2C write)
with safe_grove_access(timeout=1.0) as acquired:
if acquired:
grovepi.analogWrite(BUZZER_PIN, 128)
time.sleep(delay) # Sleep WITHOUT holding the lock!
# 2. Turn sound OFF
with safe_grove_access(timeout=1.0) as acquired:
if acquired:
grovepi.analogWrite(BUZZER_PIN, 0)
time.sleep(delay) # Sleep WITHOUT holding the lock!
except Exception as e:
print(f"[BUZZER] Error during siren execution: {e}")
finally:
_stop_sound()
def buzzer_siren(beats_nb: int = 3, delay: float = 0.2, async_run: bool = True):
if async_run:
thread = threading.Thread(target=_siren_worker, args=(beats_nb, delay), daemon=True)
thread.start()
else:
_siren_worker(beats_nb, delay)
+17 -4
View File
@@ -1,12 +1,25 @@
import grovepi
import math
from sensors.lock import grove_lock
from picamera2 import Picamera2, Preview
import time
picam2 = Picamera2()
camera_config = picam2.create_still_configuration()
# Get maximum full-sensor dimensions
sensor_format = picam2.sensor_modes[0]
max_width = sensor_format['size'][0]
max_height = sensor_format['size'][1]
camera_config = picam2.create_still_configuration(
raw={"size": (1640, 1232)},
main={"size": (1640, 1232)}
)
picam2.set_controls({
"AeEnable": True,
"AwbEnable": True,
"ExposureTime": 100000,
"ExposureValue": 0.3,
"Brightness": 0.30,
"Contrast": 1.1
})
picam2.configure(camera_config)
picam2.start()
+14 -13
View File
@@ -2,7 +2,7 @@ import serial
import time
import threading
from shared.logging import log
from sensors.lock import serial_lock
from sensors.lock import safe_serial_access
def calculate_nmea_checksum(line: str) -> bool:
"""Validates standard NMEA 0183 sentence checksum ($...*HH)."""
@@ -34,25 +34,27 @@ class GROVEGPS:
self.longitude = -1.0
def read(self):
"""Reads the latest GGA sentence from serial, thread-safely."""
with serial_lock:
# 1. Flush accumulated stale data in the UART buffer
if self.ser.in_waiting > 0:
self.ser.reset_input_buffer()
"""Reads the freshest GGA sentence from serial, thread-safely."""
# Allow sufficient time for the serial reads (readline can block up to timeout=1s per line)
with safe_serial_access(timeout=2.0) as acquired:
if not acquired:
log("GPS: Serial lock acquisition timed out")
return False
# 2. Try reading up to 15 lines to catch the freshest GGA sentence
for _ in range(5):
# 1. Flush accumulated stale buffer data
self.ser.reset_input_buffer()
# 2. Read through the fresh incoming stream to find a complete GGA line
for _ in range(15):
raw_bytes = self.ser.readline()
try:
line = raw_bytes.decode('utf-8', errors='ignore').strip()
# log(f"GPS: Read line: {line}")
log(f"GPS: Read line: {line}")
except Exception:
continue
# Supports both $GPGGA and modern $GNGGA sentences
if (line.startswith('$GPGGA') or line.startswith('$GNGGA')) and calculate_nmea_checksum(line):
if self.parse_gga(line):
return True
return self.parse_gga(line)
return False
def parse_gga(self, line):
@@ -118,5 +120,4 @@ def get_gps_data():
else:
log(f"GPS: No valid fix or data available. Satellites: {gps.satellites}, Quality: {gps.quality}")
# Return None so main.py doesn't process or log empty GPS data
return None
+27 -1
View File
@@ -1,5 +1,31 @@
import threading
from contextlib import contextmanager
# Dedicated lock for I2C bus access (used by GrovePi sensors)
grove_lock = threading.Lock()
# Dedicated lock for UART/Serial port access
serial_lock = threading.Lock()
serial_lock = threading.Lock()
@contextmanager
def safe_grove_access(timeout=1.0):
"""
Safely acquire the Grove I2C lock with a timeout.
Yields True if lock acquired, False otherwise.
Guarantees lock release only when successfully acquired.
"""
acquired = grove_lock.acquire(timeout=timeout)
try:
yield acquired
finally:
if acquired:
grove_lock.release()
@contextmanager
def safe_serial_access(timeout=1.0):
"""Safely acquire the UART/Serial lock with a timeout."""
acquired = serial_lock.acquire(timeout=timeout)
try:
yield acquired
finally:
if acquired:
serial_lock.release()
+101
View File
@@ -0,0 +1,101 @@
import time
import pigpio
from typing import Optional
from sensors.lock import safe_serial_access
class RFIDReader:
"""
Grove 125kHz RFID Reader using bit-banged software serial on GPIO 17 (Pin 11).
Parses 14-byte frame: [0x02 STX] + [10 ASCII ID] + [2 ASCII Checksum] + [0x03 ETX]
"""
def __init__(self, rx_pin: int = 17, baudrate: int = 9600):
self.rx_pin = rx_pin
self.baudrate = baudrate
self.buffer = bytearray()
self.pi = pigpio.pi()
if not self.pi.connected:
raise RuntimeError("pigpio daemon is not running. Run 'sudo systemctl start pigpiod'.")
with safe_serial_access(timeout=1.0) as acquired:
if not acquired:
raise RuntimeError("RFID: Could not acquire serial lock during initialization")
self.pi.set_mode(self.rx_pin, pigpio.INPUT)
# Clean up lingering serial sessions on this pin
try:
self.pi.bb_serial_read_close(self.rx_pin)
except pigpio.error:
pass
self.pi.bb_serial_read_open(self.rx_pin, self.baudrate, 8)
def read_tag(self) -> Optional[str]:
"""
Reads and accumulates bytes, returning the 10-digit Tag ID.
"""
with safe_serial_access(timeout=0.5) as acquired:
if not acquired:
return None
if not self.pi or not self.pi.connected:
return None
count, data = self.pi.bb_serial_read(self.rx_pin)
if count > 0:
self.buffer.extend(data)
# Look for Start-of-Text (0x02)
stx_idx = self.buffer.find(b"\x02")
if stx_idx != -1:
# Discard noise prior to 0x02
if stx_idx > 0:
self.buffer = self.buffer[stx_idx:]
# Wait for full 14-byte payload
if len(self.buffer) >= 14:
raw_frame = self.buffer[:14]
self.buffer = self.buffer[14:] # Flush parsed frame
# Verify End-of-Text (0x03)
if raw_frame[-1] == 0x03:
try:
# Extract 10-character ID (indices 1 through 10)
return raw_frame[1:11].decode("ascii")
except UnicodeDecodeError:
return None
else:
# Flush buffer if filled with noise without STX marker
if len(self.buffer) > 64:
self.buffer.clear()
return None
def close(self):
with safe_serial_access(timeout=1.0) as acquired:
if self.pi and self.pi.connected:
try:
self.pi.bb_serial_read_close(self.rx_pin)
except pigpio.error:
pass
self.pi.stop()
if __name__ == "__main__":
reader = RFIDReader(rx_pin=17)
print("RFID Reader active on GPIO 17 (Pin 11). Swipe a tag...")
try:
while True:
tag = reader.read_tag()
if tag:
print(f"Scanned Tag ID: {tag}")
time.sleep(0.05)
except KeyboardInterrupt:
print("\nStopping reader.")
finally:
reader.close()
+104
View File
@@ -0,0 +1,104 @@
import time
import sys
from sensors.lock import safe_grove_access
if sys.platform == 'uwp':
import winrt_smbus as smbus
bus = smbus.SMBus(1)
else:
import smbus
import RPi.GPIO as GPIO
rev = GPIO.RPI_REVISION
if rev == 2 or rev == 3:
bus = smbus.SMBus(1)
else:
bus = smbus.SMBus(0)
# Device I2C addresses
DISPLAY_RGB_ADDR = 0x62
DISPLAY_TEXT_ADDR = 0x3e
def setRGB(r, g, b, brightness=1.0):
"""Set backlight to (R,G,B) with optional brightness level (0.0 to 1.0)."""
brightness = max(0.0, min(1.0, float(brightness)))
r_scaled = int(max(0, min(255, r * brightness)))
g_scaled = int(max(0, min(255, g * brightness)))
b_scaled = int(max(0, min(255, b * brightness)))
with safe_grove_access(timeout=1.0) as acquired:
if not acquired:
return
try:
bus.write_byte_data(DISPLAY_RGB_ADDR, 0, 0)
bus.write_byte_data(DISPLAY_RGB_ADDR, 1, 0)
bus.write_byte_data(DISPLAY_RGB_ADDR, 0x08, 0xaa)
bus.write_byte_data(DISPLAY_RGB_ADDR, 4, r_scaled)
bus.write_byte_data(DISPLAY_RGB_ADDR, 3, g_scaled)
bus.write_byte_data(DISPLAY_RGB_ADDR, 2, b_scaled)
except OSError:
pass
def textCommand(cmd):
"""Send command to display (internal use)."""
bus.write_byte_data(DISPLAY_TEXT_ADDR, 0x80, cmd)
def setText(text):
"""Set display text (\n for second line or auto wrap)."""
with safe_grove_access(timeout=1.5) as acquired:
if not acquired:
return
try:
textCommand(0x01) # Clear display
time.sleep(0.05)
textCommand(0x08 | 0x04) # Display on, no cursor
textCommand(0x28) # 2 lines
time.sleep(0.05)
count = 0
row = 0
for c in text:
if c == '\n' or count == 16:
count = 0
row += 1
if row == 2:
break
textCommand(0xc0)
if c == '\n':
continue
count += 1
bus.write_byte_data(DISPLAY_TEXT_ADDR, 0x40, ord(c))
time.sleep(0.001)
except OSError:
pass
def setText_norefresh(text):
"""Update display text without full screen erase."""
with safe_grove_access(timeout=1.5) as acquired:
if not acquired:
return
try:
textCommand(0x02) # Return home
time.sleep(0.05)
textCommand(0x08 | 0x04) # Display on, no cursor
textCommand(0x28) # 2 lines
time.sleep(0.05)
count = 0
row = 0
while len(text) < 32: # Clear rest of screen space
text += ' '
for c in text:
if c == '\n' or count == 16:
count = 0
row += 1
if row == 2:
break
textCommand(0xc0)
if c == '\n':
continue
count += 1
bus.write_byte_data(DISPLAY_TEXT_ADDR, 0x40, ord(c))
time.sleep(0.001)
except OSError:
pass
+20 -9
View File
@@ -15,17 +15,28 @@ white = 1 # The White colored sensor.
def get_temperature_and_humidity():
with grove_lock:
[temp,humidity] = grovepi.dht(sensor,blue)
if math.isnan(temp) == False and math.isnan(humidity) == False:
return temp, humidity
else:
print("Error reading from DHT sensor")
return None, None
try:
[temp, humidity] = grovepi.dht(sensor, blue)
if not math.isnan(temp) and not math.isnan(humidity) and temp > -40 and humidity >= 0:
return temp, humidity
except Exception:
pass
return None, None
def get_temperature_and_humidity_with_retry(max_retries=3):
for _ in range(max_retries): # Try up to max_retries times
def get_temperature_and_humidity_with_retry(max_retries=4, retry_delay=2.5):
"""
Reads temperature and humidity with retries.
Uses a 1.5s delay between attempts to respect DHT sampling limits.
"""
for attempt in range(max_retries):
temp, humidity = get_temperature_and_humidity()
if temp is not None and humidity is not None:
return temp, humidity
time.sleep(1) # Wait a bit before retrying
# Wait before retrying (DHT sensors require >= 1s between reads)
if attempt < max_retries - 1:
time.sleep(retry_delay)
print("Warning: All DHT sensor read attempts failed.")
return None, None
+13 -22
View File
@@ -1,33 +1,24 @@
import grovepi
from sensors.lock import grove_lock
from sensors.lock import safe_grove_access
from shared import config
# Connect the Grove Ultrasonic Ranger to digital port D4
# SIG,NC,VCC,GND
ULTRASONIC_RANGER_PORT = 4
def read_ultrasonic_ranger(ultrasonic_ranger=ULTRASONIC_RANGER_PORT):
if not grove_lock.acquire(timeout=1.0):
print("Ultrasonic: Lock acquisition timed out")
return None
try:
return grovepi.ultrasonicRead(ultrasonic_ranger)
except Exception as e:
print(f"Error: {e}")
return None
finally:
grove_lock.release()
with safe_grove_access(timeout=1.5) as acquired:
if not acquired:
return None
try:
return grovepi.ultrasonicRead(ultrasonic_ranger)
except Exception as e:
print(f"Ultrasonic read error: {e}")
return None
def get_dish_height():
"""Returns the height of the dish in centimeters."""
distance = read_ultrasonic_ranger()
if distance is not None:
# Assuming the ultrasonic sensor is mounted at a fixed height above the dish
# and pointing downwards, we can calculate the height of the dish.
# For example, if the sensor is 30 cm above the dish when it's empty:
# cm
dish_height = config.COOKING_COMPARTMENT_HEIGHT - distance
return max(dish_height, 0) # Ensure height is not negative
else:
return None
return max(dish_height, 0)
return None
+1
View File
@@ -9,6 +9,7 @@ User=pi
Group=pi
WorkingDirectory=/home/pi/SmartWave
EnvironmentFile=-/etc/default/smartwave
Environment="PYTHONUNBUFFERED=1"
ExecStart=/bin/sh /home/pi/SmartWave/orchestrateur/launch.sh /home/pi/SmartWave/orchestrateur/main.py
Restart=on-failure
RestartSec=5
+159
View File
@@ -0,0 +1,159 @@
import asyncio
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
import uvicorn
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SmartWave Technician Portal</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: "Segoe UI", Roboto, monospace; background: #121212; color: #e0e0e0; height: 100vh; overflow: hidden; }
.container { display: flex; height: 100vh; }
.logs-section { flex: 1; display: flex; flex-direction: column; border-right: 1px solid #333; padding: 15px; }
.logs-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.logs-header h2 { font-size: 1.1rem; color: #4fc3f7; }
.logs-header button { background: #333; color: #fff; border: 1px solid #555; padding: 4px 10px; border-radius: 4px; cursor: pointer; }
pre#log-output { flex: 1; background: #000; color: #00ff66; padding: 12px; border-radius: 6px; overflow-y: auto; font-size: 0.85rem; line-height: 1.4; white-space: pre-wrap; word-break: break-all; }
.control-section { width: 500px; padding: 20px; background: #1a1a1a; display: flex; flex-direction: column; gap: 15px; }
.control-section h2 { font-size: 1.1rem; color: #ffb74d; margin-bottom: 5px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
label { font-size: 0.85rem; color: #aaa; }
input[type="text"] { background: #2a2a2a; border: 1px solid #444; color: #fff; padding: 10px; border-radius: 4px; font-size: 0.9rem; }
input[type="text"]:focus { outline: none; border-color: #ffb74d; }
button.btn-submit { background: #ffb74d; color: #121212; border: none; padding: 12px; font-weight: bold; border-radius: 4px; cursor: pointer; transition: background 0.2s; }
button.btn-submit:hover { background: #ffa726; }
#status-msg { font-size: 0.85rem; padding: 8px; border-radius: 4px; display: none; }
.success { background: #1b5e20; color: #a5d6a7; }
.error { background: #b71c1c; color: #ef9a9a; }
</style>
</head>
<body>
<div class="container">
<div class="logs-section">
<div class="logs-header">
<h2>SmartWave Orchestrator System Logs</h2>
<button onclick="fetchLogs()">Refresh</button>
</div>
<pre id="log-output">Loading logs...</pre>
</div>
<div class="control-section">
<h2>Telemetry Control</h2>
<div class="form-group">
<label for="endpoint">Telemetry Endpoint URL:</label>
<input type="text" id="endpoint" value="https://smartwave.matthiasg.dev/telemetry">
</div>
<button class="btn-submit" onclick="triggerTelemetry()">Send Telemetry</button>
<div id="status-msg"></div>
</div>
</div>
<script>
async function fetchLogs() {
try {
const res = await fetch('/api/logs');
const logs = await res.json();
const logContainer = document.getElementById('log-output');
logContainer.textContent = logs.map(entry => {
const ts = entry.timestamp ? entry.timestamp.replace('T', ' ').split('.')[0] : 'RAW';
const pid = entry.pid ? `[PID:${entry.pid}]` : '';
return `${ts} ${pid} ${entry.message}`;
}).join('\\n');
logContainer.scrollTop = logContainer.scrollHeight;
} catch (err) {
console.error("Failed to fetch logs", err);
}
}
async function triggerTelemetry() {
const endpoint = document.getElementById('endpoint').value;
const statusDiv = document.getElementById('status-msg');
statusDiv.style.display = 'block';
statusDiv.className = '';
statusDiv.textContent = 'Sending telemetry...';
try {
const formData = new FormData();
formData.append('endpoint', endpoint);
const res = await fetch('/api/telemetry', {
method: 'POST',
body: formData
});
const data = await res.json();
if (res.ok && data.status === 'ok') {
statusDiv.className = 'success';
statusDiv.textContent = 'Telemetry sent successfully!';
} else {
throw new Error(data.detail || 'Failed');
}
} catch (err) {
statusDiv.className = 'error';
statusDiv.textContent = 'Error sending telemetry: ' + err.message;
}
}
fetchLogs();
setInterval(fetchLogs, 3000);
</script>
</body>
</html>
"""
def create_app(get_logs_cb, telemetry_cb, is_tech_mode_cb) -> FastAPI:
"""Factory creating FastAPI app with injected handler callbacks."""
app = FastAPI(title="Technician Portal")
# Attach callbacks to app.state
app.state.get_systemd_logs = get_logs_cb
app.state.handle_telemetry_request = telemetry_cb
app.state.is_tech_mode = is_tech_mode_cb
# Middleware to block traffic when TECHNICIAN_MODE is False
@app.middleware("http")
async def enforce_technician_mode(request: Request, call_next):
if not request.app.state.is_tech_mode():
return JSONResponse(
status_code=503,
content={"detail": "Technician mode is disabled."}
)
return await call_next(request)
@app.get("/", response_class=HTMLResponse)
async def serve_dashboard():
return HTML_TEMPLATE
@app.get("/api/logs")
async def api_get_logs(request: Request):
try:
logs = await request.app.state.get_systemd_logs(lines=200, service_name="smartwave")
return JSONResponse(content=logs)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/telemetry")
async def api_trigger_telemetry(request: Request, endpoint: str = Form(...)):
try:
await request.app.state.handle_telemetry_request(endpoint=endpoint, do_timeout=True)
return {"status": "ok", "message": f"Telemetry request sent to {endpoint}"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Telemetry trigger failed: {str(e)}")
return app
async def start_technician_web_server(get_logs_cb, telemetry_cb, is_tech_mode_cb, host="192.168.50.1", port=8080):
"""Asynchronous server launcher meant to run within main.py's event loop."""
app = create_app(get_logs_cb, telemetry_cb, is_tech_mode_cb)
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
await server.serve()
+4 -2
View File
@@ -6,13 +6,15 @@ import shared.config as config
import shared.payloads as payloads
import shared.cookingState as cookingState
import shared.safeQueue as safeQueue
import shared.alerts as alerts
import shared.microwave_state as microwave_state
try:
import shared.lora_device as lora_device
except ImportError:
except (ImportError, SyntaxError):
pass # No need
try:
import shared.uart_comm as uart_comm
except ImportError:
except (ImportError, SyntaxError):
pass # No need as we are on the RPI
import shared.sensors
+68
View File
@@ -0,0 +1,68 @@
import time
try:
import asyncio
import inspect
ASYNCIO = True
except ImportError:
ASYNCIO = False
class AlertManager:
def __init__(self):
self.alerts = []
self.on_alert_callback = None
def set_on_alert_callback(self, callback):
self.on_alert_callback = callback
def call_on_alert_callback(self, alert):
if self.on_alert_callback:
if ASYNCIO and inspect.iscoroutinefunction(self.on_alert_callback):
# Schedule coroutine on the running loop without blocking
try:
loop = asyncio.get_running_loop()
loop.create_task(self.on_alert_callback(alert))
except RuntimeError:
# Fallback if called outside an active event loop
asyncio.run(self.on_alert_callback(alert))
else:
self.on_alert_callback(alert)
def add_alert(self, alert):
self.alerts.append(alert)
self.call_on_alert_callback(alert)
def get_alerts(self):
return self.alerts
def clear_alerts(self):
self.alerts.clear()
class Alert:
def __init__(self, alertType: "AlertType", message: str, redistributed: bool = False, small_message: str = None):
self.alertType = alertType
self.message = message
self.small_message = small_message
self.timestamp = time.time()
self.redistributed = redistributed
def to_dict(self):
return {
"type": self.alertType,
"message": self.message,
"timestamp": self.timestamp
}
def to_json(self):
import json
return json.dumps(self.to_dict())
def from_json(json_string: str) -> "Alert":
import json
data = json.loads(json_string)
alert = Alert(data["type"], data["message"])
alert.timestamp = data["timestamp"]
return alert
class AlertType:
COOKING_SAFETY = "COOKING_SAFETY"
TEMPERATURE_ALERT = "TEMPERATURE_ALERT"
+18 -1
View File
@@ -1,4 +1,14 @@
# Debugging and others
DEBUG=True
DEBUG_MESSAGES=True
DEBUG_DANGEROUS_AREA=False
DEBUG_TEMPERATURE_ALERT=False
DEBUG_SHORTER_COOKING_PLAN=True
DEBUG_DISHANALYZER=True
# Technitian Web Server
TECHNICIAN_WEB_SERVER_HOST = "192.168.50.1"
TECHNICIAN_WEB_SERVER_PORT = 8080
# LoRa
LORA_HEARTBEAT_INTERVAL = 30
@@ -11,8 +21,15 @@ MQTT_TOPIC_COOKING = b"smartwave/cooking"
MQTT_KEEPALIVE = 30
USE_TLS = True
MQTT_QOS = 1
CLOUD_MQTT_QOS = 2
# Long because messages are stored into the broker and will be sent when the orchestrator is back online.
MQTT_HELLO_INTERVAL = 30
# Microwave Model
COOKING_COMPARTMENT_HEIGHT = 30 # cm
COOKING_COMPARTMENT_HEIGHT = 20.5 # cm
BUZZER_ACTIVATED = True
MAX_ALLOWED_TEMPERATURE_TECHNICAL_COMPARTMENT = 70
MAX_ALLOWED_TEMPERATURE_COOKING_COMPARTMENT = 200
# TELEMETRY
TELEMETRY_SEND_INTERVAL = 3
+2 -2
View File
@@ -1,6 +1,6 @@
from shared.config import DEBUG
from shared.config import DEBUG_MESSAGES
def log(message):
"""Log a message to the console if DEBUG is enabled."""
if DEBUG:
if DEBUG_MESSAGES:
print(f"\n{message}")
+141 -103
View File
@@ -20,11 +20,19 @@ else:
class BaseLoraDevice:
"""Base class providing automatic ACK generation, retries, and duplicate filtering."""
MAX_PAYLOAD_SIZE = 222 # Maximum safe LoRa payload size in bytes
def __init__(self):
self.processed_msg_ids = set()
self.received_acks = set()
self.pending_rx_queue = []
self.default_group = 2
# NEW: Global TX lock to prevent concurrent overlapping transmissions
if IS_MICROPYTHON:
self.tx_lock = _thread.allocate_lock()
else:
self.tx_lock = threading.Lock()
def _generate_msg_id(self):
return random.getrandbits(16)
@@ -73,53 +81,51 @@ class BaseLoraDevice:
return packet
def send_reliable(self, payload, max_retries=4, ack_timeout=3.0):
def send_reliable(self, payload, max_retries=5, ack_timeout=3.0):
"""Sends a payload and listens in a single continuous RX window for the ACK."""
lock = getattr(self, 'lock', None)
if isinstance(payload, dict):
payload = dict(payload)
else:
payload = {"data": payload}
with self.tx_lock:
lock = getattr(self, 'lock', None)
if isinstance(payload, dict):
payload = dict(payload)
else:
payload = {"data": payload}
msg_id = self._generate_msg_id()
payload["_msg_id"] = msg_id
msg_id = self._generate_msg_id()
payload["_msg_id"] = msg_id
log(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
log(f"\n[ReliableLoRa] === Starting send_reliable for msg_id {msg_id} ===")
for attempt in range(max_retries):
log(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
self.send(payload)
for attempt in range(max_retries):
log(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
self.send(payload)
# 1. Open a single continuous RX window for the full timeout duration
# (Defaulted to 3.0s to account for LA66 UART + transmission time)
timeout_ms = int(ack_timeout * 1000)
packet = self.receive_packet(timeout_ms=timeout_ms)
timeout_ms = int(ack_timeout * 1000)
packet = self.receive_packet(timeout_ms=timeout_ms)
if packet:
if lock: lock.acquire()
try:
filtered_packet = self._process_incoming_packet(packet)
if filtered_packet:
self.pending_rx_queue.append(filtered_packet)
finally:
if lock: lock.release()
# 2. Process incoming packet if received
if packet:
if lock: lock.acquire()
try:
filtered_packet = self._process_incoming_packet(packet)
if filtered_packet:
self.pending_rx_queue.append(filtered_packet)
if msg_id in self.received_acks:
self.received_acks.remove(msg_id)
log(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
return True
finally:
if lock: lock.release()
# 3. Check if matching ACK was received
if lock: lock.acquire()
try:
if msg_id in self.received_acks:
self.received_acks.remove(msg_id)
log(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
return True
finally:
if lock: lock.release()
log(f"[ReliableLoRa] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
log(f"[ReliableLoRa] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
print(f"[ReliableLoRa] ERROR: Failed to receive ACK for msg_id {msg_id} after {max_retries} attempts.")
return False
print(f"[ReliableLoRa] ERROR: Failed to receive ACK for msg_id {msg_id} after {max_retries} attempts.")
return False
def receive_reliable(self, timeout_ms=1000):
"""Receives a packet, automatically sending ACKs and filtering duplicate retries."""
@@ -225,6 +231,13 @@ if IS_MICROPYTHON:
payload = payload.encode('utf-8')
paquet_physique = bytes([group]) + payload
if len(paquet_physique) > self.MAX_PAYLOAD_SIZE:
raise ValueError(
f"[LoRa SPI] Payload exceeds MTU limit: {len(paquet_physique)} bytes "
f"(max allowed: {self.MAX_PAYLOAD_SIZE} bytes)."
)
try:
self.lora.send(paquet_physique)
except Exception as e:
@@ -287,21 +300,31 @@ else:
self.ser.reset_output_buffer()
self.lock = threading.Lock()
# Initial configuration
self._emergency_rx_buffer = []
self.configure(freq=868.1, sf=7, bw=125)
def _send_at_cmd(self, cmd, wait_time=0.3):
"""Sends AT command, draining unread serial noise first."""
# Drain any lingering lines (like 'Rssi= -4' or incoming data)
if self.ser.in_waiting > 0:
self.ser.read_all()
def _send_at_cmd(self, cmd, timeout=1.5):
"""Sends AT command and safely handles async incoming packets."""
self.ser.write(f"{cmd}\r\n".encode('utf-8'))
time.sleep(wait_time)
start = time.time()
resp = ""
while self.ser.in_waiting > 0:
resp += self.ser.readline().decode('utf-8', errors='ignore')
while (time.time() - start) < timeout:
if self.ser.in_waiting > 0:
line = self.ser.readline().decode('utf-8', errors='ignore').strip()
if not line:
continue
if any(marker in line for marker in ["+RECV:", "+RCV=", "+DRX:", "(HEX:)", "Data:"]):
self._emergency_rx_buffer.append(line)
continue
resp += line + "\n"
if "OK" in line or "ERROR" in line or "AT_BUSY" in line:
break
time.sleep(0.02)
return resp
def configure(self, freq=868.1, sf=7, bw=125):
@@ -312,14 +335,14 @@ else:
# Parameters: Freq, SF, BW, CR(0=4/5), Preamble(8), Header(1=Explicit), CRC(1=ON), IQ(0=Standard), NetMode(0=P2P), Power(14), SyncWord(18=0x12), Format(0), Type(1)
at_cfg_cmd = f"AT+CFG={freq_hz},{sf},{bw_code},0,8,1,1,0,0,14,18,0,1"
self._send_at_cmd(at_cfg_cmd, wait_time=0.2)
self._send_at_cmd(at_cfg_cmd, timeout=0.2)
# Fallback standalone commands
self._send_at_cmd("AT+SYNCWORD=18", wait_time=0.1)
self._send_at_cmd("AT+PRECV=65535", wait_time=0.1)
self._send_at_cmd("AT+SYNCWORD=18", timeout=0.1)
self._send_at_cmd("AT+PRECV=65535", timeout=0.1)
self.ser.reset_input_buffer()
def send(self, payload, group=None):
def send(self, payload, group=None, retries=0):
"""Encodes payload into a HEX string and transmits via 4-parameter AT+SEND."""
with self.lock:
if group is None:
@@ -331,14 +354,20 @@ else:
if isinstance(payload, str):
payload = payload.encode('utf-8')
if len(payload) > self.MAX_PAYLOAD_SIZE:
raise ValueError(
f"[LoRa Serial] Payload exceeds MTU limit: {len(payload)} bytes "
f"(max allowed: {self.MAX_PAYLOAD_SIZE} bytes)."
)
hex_payload = payload.hex()
log(f"[RPi LoRa Serial] Transmitting HEX string: {hex_payload}")
# Format: AT+SEND=<group>,<payload_string>,<confirm>,<retries>
cmd = f"AT+SEND={group},{hex_payload},0,3"
cmd = f"AT+SEND={group},{hex_payload},0,{retries}"
resp = self._send_at_cmd(cmd, wait_time=0.3)
resp = self._send_at_cmd(cmd, timeout=0.3)
log(f"[RPi LoRa Serial] AT+SEND response: {resp}")
def receive_packet(self, timeout_ms=500):
@@ -348,64 +377,70 @@ else:
timeout_s = timeout_ms / 1000.0
while (time.time() - start_time) < timeout_s:
if self.ser.in_waiting > 0:
line = ""
# NEW: Process emergency buffer first before checking serial
if self._emergency_rx_buffer:
line = self._emergency_rx_buffer.pop(0)
elif self.ser.in_waiting > 0:
line = self.ser.readline().decode('utf-8', errors='ignore').strip()
if line:
payload_bytes = None
# Robust parsing for LA66 response variants (+RECV:, +RCV=, +DRX:, HEX:, Data:)
if "+RECV:" in line:
parts = line.split("+RECV:")[1].strip().split(",")
hex_str = parts[2].strip() if len(parts) >= 3 else parts[0].strip()
try: payload_bytes = bytes.fromhex(hex_str)
if line:
payload_bytes = None
# Robust parsing for LA66 response variants (+RECV:, +RCV=, +DRX:, HEX:, Data:)
if "+RECV:" in line:
parts = line.split("+RECV:")[1].strip().split(",")
hex_str = parts[2].strip() if len(parts) >= 3 else parts[0].strip()
try: payload_bytes = bytes.fromhex(hex_str)
except ValueError: pass
elif "+RCV=" in line:
parts = line.split("+RCV=")[1].strip().split(",")
if len(parts) >= 4:
try: payload_bytes = bytes.fromhex(parts[3].strip())
except ValueError: pass
elif "+RCV=" in line:
parts = line.split("+RCV=")[1].strip().split(",")
if len(parts) >= 4:
try: payload_bytes = bytes.fromhex(parts[3].strip())
except ValueError: pass
elif "+DRX:" in line:
parts = line.split("+DRX:")[1].strip().split(",")
if len(parts) >= 2:
try: payload_bytes = bytes.fromhex(parts[1].strip())
except ValueError: pass
elif "(HEX:)" in line:
hex_part = line.split("(HEX:)")[1].strip().replace(" ", "")
try: payload_bytes = bytes.fromhex(hex_part)
elif "+DRX:" in line:
parts = line.split("+DRX:")[1].strip().split(",")
if len(parts) >= 2:
try: payload_bytes = bytes.fromhex(parts[1].strip())
except ValueError: pass
elif "Data:" in line:
payload_bytes = line.split("Data:")[1].strip().encode('utf-8')
elif "(HEX:)" in line:
hex_part = line.split("(HEX:)")[1].strip().replace(" ", "")
try: payload_bytes = bytes.fromhex(hex_part)
except ValueError: pass
elif "Data:" in line:
payload_bytes = line.split("Data:")[1].strip().encode('utf-8')
if payload_bytes and len(payload_bytes) > 0:
if payload_bytes[0] in (0x7B, 0x5B):
group = self.default_group
payload_clean = payload_bytes.strip(b'\x00 \r\n\t')
elif len(payload_bytes) > 1:
group = payload_bytes[0]
payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t')
else:
continue
if payload_bytes and len(payload_bytes) > 0:
if payload_bytes[0] in (0x7B, 0x5B):
group = self.default_group
payload_clean = payload_bytes.strip(b'\x00 \r\n\t')
elif len(payload_bytes) > 1:
group = payload_bytes[0]
payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t')
else:
continue
try:
text = payload_clean.decode('utf-8').strip('\x00 \r\n\t')
except UnicodeError:
continue
if text.startswith('{') or text.startswith('['):
decoded_text = text
elif text.lower().startswith('7b') or text.lower().startswith('5b'):
try:
text = payload_clean.decode('utf-8').strip('\x00 \r\n\t')
except UnicodeError:
continue
if text.startswith('{') or text.startswith('['):
decoded_text = bytes.fromhex(text).decode('utf-8').strip('\x00 \r\n\t')
except Exception:
decoded_text = text
elif text.lower().startswith('7b') or text.lower().startswith('5b'):
try:
decoded_text = bytes.fromhex(text).decode('utf-8').strip('\x00 \r\n\t')
except Exception:
decoded_text = text
else:
decoded_text = text
try:
parsed_json = json.loads(decoded_text)
return {"group": group, "data": parsed_json, "raw": False}
except json.JSONDecodeError:
return {"group": group, "data": decoded_text, "raw": True}
else:
decoded_text = text
try:
parsed_json = json.loads(decoded_text)
return {"group": group, "data": parsed_json, "raw": False}
except json.JSONDecodeError:
return {"group": group, "data": decoded_text, "raw": True}
time.sleep(0.01)
return None
@@ -422,6 +457,9 @@ def get_lora_device(port_or_pins=None):
class LoraCommands:
PING = "ping"
MICROVAVE_STATE_UPDATE = "microwave_state_update"
COOKING_STATE_UPDATE = "cooking_state_update"
COOKING_UPDATE = "cooking_update"
TOGGLE_PAUSE = "toggle_pause"
TOGGLE_DEFROST = "toggle_defrost"
TOGGLE_DEFROST = "toggle_defrost"
NEW_ALERT = "new_alert"
+95
View File
@@ -0,0 +1,95 @@
from shared.cookingState import CookingStates
import shared.config as config
class MicrowaveStateFields:
STATE = "state"
PAUSED = "paused"
COOKING_STATE = "cooking_state"
COOKING_ESTIMATED_REMAINING_TIME = "cooking_estimated_remaining_time"
COOKING_START_TIME = "cooking_start_time"
class MicrowaveState:
state = None
paused = False
cooking_state = None
cooking_estimated_remaining_time = 0
cooking_start_time = 0
def __init__(self, id, on_change_callback=None):
self.friendly_name = f"Microwave-{id}"
self.state = MicrowaveState.IDLE
self.cooking_state = CookingStates.IDLE
self.on_change_callback = on_change_callback
def _notify_change(self, field: MicrowaveStateFields = None):
"""Invokes callback if registered on state attribute changes."""
if self.on_change_callback:
try:
self.on_change_callback(self, field)
except Exception as e:
print(f"[MicrowaveState] Error in change callback: {e}")
def set_state(self, new_state):
if config.DEBUG:
assert new_state in (
MicrowaveState.IDLE,
MicrowaveState.ANALYZING,
MicrowaveState.WAITING_FOR_CLOUD,
MicrowaveState.COOKING,
MicrowaveState.DONE,
MicrowaveState.ALERT,
), f"Invalid state: {new_state}"
if self.state != new_state:
self.state = new_state
self._notify_change(MicrowaveStateFields.STATE)
def set_paused(self, is_paused):
if self.paused != is_paused:
self.paused = is_paused
self._notify_change(MicrowaveStateFields.PAUSED)
def toggle_pause(self):
self.set_paused(not self.paused)
def set_cooking_state(self, new_cooking_state):
if config.DEBUG:
assert new_cooking_state in (
CookingStates.IDLE,
CookingStates.COOKING,
CookingStates.DONE,
CookingStates.STIRRING_REQUIRED,
CookingStates.ALERT
), "Invalid cooking state: " + str(new_cooking_state)
if self.cooking_state != new_cooking_state:
if self.cooking_state == CookingStates.COOKING and new_cooking_state == CookingStates.DONE:
self.set_state(MicrowaveState.DONE)
self.cooking_state = new_cooking_state
self._notify_change(MicrowaveStateFields.COOKING_STATE)
def set_cooking_estimated_remaining_time(self, time_seconds):
if self.cooking_estimated_remaining_time != time_seconds:
self.cooking_estimated_remaining_time = time_seconds
self._notify_change(MicrowaveStateFields.COOKING_ESTIMATED_REMAINING_TIME)
def set_cooking_start_time(self, start_time):
if self.cooking_start_time != start_time:
self.cooking_start_time = start_time
self._notify_change(MicrowaveStateFields.COOKING_START_TIME)
def to_dict(self):
return {
"friendly_name": self.friendly_name,
"state": self.state,
"paused": self.paused,
"cooking_estimated_remaining_time": self.cooking_estimated_remaining_time,
"cooking_start_time": self.cooking_start_time
}
IDLE = "IDLE" # Microwave is empty
ANALYZING = "ANALYZING" # Reading sensors & waiting for IR
WAITING_FOR_CLOUD = "WAITING_FOR_CLOUD" # Waiting for API parameters
COOKING = "COOKING" # Microwave is active
DONE = "DONE" # Finished/Stopped, waiting for dish removal
ALERT = "ALERT" # Alert state, waiting for user action
+44 -1
View File
@@ -1,5 +1,7 @@
from time import time
from shared.deviceTypes import DEVICE_TYPES
from shared.alerts import Alert, AlertType
import shared.config as config
try:
@@ -51,4 +53,45 @@ def mqtt_cooking_config(id_microwave, cook_time, power_level, target_temp):
"cook_time": cook_time,
"power_level": power_level,
"target_temp": target_temp
})
})
def telemetry_payload(device_id, microwave_states, button_state, cloud_alert, gps_data, ambient_temp, ambient_humidity, connected_components, logs):
return as_json({
"device_id": device_id,
"timestamp": int(time()),
"states": microwave_states,
"button_state": button_state,
"cloud_alert": cloud_alert,
"gps": gps_data,
"ambient_temp": ambient_temp,
"ambient_humidity": ambient_humidity,
"connected_components": connected_components,
"logs": logs
})
def lora_new_alert(alert: Alert, with_message=False):
try:
from shared.lora_device import LoraCommands
except ImportError:
pass # No need
payload = {
"action": LoraCommands.NEW_ALERT,
"alert_type": alert.alertType
}
if with_message:
payload["alert_message"] = alert.message
return payload
def lora_microwave_state(state: str):
try:
from shared.lora_device import LoraCommands
except ImportError:
pass # No need
return {
"action": LoraCommands.MICROVAVE_STATE_UPDATE,
"new_microwave_state": state
}