Compare commits

..

154 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
Ninluc dc30f6ce6c Higher debug cooking params
Build, push image, and notify Watchtower / build-image (push) Successful in 57s
Build, push image, and notify Watchtower / notify (push) Successful in 11s
2026-08-07 16:51:08 +02:00
Ninluc 16e3325f8f Better estimated remaining time 2026-08-07 16:51:00 +02:00
Ninluc 988cb7958c Better cooking_state_temperature_provider 2026-08-07 16:50:14 +02:00
Ninluc 053964e56c UART commands for getting dish temperature 2026-08-07 16:14:43 +02:00
Ninluc ec9be8ac30 cooking state logs at interval 2026-08-07 16:13:47 +02:00
Ninluc f30b29be69 More Screen updates, icon and screens correction 2026-08-07 14:32:02 +02:00
Ninluc 46acbb88c2 Fix RGB lights 2026-08-06 19:42:34 +02:00
Ninluc 6ab2a2bea3 Fix Lora ? 2026-08-06 19:38:15 +02:00
Ninluc 35a330e7ae Microwave Screen + defrost mode 2026-08-06 19:27:35 +02:00
61 changed files with 4103 additions and 566 deletions
+3 -1
View File
@@ -1,4 +1,6 @@
`export PYTHONPATH=/home/pi/SmartWave` `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 os
import base64 import base64
import json import json
import urllib.request from typing import Any, Union
import urllib.error from openai import OpenAI
from pydantic import BaseModel
API_HOST = os.getenv("OPENAI_API_HOST", "https://chat.matthiasg.dev/ollama") # Default base_url pointing to Lemonade's OpenAI-compatible endpoint
AI_MODEL = os.getenv("OPENAI_MODEL", "llava:7b-v1.6-mistral-q4_1") API_HOST = os.getenv("OPENAI_API_HOST", "https://lemonade.matthiasg.dev/api/v1")
AI_MODEL_THINK = os.getenv("OPENAI_MODEL_THINK", "True").lower() in ("true", "1", "t") AI_MODEL = os.getenv("OPENAI_MODEL", "LFM2.5-VL-1.6B-GGUF-Q8_0")
OPENAPI_TOKEN = os.getenv("OPENAI_API_TOKEN", None) AI_MODEL_THINK = os.getenv("OPENAI_MODEL_THINK", "False").lower() in ("true", "1", "t")
OPENAPI_ENDPOINT = "/api/generate" OPENAPI_TOKEN = os.getenv("OPENAI_API_TOKEN", "lemonade")
print(f"Using API Host: {API_HOST}") print(f"Using API Host: {API_HOST}")
print(f"Using API Model: {AI_MODEL}") print(f"Using API Model: {AI_MODEL}")
print(f"Using API Model Think: {AI_MODEL_THINK}") 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 ''}") 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: # Initialize OpenAI Client for Lemonade
"""Call the API with the given endpoint and body dict.""" client = OpenAI(
url = f"{API_HOST}{endpoint}" base_url=API_HOST,
headers = { api_key=OPENAPI_TOKEN if OPENAPI_TOKEN else "lemonade"
"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}")
def generate( def generate(
model: str = AI_MODEL, model: str = AI_MODEL,
prompt: str = "", prompt: str = "",
images: list[str] = None, images: list[str] = None,
output_format: str = None, output_format: Union[str, dict, type[BaseModel]] = None,
system_message: str = None, system_message: str = None,
keep_alive: bool = True, keep_alive: bool = True,
should_think: bool = AI_MODEL_THINK, should_think: bool = AI_MODEL_THINK,
) -> str: ) -> str:
""" """
Generate a response for a given prompt with a provided model via the Ollama/OpenAI API. Generate a completion response using the OpenAI SDK against Lemonade / OpenAI compatible APIs.
Handles base64 encoding for local image file paths and structures the request body. Handles multimodal image content, structured outputs, and custom Lemonade parameters.
""" """
if images is None: if images is None:
images = [] images = []
# Transform image file paths to base64 strings messages = []
encoded_images = []
# 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: for img_path in images:
if os.path.isfile(img_path): if os.path.isfile(img_path):
with open(img_path, "rb") as image_file: 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: else:
# If it's already a base64 string or an invalid path, keep as-is b64_str = img_path
encoded_images.append(img_path)
body = { # Ensure base64 string includes Data URI prefix for OpenAI vision format
"model": model, if not b64_str.startswith("data:"):
"prompt": prompt, image_url = f"data:image/jpeg;base64,{b64_str}"
"images": encoded_images, 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, "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: # 4. Handle Structured Outputs / JSON mode
body["system"] = system_message
if output_format is not None: if output_format is not None:
try: if isinstance(output_format, type) and issubclass(output_format, BaseModel):
body["format"] = json.loads(output_format) # Pydantic Model -> Use structured output parsing API
except json.JSONDecodeError: response = client.beta.chat.completions.parse(
body["format"] = output_format 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: # Standard completion request
body["keep_alive"] = "0m" response = client.chat.completions.create(**request_kwargs)
return response.choices[0].message.content or ""
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", "")
if __name__ == "__main__": if __name__ == "__main__":
# Example usage:
result = generate( 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, should_think=AI_MODEL_THINK,
) )
print(result) 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_ID__FOOD = os.getenv("EDAMAM_APP_ID__FOOD", None)
EDAMAM_APP_KEY__FOOD = os.getenv("EDAMAM_APP_KEY__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: class EdamamAPI:
"""Wrapper around Edamam Food Database API 2.0 (Vision & Nutrients)""" """Wrapper around Edamam Food Database API 2.0 (Vision & Nutrients)"""
@@ -17,7 +17,7 @@ class EdamamAPI:
pass pass
def analyze_dish_image(self, image_file_path: str): def analyze_dish_image(self, image_file_path: str):
if SAVE_EDAMAM_API_TOKEN: if SAVEUP_EDAMAM_API_TOKEN:
time.sleep(3) time.sleep(3)
return json.loads(""" 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 # Query parameters as specified by the YAML schema
params = { # params = {
"app_id": EDAMAM_APP_ID__FOOD, # "app_id": EDAMAM_APP_ID__FOOD,
"app_key": EDAMAM_APP_KEY__FOOD, # "app_key": EDAMAM_APP_KEY__FOOD,
"beta": "true" # Recommended since vision is in Beta # "beta": "true" # Recommended since vision is in Beta
} # }
print("params:", params) # print("params:", params)
# Specify the media type in the header # Specify the media type in the header
headers = { headers = {
@@ -932,7 +932,7 @@ class EdamamAPI:
response = requests.post( response = requests.post(
url, url,
params=params, # params=params,
headers=headers, headers=headers,
data=raw_image_bytes 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 : 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 os
import base64
import uuid
from flask import Flask, request, jsonify
from pymongo import MongoClient
from APIs import generate, EdamamAPI
import sys 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 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, '..') sys.path.insert(0, '..')
try: try:
from shared import config from shared import config
@@ -18,114 +34,634 @@ app = Flask(__name__)
# Configuration & Setup # 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/") MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/")
client = MongoClient(MONGO_URI) client = MongoClient(MONGO_URI)
db = client["microwave_network_db"] db = client["microwave_network_db"]
# Database Collections
client_collection = db["client_data"]
cooking_collection = db["cooking_parameters"] 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 # 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) os.makedirs(CAMERA_IMAGE_DIR, exist_ok=True)
# --------------------------------------------------------- # ---------------------------------------------------------
# Classes # Integrations Setup
# --------------------------------------------------------- # ---------------------------------------------------------
microwave_cook_planner = MicrowaveCookPlanner() 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("/") @app.route("/")
def hello_world(): def hello_world():
gen = generate(prompt="Say Hello, to the user !") gen = generate(prompt="Say Hello, to the user !")
print(gen)
return f"<p>{gen}</p>" 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"]) @app.route("/cooking-params", methods=["POST"])
def cooking_params(): async def cooking_params():
data = request.get_json() try:
data = request.get_json()
if not data: if not data or not isinstance(data, dict):
return jsonify({"error": "Invalid or missing JSON payload"}), 400 return jsonify({"@odata.error": {"code": "400", "message": "Invalid payload"}}), 400
# Extract user or device parameters (with fallback defaults) # data["microwave_id"] = g.device_id
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 forwarded_for = request.headers.get('X-Forwarded-For')
microwave_wattage = int(data.get("microwave_wattage", 900)) # e.g., 900W client_ip = forwarded_for.split(',')[0].strip() if forwarded_for else request.remote_addr
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) 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" filename = f"dish_{uuid.uuid4().hex}.jpg"
filepath = os.path.join(CAMERA_IMAGE_DIR, filename) filepath = os.path.join(CAMERA_IMAGE_DIR, filename)
try: with open(filepath, "wb") as f:
with open(filepath, "wb") as f: f.write(base64.b64decode(camera_image_b64))
f.write(base64.b64decode(camera_image_b64)) data["camera_image"] = filepath
data["camera_image"] = filepath enhanced_filepath = enhance_image_for_ai(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
# 2. Run the Cook Planning Engine # Concurrent AI Execution
try: safety_task = asyncio.to_thread(safety_checker.check_dish_safety, filepath)
cook_plan = microwave_cook_planner.generate_plan( planner_task = asyncio.to_thread(
image_path=filepath, microwave_cook_planner.generate_plan,
image_path=enhanced_filepath,
height_cm=height_cm, height_cm=height_cm,
initial_temp_c=initial_temp_c, initial_temp_c=initial_temp_c,
microwave_wattage=microwave_wattage, microwave_wattage=microwave_wattage,
defrost_mode=defrost_mode 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: 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 @app.route("/alert", methods=["POST"])
data["analysis_results"] = cook_plan def alert():
# 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():
data = request.get_json() data = request.get_json()
if data is None or not isinstance(data, dict):
if not data: return jsonify({
return jsonify({"error": "Invalid or missing JSON payload"}), 400 "@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: try:
# Insert the dictionary directly into Mongo (it will retain your exact JSON keys) saved_alert = alert_collection.insert_one(data)
device_network_collection.insert_one(data) doc_id = str(saved_alert.inserted_id)
return "", 200
except Exception as e: 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"]) if alert_type != "COOKING_SAFETY":
def debug():
image_path = "microwaveDish.jpg" client_name = f"Unknown Client (Orchestrator {orchestrator_id})" if orchestrator_id else "Unknown Client"
edamam = EdamamAPI() client_email = None
return edamam.analyze_dish_image(image_path) 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__": 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, "reconciled_mass_g": final_mass_g,
"mass_validation_status": mass_data["status"], "mass_validation_status": mass_data["status"],
"cook_plan": cook_plan, "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 cv2
import numpy as np import numpy as np
from typing import Dict, Any, Optional 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: class MicrowaveDishAnalyzer:
@@ -10,8 +16,7 @@ class MicrowaveDishAnalyzer:
""" """
# Constant scale ratio: Centimeters per Pixel. # Constant scale ratio: Centimeters per Pixel.
# TODO : Replace this value once your camera calibration is complete. CM_PER_PIXEL: float = 0.005925926
CM_PER_PIXEL: float = 0.05 # Example: 1 pixel = 0.05 cm
def __init__(self, cm_per_pixel: Optional[float] = None): def __init__(self, cm_per_pixel: Optional[float] = None):
if cm_per_pixel is not None: if cm_per_pixel is not None:
@@ -43,6 +48,17 @@ class MicrowaveDishAnalyzer:
largest_contour = max(contours, key=cv2.contourArea) largest_contour = max(contours, key=cv2.contourArea)
area_pixels = cv2.contourArea(largest_contour) 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 # 5. Convert pixels² to cm² using scale ratio squared
area_cm2 = area_pixels * (self.cm_per_pixel ** 2) area_cm2 = area_pixels * (self.cm_per_pixel ** 2)
return float(area_cm2) 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 pymongo==4.6.1
gunicorn==21.2.0 gunicorn==21.2.0
opencv-python-headless 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) # Configuration des ports (À remplacer par tes propres chemins by-path)
# Pour trouver tes chemins : ls -l /dev/serial/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_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-usbv2-0:6.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... # Ajoute les autres si besoin...
# Configuration Raspberry Pi # Configuration Raspberry Pi
@@ -65,6 +65,7 @@ deploy_to_rpi() {
--exclude '__pycache__' \ --exclude '__pycache__' \
--exclude '*.pyc' \ --exclude '*.pyc' \
--exclude '.mypy_cache' \ --exclude '.mypy_cache' \
--exclude 'ecran_externe' \
./ "${RPI_USER}@${RPI_HOST}:${RPI_DEST}/" ./ "${RPI_USER}@${RPI_HOST}:${RPI_DEST}/"
echo "🔄 Redémarrage du service systemd ${RPI_SYSTEMD_SERVICE}..." 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.
+152
View File
@@ -0,0 +1,152 @@
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')
image_Alert_bits = bytearray(b'\xf7\x80\xe3\x80\xeb\x80\xc9\x80\xc9\x80\x80\x80\x88\x80\x00\x00')
image_CoolHiSmall_bits = bytearray(b'\x1f\xff\xc0?\xff\xe0\x7f\xff\xf0\xff\xdf\xf8\xffW\xf8\xfd\x8d\xf8\xf9\xdc\xf8\xfe\xdb\xf8\xf7Wx\xfb\x8e\xf8\xe0\x008\xfb\x8e\xf8\xf7Wx\xfe\xdb\xf8\xf9\xdc\xf8\xfd\x8d\xf8\xffW\xf8\xff\xdf\xf8\x7f\xff\xf0?\xff\xe0\x1f\xff\xc0')
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
CHARACTER_GAP = 2
CHARACTER_FULL_WIDTH = CHARACTER_WIDTH + CHARACTER_GAP
CHARACTER_HEIGHT = 7
class MicrowaveScreen:
# Used for animation
# lastUpdateTime = 0 # Not used right now
def __init__(self, display):
self.display = display
self.width = display.width
self.height = display.height
def h_centered_text(self, text, y, color=1):
"""Centers text horizontally
Args:
text (str): _description_
y (int): _description_
color (int, optional): _description_. Defaults to 1.
"""
text = str(text)
text_width = len(text) * CHARACTER_FULL_WIDTH
if config.DEBUG:
if text_width > self.width:
print(f"[MicrowaveScreen] Warning: Text '{text}' is too long to fit on the screen.")
x = (self.width - text_width) // 2
self.display.text(text, x, y, color)
def flick(self):
self.display.fill(0)
def show(self):
self.display.show()
def bootScreen(self):
self.display.text("SmartWave", 28, 28, 1)
self.show()
def _progressBar(self, progress, x, y, width=87, height=13, color=1):
# Box outline
self.display.rect(x, y, width, height, color)
# Fill the progress bar based on the progress value (0.0 to 1.0)
fill_width = int(progress * (width)) # Subtract 2
self.display.fill_rect(x, y, fill_width, height, color)
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 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 and cooking_state.state == CookingStates.STIRRING_REQUIRED:
self._message("Pls Stir", True)
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)
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 and cooking_state.paused:
self.h_centered_text("Paused", 40, 1)
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 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 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:
self.display.blit(fb_image_CoolHiSmall_bits, 2, 2)
else:
self.display.blit(fb_image_CoolLoSmall_bits, 2, 2)
self.show()
pass
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, 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._message(text, alert)
self.show()
+239 -60
View File
@@ -1,11 +1,10 @@
import gc import gc
import sys import sys
import time import time
import _thread
import uasyncio as asyncio import uasyncio as asyncio
from machine import Pin, SoftI2C from machine import Pin, SoftI2C
import framebuf
import ssd1306 import ssd1306
import ujson
# Clean memory immediately # Clean memory immediately
gc.collect() gc.collect()
@@ -16,6 +15,10 @@ from shared.uart_comm import UARTCommand, UARTCommandType
from shared.sensors import RGBLED from shared.sensors import RGBLED
from shared.logging import log from shared.logging import log
from shared.lora_device import LoraCommands 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 --- # --- READ DEVICE ID ---
try: try:
@@ -26,20 +29,69 @@ except Exception:
# --- GLOBAL VARIABLES --- # --- GLOBAL VARIABLES ---
cooking_state = None cooking_state = None
cooking_start_time = None # Track start timestamp
microwave_state: str = None
data_queue = SafeQueue() data_queue = SafeQueue()
lora = None lora = None
uart_device = None uart_device = None
magnetron_led = None magnetron_led = None
display = None microwave_screen = None
defrost_mode = False
current_temp = [None, None]
last_temp = [None, None]
temperature_asked = False
last_temp_request_time = 0
alert_manager = None
PING_PAYLOAD = { PING_PAYLOAD = {
"id": DEVICE_ID, "id": DEVICE_ID,
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"] "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(): def init_hardware():
"""Initializes all hardware components.""" """Initializes all hardware components."""
global lora, uart_device, magnetron_led, display global lora, uart_device, magnetron_led, microwave_screen
print("[Main] Initializing hardware peripherals...") print("[Main] Initializing hardware peripherals...")
@@ -47,7 +99,15 @@ def init_hardware():
vext = Pin(19, Pin.OUT) vext = Pin(19, Pin.OUT)
vext.value(0) vext.value(0)
time.sleep_ms(100) time.sleep_ms(100)
# Init OLED Display
scl_pin = Pin(18, Pin.OUT, pull=Pin.PULL_UP)
sda_pin = Pin(17, Pin.OUT, pull=Pin.PULL_UP)
display_i2c = SoftI2C(scl=scl_pin, sda=sda_pin, freq=100000)
display = ssd1306.SSD1306_I2C(128, 64, display_i2c, addr=0x3C)
microwave_screen = MicrowaveScreen(display)
microwave_screen.bootScreen()
# Init LoRa # Init LoRa
lora = get_lora() lora = get_lora()
lora.configure(freq=868.1, sf=7) lora.configure(freq=868.1, sf=7)
@@ -57,23 +117,14 @@ def init_hardware():
magnetron_led.color = RGBLED.WHITE_YELLOW magnetron_led.color = RGBLED.WHITE_YELLOW
magnetron_led.off() magnetron_led.off()
# Init OLED Display
scl_pin = Pin(18, Pin.OUT, pull=Pin.PULL_UP)
sda_pin = Pin(17, Pin.OUT, pull=Pin.PULL_UP)
display_i2c = SoftI2C(scl=scl_pin, sda=sda_pin, freq=100000)
display = ssd1306.SSD1306_I2C(128, 64, display_i2c, addr=0x3C)
display.text("Booting...", 1, 2, 1)
display.show()
# Init UART # Init UART
uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45) uart_device = get_uart(uart_id=1, tx_pin=46, rx_pin=45)
print(f"[Main] ESP32 initialized with ID: '{DEVICE_ID}' (Type: {deviceTypes.DEVICE_TYPES['MICROWAVE']})") print(f"[Main] ESP32 initialized with ID: '{DEVICE_ID}' (Type: {deviceTypes.DEVICE_TYPES['MICROWAVE']})")
# --- ASYNC LORA TASK (REPLACES _THREAD) ---
# --- DEDICATED LORA HARDWARE THREAD --- async def lora_rx_and_heartbeat_task():
def lora_hardware_thread(): """Replaces the hardware OS thread with a non-blocking async task to avoid SPI collisions."""
"""Runs in a separate OS thread to keep the LoRa radio in continuous RX mode."""
last_heartbeat_time = 0 last_heartbeat_time = 0
while True: while True:
@@ -82,70 +133,144 @@ def lora_hardware_thread():
# 1. Send periodic heartbeat # 1. Send periodic heartbeat
if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL: if now - last_heartbeat_time >= config.LORA_HEARTBEAT_INTERVAL:
last_heartbeat_time = now last_heartbeat_time = now
print("\n[LoRa Thread] Sending Heartbeat...") log("\n[LoRa Task] Sending Heartbeat...")
if lora: if lora:
lora.send(PING_PAYLOAD) 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: if lora:
paquet = lora.receive_reliable(timeout_ms=300) paquet = lora.receive_reliable(timeout_ms=500)
if paquet is not None: 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) data_queue.put(paquet)
time.sleep_ms(10) # Give control back to event loop
await asyncio.sleep_ms(10)
# --- COOKING STATE CALLBACKS --- # --- COOKING STATE CALLBACKS ---
def cooking_state_temperature_provider(): def cooking_state_temperature_provider():
return 22.0, 29.0 # TODO: Replace with real temperature reading global current_temp, last_temp, temperature_asked, last_temp_request_time, uart_device
def temp_is_invalid(temp):
return (
temp is None
or not isinstance(temp, (list, tuple))
or len(temp) < 2
or temp[0] is None
or temp[1] is None
)
# 1. Check for UART request timeout (reset lock if 3 seconds pass without a response)
now = time.time()
if temperature_asked and (now - last_temp_request_time > 3):
print("[CookingState] Temperature request timed out. Retrying UART request...")
temperature_asked = False
# 2. Trigger new UART request if idle
if not temperature_asked and uart_device:
temperature_asked = True
last_temp_request_time = now
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_REQUEST, {}))
# 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]]
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
if not temp_is_invalid(last_temp):
return [float(last_temp[0]), float(last_temp[1])]
# 5. Default fallback if no data has ever arrived
return (0.0, 0.0)
def cooking_state_on_state_change(state): def cooking_state_on_state_change(state):
global cooking_start_time, uart_device
print(f"[CookingState] State changed to: {state.state}") 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() magnetron_led.off()
else: else:
magnetron_led.on() magnetron_led.on()
# Send state updates to WiFi board and Orchestrator # 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})) 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})
# Update OLED display # Send LoRa update with start time and remaining time if beginning cooking process
if display: send_cooking_update(
display.fill(0) state=state.state,
display.text(cookingState.CookingStates.get_state_name(state.state), 1, 2, 1) start_time=cooking_start_time if state.state == cookingState.CookingStates.COOKING else None,
display.show() estimated_remaining_time=state.get_remaining_time() if hasattr(state, "get_remaining_time") else None,
paused=state.paused
)
update_screen()
def cooking_state_on_refresh(state): def cooking_state_on_refresh(state):
# TODO: Show screen information # Update OLED display
pass update_screen()
def cooking_state_on_pause(state): 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 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): if not state.paused and (state.state == cookingState.CookingStates.STIRRING_REQUIRED or state.state == cookingState.CookingStates.ALERT):
state.set_state(cookingState.CookingStates.COOKING) 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 TASKS ---
async def uart_polling_task(): async def uart_polling_task():
"""Polls UART for incoming messages from the WiFi board.""" """Polls UART for incoming messages from the WiFi board."""
global cooking_state global cooking_state, cooking_start_time, temperature_asked, microwave_state
while True: while True:
if uart_device and uart_device.any(): if uart_device and uart_device.any():
command = uart_device.read_as_command() command = uart_device.read_as_command()
if command: if command:
print(f"[UART Task] Received command from WiFi Board: {command.command_type}") log(f"[UART Task] Received command from WiFi Board: {command.command_type}")
if command.command_type == UARTCommandType.COOKING_PARAMS: if command.command_type == UARTCommandType.COOKING_PARAMS:
params = command.payload params = command.payload
print(f"[UART Task] Cooking parameters received: {params}") print(f"[UART Task] Cooking parameters received: {params}")
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_REQUEST, {}))
# Track start time timestamp
cooking_start_time = time.time()
cooking_state = cookingState.CookingState( cooking_state = cookingState.CookingState(
cook_time=params["cook_time"], cook_time=params["cook_time"],
power_level=params["power_level"], power_level=params["power_level"],
@@ -156,8 +281,19 @@ async def uart_polling_task():
cooking_state.set_refresh_callback(cooking_state_on_refresh) cooking_state.set_refresh_callback(cooking_state_on_refresh)
cooking_state.set_pause_callback(cooking_state_on_pause) cooking_state.set_pause_callback(cooking_state_on_pause)
await asyncio.sleep_ms(20) await asyncio.sleep_ms(200)
cooking_state_on_state_change(cooking_state) 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
payload = ujson.loads(command.payload) if isinstance(command.payload, str) else command.payload
current_temp[0] = payload.get("dish_temp", 0.0)
current_temp[1] = payload.get("ambient_temp", 0.0)
except Exception as e:
print(f"[UART Task] Error parsing temperature payload: {e}")
finally:
temperature_asked = False
else: else:
print(f"[UART Task] Unknown command type received: {command.command_type}") print(f"[UART Task] Unknown command type received: {command.command_type}")
@@ -166,7 +302,7 @@ async def uart_polling_task():
async def lora_process_task(): async def lora_process_task():
"""Consumes packets pushed to data_queue by the LoRa hardware thread.""" """Consumes packets pushed to data_queue by the LoRa hardware thread."""
global cooking_state global cooking_state, defrost_mode, microwave_screen, microwave_state
while True: while True:
while not data_queue.empty(): while not data_queue.empty():
@@ -174,34 +310,79 @@ async def lora_process_task():
if paquet and not paquet.get("raw"): if paquet and not paquet.get("raw"):
data = paquet.get("data", {}) 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 "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 is not None:
if cooking_state.state == cookingState.CookingStates.DONE: 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) cooking_state.set_state(cookingState.CookingStates.IDLE)
await asyncio.sleep_ms(20) await asyncio.sleep_ms(20)
cooking_state = None cooking_state = None
else: else:
cooking_state.toggle_pause() cooking_state.toggle_pause()
if cooking_state.paused: print(f"[LoRa Process] Cooking paused state toggled to {cooking_state.paused}")
print("[LoRa Process] Cooking paused via orchestrator command.")
else:
print("[LoRa Process] Cooking resumed via orchestrator command.")
else: else:
log("[LoRa Process] No active cooking state to toggle pause/resume.") log("[LoRa Process] No active cooking state to toggle pause/resume.")
elif action == LoraCommands.TOGGLE_DEFROST:
print("[LoRa Process] Toggling defrost mode via orchestrator command.")
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(): async def cooking_loop_task():
"""Ticks the cooking state and logs information periodically.""" """Ticks the cooking state and logs information periodically without flooding output."""
log_counter = 0
while True: while True:
if cooking_state is not None: if cooking_state is not None:
cooking_state.update_tick() cooking_state.update_tick()
print(f"[Cooking Task] State: {cooking_state.state}, Temp: {cooking_state.current_dish_temp}, " log_counter += 1
f"Paused: {cooking_state.paused}, Remaining: {cooking_state.get_remaining_time():.2f}s, "
f"Est. Remaining: {cooking_state.get_remaining_time_estimation():.2f}s") # 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) await asyncio.sleep_ms(500)
@@ -215,26 +396,24 @@ async def memory_cleanup_task():
# --- BOOTSTRAP --- # --- BOOTSTRAP ---
async def main(): async def main():
global alert_manager
print("[Main] Starting application...") print("[Main] Starting application...")
init_hardware() init_hardware()
# Launch dedicated hardware thread for LoRa RX alert_manager = AlertManager()
try: alert_manager.set_on_alert_callback(on_new_alert)
_thread.stack_size(16 * 1024)
except Exception:
pass
_thread.start_new_thread(lora_hardware_thread, ())
print("[Main] LoRa hardware background thread started.")
# 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(uart_polling_task())
asyncio.create_task(lora_process_task()) asyncio.create_task(lora_process_task())
asyncio.create_task(cooking_loop_task()) asyncio.create_task(cooking_loop_task())
asyncio.create_task(memory_cleanup_task()) asyncio.create_task(memory_cleanup_task())
print("[Main] All async tasks running concurrently!") print("[Main] All async tasks running concurrently!")
update_screen()
# Keep main task alive indefinitely # Keep main task alive indefinitely
while True: while True:
await asyncio.sleep(3600) await asyncio.sleep(3600)
+8 -6
View File
@@ -195,14 +195,16 @@ async def uart_task():
try: try:
cmd = uart_device.read_as_command() cmd = uart_device.read_as_command()
if cmd: if cmd and hasattr(cmd, "command_type"):
print("[UART] Command received from LoRa board:", cmd.command_type, cmd.payload) print("[UART] Command received from LoRa board:", cmd.command_type, cmd.payload)
if ( if cmd.command_type == UARTCommandType.COOKING_STATE_UPDATE:
hasattr(cmd, "command_type")
and cmd.command_type == UARTCommandType.COOKING_STATE_UPDATE
):
if cooking_state: if cooking_state:
cooking_state.set_state(cmd.payload.get("state")) cooking_state.set_state(cmd.payload.get("state"))
elif cmd.command_type == UARTCommandType.TEMPERATURE_REQUEST:
uart_device.send_as_command(UARTCommand(UARTCommandType.TEMPERATURE_RESPONSE, payloads.lora_sensor_data(
mlx_temperature_sensor.read_object_temp() if mlx_temperature_sensor else 0,
mlx_temperature_sensor.read_ambient_temp() if mlx_temperature_sensor else 0
)))
except Exception as e: except Exception as e:
print("[UART Task] Error reading command:", e) print("[UART Task] Error reading command:", e)
@@ -221,7 +223,7 @@ async def connect_mqtt_async():
mqtt_client = get_mqtt_client( mqtt_client = get_mqtt_client(
host="192.168.50.1", # TODO : Use config.MQTT_BROKER_HOST instead of hardcoding host="192.168.50.1", # TODO : Use config.MQTT_BROKER_HOST instead of hardcoding
port=8884, port=8884,
client_id="smartwave-esp32-demo", client_id="smartwave-esp32-demo", # TODO : Use DEVICE_ID instead of hardcoding
use_tls=True, use_tls=True,
cafile=MQTT_CA_FILE, cafile=MQTT_CA_FILE,
keepalive=30, keepalive=30,
+8 -1
View File
@@ -2,4 +2,11 @@
# IP Adresses # IP Adresses
- AP (dynamic) : `10.110.0.190` - 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: 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: mqtt-broker:
image: eclipse-mosquitto:2.0 image: eclipse-mosquitto:2.0
container_name: mqtt-broker
restart: unless-stopped
network_mode: host
environment: environment:
MQTT_TLS_ENABLED: ${MQTT_TLS_ENABLED:-true} MQTT_TLS_ENABLED: ${MQTT_TLS_ENABLED:-true}
# restart: unless-stopped
ports:
- "192.168.50.1:8884:8884"
volumes: volumes:
- ./mqtt/mosquitto-tls.conf:/mosquitto/config/mosquitto-tls.conf:ro - ./mqtt/mosquitto-tls.conf:/mosquitto/config/mosquitto-tls.conf:ro
- ./mqtt/mosquitto-plain.conf:/mosquitto/config/mosquitto-plain.conf:ro - ./mqtt/mosquitto-plain.conf:/mosquitto/config/mosquitto-plain.conf:ro
@@ -14,8 +35,8 @@ services:
- mqtt-data:/mosquitto/data - mqtt-data:/mosquitto/data
- mqtt-log:/mosquitto/log - mqtt-log:/mosquitto/log
command: ["/bin/sh", "/scripts/start-broker.sh"] command: ["/bin/sh", "/scripts/start-broker.sh"]
# command: ["tail", "-f", "/dev/null"] # Do nothing
volumes: volumes:
otbr-data:
mqtt-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) SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(dirname -- "$SCRIPT_DIR") REPO_ROOT=$(dirname -- "$SCRIPT_DIR")
VENV_DIR="$SCRIPT_DIR/venv"
PYTHON_BIN="${PYTHON_BIN:-python3}" PYTHON_BIN="${PYTHON_BIN:-python3}"
PYTHON_SCRIPT="${1:-$SCRIPT_DIR/main.py}" PYTHON_SCRIPT="${1:-$SCRIPT_DIR/main.py}"
REQUIREMENTS_FILE="$SCRIPT_DIR/requirements.txt" REQUIREMENTS_FILE="$SCRIPT_DIR/requirements.txt"
@@ -19,24 +20,28 @@ cd "$SCRIPT_DIR"
# 2. On lance Docker en arrière-plan # 2. On lance Docker en arrière-plan
echo "Démarrage des conteneurs Docker..." echo "Démarrage des conteneurs Docker..."
# docker compose pull
docker compose up -d --remove-orphans 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 if [ -f "$REQUIREMENTS_FILE" ]; then
echo "Vérification des dépendances Python..." echo "Vérification et installation des dépendances dans le venv..."
if [ "$(id -u)" -eq 0 ]; then "$VENV_PIP" install --upgrade pip >/dev/null 2>&1 || true
"$PYTHON_BIN" -m pip install -r "$REQUIREMENTS_FILE" "$VENV_PIP" install -r "$REQUIREMENTS_FILE"
else
"$PYTHON_BIN" -m pip install --user -r "$REQUIREMENTS_FILE"
fi
fi fi
# 4. Sécurité anti-conflit : On vérifie si Docker n'a pas verrouillé le port USB de la LA66 # 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" PORT="/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"
if [ -e "$PORT" ]; then if [ -e "$PORT" ]; then
echo "Vérification de la disponibilité du port série..." 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 if lsof "$PORT" >/dev/null 2>&1; then
echo "Le port série est occupé ! Libération en cours..." echo "Le port série est occupé ! Libération en cours..."
fuser -k "$PORT" || true fuser -k "$PORT" || true
@@ -44,7 +49,7 @@ if [ -e "$PORT" ]; then
fi fi
fi fi
# 5. Lancement propre du script Python # 5. Lancement propre du script Python via le venv
echo "Launching Python script: $PYTHON_SCRIPT" echo "Launching Python script: $PYTHON_SCRIPT"
cd "$REPO_ROOT" cd "$SCRIPT_DIR"
exec "$PYTHON_BIN" "$PYTHON_SCRIPT" 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 []
+587 -113
View File
@@ -1,19 +1,34 @@
import base64 import base64
import json import json
import time import time
import traceback
import asyncio import asyncio
import requests 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 import get_lora, get_mqtt_client, deviceTypes, config, payloads, db
from shared.logging import log from shared.logging import log
from shared.cookingState import CookingStates from shared.cookingState import CookingStates
from shared.lora_device import LoraCommands 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 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(): def init_db():
"""Ensures the connected_components table exists on startup.""" """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)) 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}") 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 --- # --- Read Unique Device ID ---
def get_device_id(): def get_device_id():
@@ -50,31 +77,77 @@ def get_device_id():
pass pass
return "RPI_Orchestrateur_Default" 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 --- DEVICE_ID = get_device_id()
class MicrowaveState: API_KEY = get_api_key()
IDLE = "IDLE" # Microwave is empty
ANALYZING = "ANALYZING" # Reading sensors & waiting for IR # --- DISPLAY QUEUE & NOTIFICATION HELPERS ---
WAITING_FOR_CLOUD = "WAITING_FOR_CLOUD" # Waiting for API parameters display_update_queue = None
COOKING = "COOKING" # Microwave is active
DONE = "DONE" # Finished/Stopped, waiting for dish removal 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 # Global state trackers
microwave_states = {"2": MicrowaveState.IDLE} microwave_states = {"2": MicrowaveState("2", on_change_callback=on_microwave_change)}
button_state = False 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 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 # Async synchronization trackers for MQTT IR sensors responses
ir_data_cache = {} # mw_id -> dict of IR readings ir_data_cache = {} # mw_id -> dict of IR readings
ir_data_events = {} # mw_id -> asyncio.Event() ir_data_events = {} # mw_id -> asyncio.Event()
# --- HARDWARE & MQTT SETUP ---
# --- HARDWARE SETUP ---
lora = get_lora() lora = get_lora()
lora.configure() lora.configure()
# Local MQTT Broker (ESP32 IR & Sensors)
mqtt_client = get_mqtt_client( mqtt_client = get_mqtt_client(
host="192.168.50.1", host="192.168.50.1",
client_id="smartwave-orchestrateur-" + DEVICE_ID, 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_SENSOR, qos=config.MQTT_QOS)
mqtt_client.subscribe(config.MQTT_TOPIC_HELLO, 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"): if hasattr(mqtt_client._client, "loop_start"):
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(): async def lora_listener_task():
"""Polls LoRa and pushes to the async queue.""" """Polls LoRa and pushes to the async queue."""
print("[LoRa] Async listener started.") print("[LoRa] Async listener started.")
@@ -102,8 +203,8 @@ async def lora_listener_task():
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
async def mqtt_listener_task(): async def mqtt_listener_task():
"""Polls MQTT cache and pushes to the async queue.""" """Polls local MQTT cache and pushes to the async queue."""
print("[MQTT] Async listener started.") print("[MQTT Local] Async listener started.")
while True: while True:
message = mqtt_client.get_message() message = mqtt_client.get_message()
if message: if message:
@@ -124,19 +225,264 @@ async def mqtt_listener_task():
}) })
await asyncio.sleep(0.1) 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(): def button_callback():
"""Button physical interrupt callback.""" """Button physical interrupt callback."""
global button_state global button_state
if microwave_states.get("2") == MicrowaveState.COOKING: 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'.") 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: else:
button_state = not button_state button_state = not button_state
print(f"[Button] Defrost state toggled to: {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}, max_retries=5)
button.set_callback(button_callback) button.set_callback(button_callback)
button.start_button_monitoring_thread() 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 --- # --- HARDWARE CONTROLLERS ---
def _stop_hardware(microwave_id: str): def _stop_hardware(microwave_id: str):
print(f"[{microwave_id}] /!\\ Emergency stop issued to hardware.") print(f"[{microwave_id}] /!\\ Emergency stop issued to hardware.")
@@ -158,6 +504,16 @@ def read_local_sensors(microwave_id, initial_dish_height):
if temp is not None: if temp is not None:
sensor_data["temperature"] = temp sensor_data["temperature"] = temp
sensor_data["humidity"] = hum 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: except Exception as e:
log(f"[{microwave_id}] DHT read warning: {e}") log(f"[{microwave_id}] DHT read warning: {e}")
@@ -172,7 +528,7 @@ def read_local_sensors(microwave_id, initial_dish_height):
async def handle_new_dish(microwave_id, detected_height): async def handle_new_dish(microwave_id, detected_height):
"""Triggered when a new dish is placed inside.""" """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...") 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 # 1. Setup synchronization event and clear previous cache for this microwave
@@ -192,34 +548,78 @@ async def handle_new_dish(microwave_id, detected_height):
# 4. Wait for local sensors to finish reading # 4. Wait for local sensors to finish reading
sensors_data = await sensor_task 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 # 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.") print(f"[{microwave_id}] Dish removed during sensor read. Aborting.")
ir_data_events.pop(microwave_id, None) ir_data_events.pop(microwave_id, None)
return return
# 5. Wait for MQTT IR data (if it already arrived, event.wait() returns instantly) # 5. Wait for MQTT IR data
try: try:
await asyncio.wait_for(event.wait(), timeout=10.0) await asyncio.wait_for(event.wait(), timeout=10.0)
ir_payload = ir_data_cache.get(microwave_id, {}) ir_payload = ir_data_cache.get(microwave_id, {})
sensors_data["ir_initial_temp"] = ir_payload.get("dish_temp") sensors_data["ir_initial_temp"] = ir_payload.get("dish_temp")
sensors_data["ir_ambient_temp"] = ir_payload.get("ambient_temp") sensors_data["ir_ambient_temp"] = ir_payload.get("ambient_temp")
print(f"[{microwave_id}] IR data synchronized successfully: {ir_payload}") print(f"[{microwave_id}] IR data synchronized successfully: {ir_payload}")
# 6. Dispatch cloud request task
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
except asyncio.TimeoutError: 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_initial_temp"] = None
sensors_data["ir_ambient_temp"] = None sensors_data["ir_ambient_temp"] = None
finally: finally:
ir_data_events.pop(microwave_id, None) ir_data_events.pop(microwave_id, None)
# 6. Dispatch cloud request task # === API CALLS ===
asyncio.create_task(request_cloud_cooking_plan(microwave_id, sensors_data))
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): async def request_cloud_cooking_plan(microwave_id, sensors_data):
"""Sends all data to the cloud with up to 3 retries (30s interval).""" """Sends all data to the cloud with up to 3 retries (330s timeout for AI generation)."""
global cloud_alert global cloud_alert, alert_manager
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD microwave_states[microwave_id].set_state(MicrowaveState.WAITING_FOR_CLOUD)
URL = "https://smartwave.matthiasg.dev/cooking-params" URL = "https://smartwave.matthiasg.dev/cooking-params"
# Format image # 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...") print(f"[{microwave_id}] Requesting cooking plan from cloud app...")
max_retries = 3 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): 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.") print(f"[{microwave_id}] Dish removed or state changed. Aborting API request.")
microwave_states[microwave_id].set_state(MicrowaveState.IDLE)
return return
try: try:
print(f"[{microwave_id}] Connection attempt {attempt}/{max_retries}...") 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, timeout=30)
# Abort if state changed (e.g. user removed dish while waiting for wifi) response = await asyncio.to_thread(
if microwave_states.get(microwave_id) != MicrowaveState.WAITING_FOR_CLOUD: 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.") print(f"[{microwave_id}] Dish removed during API request. Discarding API plan.")
microwave_states[microwave_id].set_state(MicrowaveState.IDLE)
return return
response.raise_for_status() 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_time = plan.get("cook_time_seconds")
c_power = plan.get("effective_power_watts") c_power = plan.get("effective_power_watts")
c_temp = plan.get("target_temp") c_temp = plan.get("target_temp")
if c_time is None or c_power is None or c_temp is None: if c_time is None or c_power is None or c_temp is None:
print(f"[{microwave_id}] ❌ Invalid plan received: {response.json()}") print(f"[{microwave_id}] Invalid plan received: {res_json}")
microwave_states[microwave_id] = MicrowaveState.DONE microwave_states[microwave_id].set_state(MicrowaveState.DONE)
return return
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W") 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].set_state(MicrowaveState.COOKING)
microwave_states[microwave_id] = MicrowaveState.COOKING
if config.DEBUG: if config.DEBUG_SHORTER_COOKING_PLAN:
c_time = min(c_time, 10) # Limit to 10s for debug c_time = 20 # Set to 20s for debug
c_temp = min(c_temp, 50) # Limit to 50°C for debug c_temp = 50 # Set to 50°C for debug
mqtt_client.publish( mqtt_client.publish(
config.MQTT_TOPIC_COOKING, config.MQTT_TOPIC_COOKING,
payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp), payloads.mqtt_cooking_config(microwave_id, c_time, c_power, c_temp),
qos=config.MQTT_QOS qos=config.MQTT_QOS
) )
microwave_states[microwave_id].set_cooking_start_time(time.time())
return return
except Exception as e: except Exception as e:
# Print stacktrace
import traceback
traceback.print_exc()
print(f"[{microwave_id}] Cloud API Error (Attempt {attempt}/{max_retries}): {e}") print(f"[{microwave_id}] Cloud API Error (Attempt {attempt}/{max_retries}): {e}")
if attempt < max_retries: if attempt < max_retries:
print(f"[{microwave_id}] Retrying in {retry_delay_seconds} seconds...") 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): 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.") print(f"[{microwave_id}] State changed during retry wait. Aborting retries.")
return return
await asyncio.sleep(1) await asyncio.sleep(1)
# Executed only if all 3 retries failed # Executed only if all 3 retries failed
print(f"[{microwave_id}] All 3 cloud retries failed. Setting global alert flag.") print(f"[{microwave_id}] All cloud retries failed. Setting global alert flag.")
cloud_alert = True set_cloud_alert(True)
microwave_states[microwave_id] = MicrowaveState.DONE microwave_states[microwave_id].set_state(MicrowaveState.DONE)
# --- MAIN LOGIC TASKS --- # --- 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) async def handle_telemetry_request(endpoint: str, do_timeout=True):
sensor_topic = to_str(config.MQTT_TOPIC_SENSOR) """
Task to gather telemetry data (including structured systemd logs)
if topic == hello_topic: and post it to the target HTTP endpoint.
if data.get("id_orchestrator") != DEVICE_ID: """
component_id = data.get("id_microwave") print(f"[Telemetry] Starting telemetry collection for endpoint: {endpoint}")
component_type = data.get("type", deviceTypes.DEVICE_TYPES["MICROWAVE"]) try:
# 1. Fetch systemd logs asynchronously
if component_id: logs_list = await get_systemd_logs(lines=200, service_name="smartwave")
print(f"[MQTT] Hello received from '{component_id}' ({component_type}). Updating DB & sending ACK.")
# Offload DB insertion to async thread execution pool # 2. Unpack temperature and humidity
await asyncio.to_thread(save_connected_component, component_id, component_type) ambient_temp, ambient_humidity = temp_hum.get_temperature_and_humidity_with_retry()
mqtt_client.publish( # Unpack microwave states
config.MQTT_TOPIC_HELLO, microwave_states_snapshot = {mw_id: mw_state.to_dict() for mw_id, mw_state in microwave_states.items()}
payloads.mqtt_hello_ack(DEVICE_ID, component_id),
qos=config.MQTT_QOS # 3. Build the telemetry payload (returns a JSON string)
) telemetry_payload = payloads.telemetry_payload(
device_id=DEVICE_ID,
elif topic == sensor_topic: microwave_states=microwave_states_snapshot,
mw_id = str(data.get("id_microwave")) button_state=button_state,
print(f"[MQTT] Sensor data received for microwave {mw_id}: {data}") 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 # 4. Wait for he microwave turn to send the telemetry data to the cloud endpoint
ir_data_cache[mw_id] = data if do_timeout:
if mw_id in ir_data_events: do_timeout = int(DEVICE_ID.split("_")[-1]) * config.TELEMETRY_SEND_INTERVAL
ir_data_events[mw_id].set() 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): async def get_filtered_dish_height(samples=3, delay=0.04):
"""Reads ultrasonic sensor multiple times and returns the median, discarding invalid zeros.""" """Reads ultrasonic sensor multiple times and returns the median, discarding invalid zeros."""
@@ -364,20 +809,23 @@ async def monitor_dish_height_task():
while True: while True:
dist = await get_filtered_dish_height() 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: if dist is not None:
# Hysteresis Thresholds: # Hysteresis Thresholds:
# - Must be > 2.5 cm to detect dish insertion # - Must be > 1.5 cm to detect dish insertion
# - Must be < 1.2 cm to detect dish removal # - Must be < 0.8 cm to detect dish removal
if dist > 2.5: if dist > 2.0:
consecutive_present += 1 consecutive_present += 1
consecutive_absent = 0 consecutive_absent = 0
elif dist < 1.2: elif dist < 1.0:
consecutive_absent += 1 consecutive_absent += 1
consecutive_present = 0 consecutive_present = 0
else: else:
# Dead-zone (1.2cm to 2.5cm) -> Noise buffer # Dead-zone (2.0cm to 1.0cm) -> Noise buffer
consecutive_present = 0 consecutive_present = 0
consecutive_absent = 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: elif consecutive_absent >= REQUIRED_STABLE_READS and current_state != MicrowaveState.IDLE:
consecutive_absent = 0 consecutive_absent = 0
print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.") print(f"\n[{mw_id}] Dish Removed! Resetting state to IDLE.")
microwave_states[mw_id] = MicrowaveState.IDLE microwave_states[mw_id].set_state(MicrowaveState.IDLE)
cloud_alert = False # Reset error alert on dish removal set_cloud_alert(False)
if current_state == MicrowaveState.COOKING: if current_state == MicrowaveState.COOKING:
_stop_hardware(mw_id) _stop_hardware(mw_id)
@@ -399,23 +847,44 @@ async def monitor_dish_height_task():
ir_data_cache.pop(mw_id, None) ir_data_cache.pop(mw_id, None)
ir_data_events.pop(mw_id, None) ir_data_events.pop(mw_id, None)
await asyncio.sleep(0.3) await asyncio.sleep(0.7)
# --- BOOTSTRAP --- # --- BOOTSTRAP ---
async def main(): async def main():
global async_event_queue global async_event_queue, display_update_queue
print("🚀 Orchestrateur Asyncio prêt. Lancement des tâches...") 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 # Initialize SQLite database table
init_db() init_db()
# Start mDNS Display discovery
await display_manager.start()
save_connected_component(DEVICE_ID, "orchestrateur")
async_event_queue = asyncio.Queue() async_event_queue = asyncio.Queue()
display_update_queue = asyncio.Queue()
await asyncio.gather( await asyncio.gather(
lora_listener_task(), lora_listener_task(),
mqtt_listener_task(), mqtt_listener_task(),
cloud_mqtt_listener_task(),
process_messages_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__": if __name__ == "__main__":
@@ -424,6 +893,11 @@ if __name__ == "__main__":
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nArrêt manuel.") print("\nArrêt manuel.")
finally: finally:
rfid.close() # <-- ADDED CLEANUP HERE
asyncio.run(display_manager.stop())
if hasattr(mqtt_client._client, "loop_stop"): if hasattr(mqtt_client._client, "loop_stop"):
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 allow_anonymous true
listener 8884 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 certfile /mosquitto/certs/server.crt
keyfile /mosquitto/certs/server.key keyfile /mosquitto/certs/server.key
require_certificate false 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 paho-mqtt>=1.6,<3
pyserial>=3.5,<4 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 # picamera2>=0.3.36,<4 # → Installed with apt install python3-picamera2
# OpenCV # OpenCV
# sudo apt install -y python3-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.temp_hum as temp_hum
import sensors.button as button import sensors.button as button
import sensors.gps as gps 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 grovepi
import time import time
import threading import threading
from sensors.lock import grove_lock from sensors.lock import safe_grove_access
from shared.logging import log from shared.logging import log
button = 2 button = 2
button_switch_state = 0
grovepi.pinMode(button, "INPUT") grovepi.pinMode(button, "INPUT")
button_callback = None button_callback = None
def read_button_state(): def read_button_state():
# Increase timeout slightly so the button thread can wait for long I2C sensor reads to finish # Attempt to acquire the lock with a 0.2s timeout
if not grove_lock.acquire(timeout=0.2): with safe_grove_access(timeout=0.2) as acquired:
return None if not acquired:
try: return None
return grovepi.digitalRead(button)
except Exception as e: try:
log(f"BTN Error: {e}") val = grovepi.digitalRead(button)
return None # Force strict binary output (0 or 1)
finally: return 1 if val == 1 else 0
grove_lock.release() except Exception as e:
log(f"BTN Error: {e}")
return None
def monitor_button(): def monitor_button():
global button_switch_state last_stable_state = 0
last_button_state = button_switch_state candidate_state = 0
consecutive_count = 0
REQUIRED_CONSECUTIVE_READS = 3
DEBOUNCE_COOLDOWN = 0.3
last_trigger_time = 0
while True: while True:
current_state = read_button_state() current_state = read_button_state()
if current_state is not None: if current_state is not None:
# Rising edge detection (0 -> 1 transition) if current_state == candidate_state:
if current_state == 1 and last_button_state == 0: consecutive_count += 1
if button_callback: else:
button_callback() candidate_state = current_state
last_button_state = current_state consecutive_count = 1
time.sleep(0.02) # Fast 20ms poll when lock is clear
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: else:
# Lock was busy; retry quickly without updating last_button_state consecutive_count = 0
time.sleep(0.01) time.sleep(0.01)
def start_button_monitoring_thread(): 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 from picamera2 import Picamera2, Preview
import time import time
picam2 = Picamera2() 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.configure(camera_config)
picam2.start() picam2.start()
+14 -13
View File
@@ -2,7 +2,7 @@ import serial
import time import time
import threading import threading
from shared.logging import log 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: def calculate_nmea_checksum(line: str) -> bool:
"""Validates standard NMEA 0183 sentence checksum ($...*HH).""" """Validates standard NMEA 0183 sentence checksum ($...*HH)."""
@@ -34,25 +34,27 @@ class GROVEGPS:
self.longitude = -1.0 self.longitude = -1.0
def read(self): def read(self):
"""Reads the latest GGA sentence from serial, thread-safely.""" """Reads the freshest GGA sentence from serial, thread-safely."""
with serial_lock: # Allow sufficient time for the serial reads (readline can block up to timeout=1s per line)
# 1. Flush accumulated stale data in the UART buffer with safe_serial_access(timeout=2.0) as acquired:
if self.ser.in_waiting > 0: if not acquired:
self.ser.reset_input_buffer() log("GPS: Serial lock acquisition timed out")
return False
# 2. Try reading up to 15 lines to catch the freshest GGA sentence # 1. Flush accumulated stale buffer data
for _ in range(5): 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() raw_bytes = self.ser.readline()
try: try:
line = raw_bytes.decode('utf-8', errors='ignore').strip() line = raw_bytes.decode('utf-8', errors='ignore').strip()
# log(f"GPS: Read line: {line}") log(f"GPS: Read line: {line}")
except Exception: except Exception:
continue continue
# Supports both $GPGGA and modern $GNGGA sentences
if (line.startswith('$GPGGA') or line.startswith('$GNGGA')) and calculate_nmea_checksum(line): if (line.startswith('$GPGGA') or line.startswith('$GNGGA')) and calculate_nmea_checksum(line):
if self.parse_gga(line): return self.parse_gga(line)
return True
return False return False
def parse_gga(self, line): def parse_gga(self, line):
@@ -118,5 +120,4 @@ def get_gps_data():
else: else:
log(f"GPS: No valid fix or data available. Satellites: {gps.satellites}, Quality: {gps.quality}") 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 return None
+27 -1
View File
@@ -1,5 +1,31 @@
import threading import threading
from contextlib import contextmanager
# Dedicated lock for I2C bus access (used by GrovePi sensors) # Dedicated lock for I2C bus access (used by GrovePi sensors)
grove_lock = threading.Lock() grove_lock = threading.Lock()
# Dedicated lock for UART/Serial port access # 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(): def get_temperature_and_humidity():
with grove_lock: with grove_lock:
[temp,humidity] = grovepi.dht(sensor,blue) try:
if math.isnan(temp) == False and math.isnan(humidity) == False: [temp, humidity] = grovepi.dht(sensor, blue)
return temp, humidity if not math.isnan(temp) and not math.isnan(humidity) and temp > -40 and humidity >= 0:
else: return temp, humidity
print("Error reading from DHT sensor") except Exception:
return None, None 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() temp, humidity = get_temperature_and_humidity()
if temp is not None and humidity is not None: if temp is not None and humidity is not None:
return temp, humidity 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 return None, None
+13 -22
View File
@@ -1,33 +1,24 @@
import grovepi import grovepi
from sensors.lock import grove_lock from sensors.lock import safe_grove_access
from shared import config from shared import config
# Connect the Grove Ultrasonic Ranger to digital port D4
# SIG,NC,VCC,GND
ULTRASONIC_RANGER_PORT = 4 ULTRASONIC_RANGER_PORT = 4
def read_ultrasonic_ranger(ultrasonic_ranger=ULTRASONIC_RANGER_PORT): def read_ultrasonic_ranger(ultrasonic_ranger=ULTRASONIC_RANGER_PORT):
if not grove_lock.acquire(timeout=1.0): with safe_grove_access(timeout=1.5) as acquired:
print("Ultrasonic: Lock acquisition timed out") if not acquired:
return None return None
try: try:
return grovepi.ultrasonicRead(ultrasonic_ranger) return grovepi.ultrasonicRead(ultrasonic_ranger)
except Exception as e: except Exception as e:
print(f"Error: {e}") print(f"Ultrasonic read error: {e}")
return None return None
finally:
grove_lock.release()
def get_dish_height(): def get_dish_height():
"""Returns the height of the dish in centimeters.""" """Returns the height of the dish in centimeters."""
distance = read_ultrasonic_ranger() distance = read_ultrasonic_ranger()
if distance is not None: 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 dish_height = config.COOKING_COMPARTMENT_HEIGHT - distance
return max(dish_height, 0) # Ensure height is not negative return max(dish_height, 0)
else: return None
return None
+1
View File
@@ -9,6 +9,7 @@ User=pi
Group=pi Group=pi
WorkingDirectory=/home/pi/SmartWave WorkingDirectory=/home/pi/SmartWave
EnvironmentFile=-/etc/default/smartwave EnvironmentFile=-/etc/default/smartwave
Environment="PYTHONUNBUFFERED=1"
ExecStart=/bin/sh /home/pi/SmartWave/orchestrateur/launch.sh /home/pi/SmartWave/orchestrateur/main.py ExecStart=/bin/sh /home/pi/SmartWave/orchestrateur/launch.sh /home/pi/SmartWave/orchestrateur/main.py
Restart=on-failure Restart=on-failure
RestartSec=5 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.payloads as payloads
import shared.cookingState as cookingState import shared.cookingState as cookingState
import shared.safeQueue as safeQueue import shared.safeQueue as safeQueue
import shared.alerts as alerts
import shared.microwave_state as microwave_state
try: try:
import shared.lora_device as lora_device import shared.lora_device as lora_device
except ImportError: except (ImportError, SyntaxError):
pass # No need pass # No need
try: try:
import shared.uart_comm as uart_comm import shared.uart_comm as uart_comm
except ImportError: except (ImportError, SyntaxError):
pass # No need as we are on the RPI pass # No need as we are on the RPI
import shared.sensors 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=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
LORA_HEARTBEAT_INTERVAL = 30 LORA_HEARTBEAT_INTERVAL = 30
@@ -11,8 +21,15 @@ MQTT_TOPIC_COOKING = b"smartwave/cooking"
MQTT_KEEPALIVE = 30 MQTT_KEEPALIVE = 30
USE_TLS = True USE_TLS = True
MQTT_QOS = 1 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. # Long because messages are stored into the broker and will be sent when the orchestrator is back online.
MQTT_HELLO_INTERVAL = 30 MQTT_HELLO_INTERVAL = 30
# Microwave Model # 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
+72 -30
View File
@@ -3,6 +3,7 @@ import time
class CookingState: class CookingState:
TEMPERATURE_TOLERANCE = 1.0 TEMPERATURE_TOLERANCE = 1.0
MIN_SIGNIFICANT_HEATING_RATE = 0.05 # °C/s threshold to consider valid heating
def __init__(self, cook_time: int, power_level: int, target_temp: float, temperature_provider=None, on_state_change=None, on_refresh=None): def __init__(self, cook_time: int, power_level: int, target_temp: float, temperature_provider=None, on_state_change=None, on_refresh=None):
self.cook_time = cook_time self.cook_time = cook_time
@@ -27,6 +28,9 @@ class CookingState:
self._last_refresh_signature = None self._last_refresh_signature = None
self._stirred = False self._stirred = False
# Moving average filter for heating rate (°C / sec)
self._smoothed_heating_rate = 0.0
def set_temperature_provider(self, temperature_provider): def set_temperature_provider(self, temperature_provider):
self.temperature_provider = temperature_provider self.temperature_provider = temperature_provider
@@ -35,7 +39,7 @@ class CookingState:
def set_refresh_callback(self, callback): def set_refresh_callback(self, callback):
self.on_refresh = callback self.on_refresh = callback
def set_pause_callback(self, callback): def set_pause_callback(self, callback):
self.on_pause = callback self.on_pause = callback
@@ -45,9 +49,9 @@ class CookingState:
self.paused = True self.paused = True
self._pause_started_at = time.time() self._pause_started_at = time.time()
# self._notify_refresh(force=True)
if self.on_pause: if self.on_pause:
self.on_pause(self) self.on_pause(self)
def unpause(self): def unpause(self):
if not self.paused: if not self.paused:
return return
@@ -56,16 +60,15 @@ class CookingState:
if self._pause_started_at is not None: if self._pause_started_at is not None:
self._paused_duration += now - self._pause_started_at self._paused_duration += now - self._pause_started_at
# self._pause_started_at = None
self.paused = False self.paused = False
# self._notify_refresh(force=True)
def toggle_pause(self): def toggle_pause(self):
if self.paused: if self.paused:
self.unpause() self.unpause()
else: else:
self.pause() self.pause()
self.on_pause(self) if self.on_pause:
self.on_pause(self)
def set_state(self, state): def set_state(self, state):
if self.state == state: if self.state == state:
@@ -90,20 +93,56 @@ class CookingState:
def get_remaining_time_estimation(self) -> float: def get_remaining_time_estimation(self) -> float:
elapsed_time = self.get_elapsed_time() elapsed_time = self.get_elapsed_time()
# 1. Base timer remaining based on standard cook time
timer_remaining = max(0.0, float(self.cook_time) - elapsed_time) timer_remaining = max(0.0, float(self.cook_time) - elapsed_time)
if self.current_dish_temp is None: # If temperature is unavailable or already met target, rely on standard timer
if self.current_dish_temp is None or self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE):
return timer_remaining return timer_remaining
if self.current_dish_temp >= self.target_temp: # 2. Prevent hitting 00:00 before stirring trigger:
return timer_remaining # If we passed half cook_time and temp is far from target, extend expected base time to 1.25x cook_time
temp_progress = max(0.0, self.current_dish_temp) / max(1.0, self.target_temp)
if elapsed_time > (self.cook_time * 0.5) and temp_progress < 0.8:
adjusted_cook_time = self.cook_time * 1.25
timer_remaining = max(0.0, adjusted_cook_time - elapsed_time)
# 3. Estimate using heating rate
heating_rate = self._estimate_heating_rate() heating_rate = self._estimate_heating_rate()
if heating_rate <= 0: temp_needed = self.target_temp - self.current_dish_temp
return timer_remaining
target_remaining = (self.target_temp - self.current_dish_temp) / heating_rate if heating_rate > 0.01:
return max(timer_remaining, max(0.0, target_remaining)) rate_based_remaining = temp_needed / heating_rate
else:
# If flat/slow, project remaining time based on remaining missing temperature fraction
temp_ratio = max(0.1, temp_needed / self.target_temp)
rate_based_remaining = max(timer_remaining, self.cook_time * temp_ratio * 1.25)
# 4. Strict dynamic cap: Never exceed maximum possible execution window (2.0x cook_time total)
max_possible_remaining = max(0.0, (self.cook_time * 2.0) - elapsed_time)
bounded_remaining = min(rate_based_remaining, max_possible_remaining)
# Return the larger of the adjusted timer or the bounded prediction
return max(timer_remaining, bounded_remaining)
def _estimate_heating_rate(self) -> float:
"""Calculates heating rate in °C/sec over time interval."""
if self._last_temperature_sample is None or self.current_dish_temp is None:
return 0.0
last_time, last_temp = self._last_temperature_sample
now = time.time()
delta_time = now - last_time
if delta_time < 0.8: # Skip micro-ticks
return 0.0
delta_temp = self.current_dish_temp - last_temp
if delta_temp <= 0:
return 0.0
return delta_temp / delta_time
def _read_temperatures(self): def _read_temperatures(self):
if self.temperature_provider is None: if self.temperature_provider is None:
@@ -118,27 +157,29 @@ class CookingState:
raise ValueError("temperature_provider must return a pair: (dish_temp, ambient_temp)") raise ValueError("temperature_provider must return a pair: (dish_temp, ambient_temp)")
def _estimate_heating_rate(self): def _update_heating_rate(self):
"""Calculates instantaneous rate and updates the Exponential Moving Average."""
now = time.time()
if self._last_temperature_sample is None: if self._last_temperature_sample is None:
return 0.0 self._last_temperature_sample = (now, self.current_dish_temp)
return
last_time, last_temp = self._last_temperature_sample last_time, last_temp = self._last_temperature_sample
now = time.time()
current_temp = self.current_dish_temp
if current_temp is None:
return 0.0
delta_time = now - last_time delta_time = now - last_time
if delta_time <= 0:
return 0.0
return (current_temp - last_temp) / delta_time if delta_time <= 0.5 or self.current_dish_temp is None:
return
instant_rate = (self.current_dish_temp - last_temp) / delta_time
self._last_temperature_sample = (now, self.current_dish_temp)
# Exponential Moving Average (EMA) - alpha smooths out sensor noise
alpha = 0.2
self._smoothed_heating_rate = (alpha * instant_rate) + ((1.0 - alpha) * self._smoothed_heating_rate)
def _notify_state_change(self): def _notify_state_change(self):
if self.on_state_change is None: if self.on_state_change is None:
return return
self.on_state_change(self) self.on_state_change(self)
def _notify_refresh(self, force=False): def _notify_refresh(self, force=False):
@@ -177,21 +218,22 @@ class CookingState:
now = time.time() now = time.time()
elapsed_time = self.get_elapsed_time() elapsed_time = self.get_elapsed_time()
self.estimated_remaining_time = self.get_remaining_time_estimation()
if self.current_dish_temp is not None: if self.current_dish_temp is not None:
if elapsed_time < (self.cook_time / 2.0) and self.current_dish_temp >= self.target_temp and (self._paused_duration == None or self._paused_duration < 5): # If the dish is heating too fast, we require stirring self._update_heating_rate()
if elapsed_time < (self.cook_time / 2.0) and self.current_dish_temp >= self.target_temp and (self._paused_duration is None or self._paused_duration < 5):
self.state = CookingStates.STIRRING_REQUIRED self.state = CookingStates.STIRRING_REQUIRED
self.pause() self.pause()
elif elapsed_time >= self.cook_time and self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE): elif elapsed_time >= self.cook_time and self.current_dish_temp >= (self.target_temp - self.TEMPERATURE_TOLERANCE):
self.state = CookingStates.DONE self.state = CookingStates.DONE
elif elapsed_time >= self.cook_time * 1.25 and (self._paused_duration == None or self._paused_duration < 5): # If the dish is not heating up elif elapsed_time >= self.cook_time * 1.25 and (self._paused_duration is None or self._paused_duration < 5):
self.state = CookingStates.STIRRING_REQUIRED self.state = CookingStates.STIRRING_REQUIRED
self.pause() self.pause()
elif self._pause_started_at != None and (self._pause_started_at + self._paused_duration) < (now - (self.cook_time * 0.75)): # If the dish had to be pause and it's been a long time, we stop the cooking elif self._pause_started_at is not None and (self._pause_started_at + self._paused_duration) < (now - (self.cook_time * 0.75)):
self.state = CookingStates.DONE self.state = CookingStates.DONE
self._last_temperature_sample = (now, self.current_dish_temp) self.estimated_remaining_time = self.get_remaining_time_estimation()
if self.state != previous_state: if self.state != previous_state:
self._notify_state_change() self._notify_state_change()
+2 -2
View File
@@ -1,6 +1,6 @@
from shared.config import DEBUG from shared.config import DEBUG_MESSAGES
def log(message): def log(message):
"""Log a message to the console if DEBUG is enabled.""" """Log a message to the console if DEBUG is enabled."""
if DEBUG: if DEBUG_MESSAGES:
print(f"\n{message}") print(f"\n{message}")
+144 -106
View File
@@ -20,11 +20,19 @@ else:
class BaseLoraDevice: class BaseLoraDevice:
"""Base class providing automatic ACK generation, retries, and duplicate filtering.""" """Base class providing automatic ACK generation, retries, and duplicate filtering."""
MAX_PAYLOAD_SIZE = 222 # Maximum safe LoRa payload size in bytes
def __init__(self): def __init__(self):
self.processed_msg_ids = set() self.processed_msg_ids = set()
self.received_acks = set() self.received_acks = set()
self.pending_rx_queue = [] self.pending_rx_queue = []
self.default_group = 2 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): def _generate_msg_id(self):
return random.getrandbits(16) return random.getrandbits(16)
@@ -33,10 +41,9 @@ class BaseLoraDevice:
"""Sends an immediate acknowledgement packet back to the sender.""" """Sends an immediate acknowledgement packet back to the sender."""
print(f"[ReliableLoRa] -> Triggering ACK send for msg_id: {ack_id}") print(f"[ReliableLoRa] -> Triggering ACK send for msg_id: {ack_id}")
if IS_MICROPYTHON: if IS_MICROPYTHON:
time.sleep_ms(100) time.sleep_ms(50)
else: else:
# Give LA66 chip time to finish logging RSSI and reset RF frontend time.sleep(0.05) # Reduced delay to fit ESP32 RX window
time.sleep(0.25)
ack_payload = {"_type": "_ack", "_ack_id": ack_id} ack_payload = {"_type": "_ack", "_ack_id": ack_id}
self.send(ack_payload) self.send(ack_payload)
@@ -74,53 +81,51 @@ class BaseLoraDevice:
return packet 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.""" """Sends a payload and listens in a single continuous RX window for the ACK."""
lock = getattr(self, 'lock', None)
if isinstance(payload, dict): with self.tx_lock:
payload = dict(payload) lock = getattr(self, 'lock', None)
else:
payload = {"data": payload} if isinstance(payload, dict):
payload = dict(payload)
else:
payload = {"data": payload}
msg_id = self._generate_msg_id() msg_id = self._generate_msg_id()
payload["_msg_id"] = 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): for attempt in range(max_retries):
log(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}") log(f"[ReliableLoRa] Attempt {attempt + 1}/{max_retries} transmitting msg_id {msg_id}")
self.send(payload) self.send(payload)
# 1. Open a single continuous RX window for the full timeout duration timeout_ms = int(ack_timeout * 1000)
# (Defaulted to 3.0s to account for LA66 UART + transmission time) 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() if lock: lock.acquire()
try: try:
filtered_packet = self._process_incoming_packet(packet) if msg_id in self.received_acks:
if filtered_packet: self.received_acks.remove(msg_id)
self.pending_rx_queue.append(filtered_packet) log(f"[ReliableLoRa] === ACK received for msg_id {msg_id} on attempt {attempt + 1} ===")
return True
finally: finally:
if lock: lock.release() if lock: lock.release()
# 3. Check if matching ACK was received log(f"[ReliableLoRa] Attempt {attempt + 1} timed out waiting for ACK for msg_id {msg_id}")
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}") 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): def receive_reliable(self, timeout_ms=1000):
"""Receives a packet, automatically sending ACKs and filtering duplicate retries.""" """Receives a packet, automatically sending ACKs and filtering duplicate retries."""
@@ -226,6 +231,13 @@ if IS_MICROPYTHON:
payload = payload.encode('utf-8') payload = payload.encode('utf-8')
paquet_physique = bytes([group]) + payload 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: try:
self.lora.send(paquet_physique) self.lora.send(paquet_physique)
except Exception as e: except Exception as e:
@@ -288,21 +300,31 @@ else:
self.ser.reset_output_buffer() self.ser.reset_output_buffer()
self.lock = threading.Lock() self.lock = threading.Lock()
# Initial configuration self._emergency_rx_buffer = []
self.configure(freq=868.1, sf=7, bw=125) self.configure(freq=868.1, sf=7, bw=125)
def _send_at_cmd(self, cmd, wait_time=0.3): def _send_at_cmd(self, cmd, timeout=1.5):
"""Sends AT command, draining unread serial noise first.""" """Sends AT command and safely handles async incoming packets."""
# Drain any lingering lines (like 'Rssi= -4' or incoming data)
if self.ser.in_waiting > 0:
self.ser.read_all()
self.ser.write(f"{cmd}\r\n".encode('utf-8')) self.ser.write(f"{cmd}\r\n".encode('utf-8'))
time.sleep(wait_time)
start = time.time()
resp = "" resp = ""
while self.ser.in_waiting > 0: while (time.time() - start) < timeout:
resp += self.ser.readline().decode('utf-8', errors='ignore') 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 return resp
def configure(self, freq=868.1, sf=7, bw=125): def configure(self, freq=868.1, sf=7, bw=125):
@@ -313,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) # 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" 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 # Fallback standalone commands
self._send_at_cmd("AT+SYNCWORD=18", wait_time=0.1) self._send_at_cmd("AT+SYNCWORD=18", timeout=0.1)
self._send_at_cmd("AT+PRECV=65535", wait_time=0.1) self._send_at_cmd("AT+PRECV=65535", timeout=0.1)
self.ser.reset_input_buffer() 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.""" """Encodes payload into a HEX string and transmits via 4-parameter AT+SEND."""
with self.lock: with self.lock:
if group is None: if group is None:
@@ -332,14 +354,20 @@ else:
if isinstance(payload, str): if isinstance(payload, str):
payload = payload.encode('utf-8') 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() hex_payload = payload.hex()
log(f"[RPi LoRa Serial] Transmitting HEX string: {hex_payload}") log(f"[RPi LoRa Serial] Transmitting HEX string: {hex_payload}")
# Format: AT+SEND=<group>,<payload_string>,<confirm>,<retries> # 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}") log(f"[RPi LoRa Serial] AT+SEND response: {resp}")
def receive_packet(self, timeout_ms=500): def receive_packet(self, timeout_ms=500):
@@ -349,64 +377,70 @@ else:
timeout_s = timeout_ms / 1000.0 timeout_s = timeout_ms / 1000.0
while (time.time() - start_time) < timeout_s: 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() line = self.ser.readline().decode('utf-8', errors='ignore').strip()
if line:
payload_bytes = None if line:
payload_bytes = None
# Robust parsing for LA66 response variants (+RECV:, +RCV=, +DRX:, HEX:, Data:)
if "+RECV:" in line: # Robust parsing for LA66 response variants (+RECV:, +RCV=, +DRX:, HEX:, Data:)
parts = line.split("+RECV:")[1].strip().split(",") if "+RECV:" in line:
hex_str = parts[2].strip() if len(parts) >= 3 else parts[0].strip() parts = line.split("+RECV:")[1].strip().split(",")
try: payload_bytes = bytes.fromhex(hex_str) 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 except ValueError: pass
elif "+RCV=" in line: elif "+DRX:" in line:
parts = line.split("+RCV=")[1].strip().split(",") parts = line.split("+DRX:")[1].strip().split(",")
if len(parts) >= 4: if len(parts) >= 2:
try: payload_bytes = bytes.fromhex(parts[3].strip()) try: payload_bytes = bytes.fromhex(parts[1].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)
except ValueError: pass except ValueError: pass
elif "Data:" in line: elif "(HEX:)" in line:
payload_bytes = line.split("Data:")[1].strip().encode('utf-8') 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: try:
if payload_bytes[0] in (0x7B, 0x5B): text = payload_clean.decode('utf-8').strip('\x00 \r\n\t')
group = self.default_group except UnicodeError:
payload_clean = payload_bytes.strip(b'\x00 \r\n\t') continue
elif len(payload_bytes) > 1:
group = payload_bytes[0] if text.startswith('{') or text.startswith('['):
payload_clean = payload_bytes[1:].strip(b'\x00 \r\n\t') decoded_text = text
else: elif text.lower().startswith('7b') or text.lower().startswith('5b'):
continue
try: try:
text = payload_clean.decode('utf-8').strip('\x00 \r\n\t') decoded_text = bytes.fromhex(text).decode('utf-8').strip('\x00 \r\n\t')
except UnicodeError: except Exception:
continue
if text.startswith('{') or text.startswith('['):
decoded_text = text decoded_text = text
elif text.lower().startswith('7b') or text.lower().startswith('5b'): else:
try: decoded_text = text
decoded_text = bytes.fromhex(text).decode('utf-8').strip('\x00 \r\n\t')
except Exception: try:
decoded_text = text parsed_json = json.loads(decoded_text)
else: return {"group": group, "data": parsed_json, "raw": False}
decoded_text = text except json.JSONDecodeError:
return {"group": group, "data": decoded_text, "raw": True}
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) time.sleep(0.01)
return None return None
@@ -423,5 +457,9 @@ def get_lora_device(port_or_pins=None):
class LoraCommands: class LoraCommands:
PING = "ping" PING = "ping"
MICROVAVE_STATE_UPDATE = "microwave_state_update"
COOKING_STATE_UPDATE = "cooking_state_update" COOKING_STATE_UPDATE = "cooking_state_update"
TOGGLE_PAUSE = "toggle_pause" COOKING_UPDATE = "cooking_update"
TOGGLE_PAUSE = "toggle_pause"
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
+50 -1
View File
@@ -1,5 +1,7 @@
from time import time from time import time
from shared.deviceTypes import DEVICE_TYPES from shared.deviceTypes import DEVICE_TYPES
from shared.alerts import Alert, AlertType
import shared.config as config
try: try:
@@ -39,10 +41,57 @@ def mqtt_sensor_data(id_microwave, dish_temp, ambient_temp):
"ambient_temp": ambient_temp "ambient_temp": ambient_temp
}) })
def lora_sensor_data(dish_temp, ambient_temp):
return as_json({
"dish_temp": dish_temp,
"ambient_temp": ambient_temp
})
def mqtt_cooking_config(id_microwave, cook_time, power_level, target_temp): def mqtt_cooking_config(id_microwave, cook_time, power_level, target_temp):
return as_json({ return as_json({
"id_microwave": id_microwave, "id_microwave": id_microwave,
"cook_time": cook_time, "cook_time": cook_time,
"power_level": power_level, "power_level": power_level,
"target_temp": target_temp "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
}
+3
View File
@@ -75,6 +75,8 @@ class RGBLED:
"""Sets the RGB color tuple (e.g., (255, 128, 0)).""" """Sets the RGB color tuple (e.g., (255, 128, 0))."""
if isinstance(rgb_tuple, (tuple, list)) and len(rgb_tuple) == 3: if isinstance(rgb_tuple, (tuple, list)) and len(rgb_tuple) == 3:
self._color = tuple(rgb_tuple) self._color = tuple(rgb_tuple)
# Automatically turn LED on unless color is OFF (0,0,0)
self._is_on = (self._color != self.OFF)
self._apply() self._apply()
else: else:
raise ValueError("Color must be a tuple of 3 integers: (R, G, B)") raise ValueError("Color must be a tuple of 3 integers: (R, G, B)")
@@ -144,6 +146,7 @@ class RGBLED:
if self._is_blinking: if self._is_blinking:
self._timer.deinit() self._timer.deinit()
self._is_blinking = False self._is_blinking = False
self.on() # Ensures LED remains ON steadily after blinking stops
def blink_toggle(self, interval_ms=500): def blink_toggle(self, interval_ms=500):
"""Toggles blinking state (starts if stopped, stops if active).""" """Toggles blinking state (starts if stopped, stops if active)."""
+3 -1
View File
@@ -97,4 +97,6 @@ class UARTCommand:
class UARTCommandType: class UARTCommandType:
"""Enumeration of known UART command types.""" """Enumeration of known UART command types."""
COOKING_PARAMS = "COOKING_PARAMS" COOKING_PARAMS = "COOKING_PARAMS"
COOKING_STATE_UPDATE = "COOKING_STATE_UPDATE" COOKING_STATE_UPDATE = "COOKING_STATE_UPDATE"
TEMPERATURE_REQUEST = "TEMPERATURE_REQUEST"
TEMPERATURE_RESPONSE = "TEMPERATURE_RESPONSE"