Compare commits
1391 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b68a53eb53 | |||
| d68d1ec53a | |||
| 9c15ece508 | |||
| fe222c0393 | |||
| 68cafe8cd0 | |||
| d01742678c | |||
| 31ae70b8fa | |||
| e7f13a5856 | |||
| a9ed367148 | |||
| 2505218385 | |||
| b5c35890af | |||
| 484d9ce21b | |||
| 9353527e1b | |||
| fd3ad63971 | |||
| 2e583649d0 | |||
| a3c509aa94 | |||
| f26a820b1d | |||
| 901e7f95e8 | |||
| 592d11aae2 | |||
| 30a0085f1d | |||
| b30d883974 | |||
| ea8f72f7ff | |||
| a3f2fa7b88 | |||
| 5100f55586 | |||
| 9d41ffbb59 | |||
| 517eb8cb77 | |||
| 9d72c88a28 | |||
| e1922d7a30 | |||
| c48d66d1b4 | |||
| fbea33e7cb | |||
| af26a01703 | |||
| 336d0d81ec | |||
| 076d17da18 | |||
| 5b4b99707a | |||
| db7b056cf4 | |||
| eb0512b3c0 | |||
| cafc2554de | |||
| 2b25d5cbad | |||
| 0a75322ad1 | |||
| 95776f5519 | |||
| 678aefd76e | |||
| 41a720f1f6 | |||
| 0b5d858187 | |||
| e65a25e526 | |||
| dbe2003d75 | |||
| a5f92ded37 | |||
| f7cf0a87cc | |||
| 902a21fc40 | |||
| eeaf87c7f2 | |||
| e6e6cb3b9a | |||
| 260240728a | |||
| 0e0e17b089 | |||
| efc14b4de0 | |||
| 6ed24b758d | |||
| 646eb09e1d | |||
| 1dd3e485a6 | |||
| 77cdd56641 | |||
| b26d94c967 | |||
| 98e01f4c5b | |||
| 32f245e6ef | |||
| bf50cb4acd | |||
| e778efa5b6 | |||
| 7d537998ca | |||
| daaf3d2158 | |||
| 2dfdcd39f1 | |||
| 5e568f59ba | |||
| ae5664dbb4 | |||
| e64d82ebb5 | |||
| c5fdf7f7e9 | |||
| a59d4ec603 | |||
| 2cdf156cd0 | |||
| 7c535d7ba8 | |||
| f7d8af493a | |||
| 46f076077b | |||
| 020126b6e0 | |||
| 99268e47b8 | |||
| 7940728b30 | |||
| c4d6d50687 | |||
| 52ab1b60a3 | |||
| 9641c43384 | |||
| 1f7e0881f3 | |||
| b2cc6b65ad | |||
| 28a779b91b | |||
| e397a69dae | |||
| 3554817f91 | |||
| 410225d54d | |||
| 4ba8a40af9 | |||
| 6523686aca | |||
| 2475e5dd5a | |||
| 8f6bfb4df1 | |||
| 36a1542176 | |||
| 71011dd67c | |||
| 173ddc9eac | |||
| 53699482e1 | |||
| e5c5afb158 | |||
| f2af2ad0b6 | |||
| 3f6f8a5695 | |||
| 0d9bb53722 | |||
| d84cd41896 | |||
| ed4b6ef897 | |||
| f50f5e2d44 | |||
| 71eaf1d22a | |||
| ba02f761c6 | |||
| 80bbdb2c09 | |||
| 6807ee6878 | |||
| 04d2e1a7bf | |||
| 07e45f508a | |||
| 2b9665c723 | |||
| c73d02f76c | |||
| 5048711acb | |||
| 62e53c5dfa | |||
| 333c5cf8d3 | |||
| d033a95b0e | |||
| 3b480eb183 | |||
| 8632e31c01 | |||
| 14e6305aa4 | |||
| e059be2d84 | |||
| 1a99a7213f | |||
| f9e8fa896d | |||
| 59713ffc22 | |||
| 681a498461 | |||
| e8b94b6efc | |||
| 5dda961dbb | |||
| a6ce5d5426 | |||
| 772b5d0973 | |||
| b707468cb6 | |||
| e33dff1ab9 | |||
| 58222b3474 | |||
| b78ca51db1 | |||
| 4a149525bd | |||
| dd1c6b8b62 | |||
| a982ff5885 | |||
| 7cf94cce14 | |||
| 1dc45a285d | |||
| c70c93c814 | |||
| f51682f929 | |||
| f12f4145ef | |||
| 6dce911172 | |||
| 8ae19beef6 | |||
| 3693b02cb9 | |||
| ac2f7ea032 | |||
| 24f12b1220 | |||
| 88db107691 | |||
| 7dfefb48e6 | |||
| 12af6e250e | |||
| 5ffd9e5fb3 | |||
| 7d78bb45d6 | |||
| 9a82328de2 | |||
| 51f8a6f65b | |||
| 99edea33e3 | |||
| f97782724e | |||
| e4df3eaecb | |||
| 16b95e4804 | |||
| f1a029262b | |||
| 51c10144c7 | |||
| b5ae7fe472 | |||
| b75d28f284 | |||
| 4a3a7127ca | |||
| bfff092657 | |||
| f2f17ac26e | |||
| 8c61af2863 | |||
| 34fb030af1 | |||
| 238ad7936a | |||
| b01598753d | |||
| 3fedff9d08 | |||
| 1fc80b05b1 | |||
| 0210791c69 | |||
| 592e97719b | |||
| ea80b5ebc3 | |||
| fe64dd9c93 | |||
| f0fb97512a | |||
| 6ea34a4c60 | |||
| 6572119360 | |||
| efb7d0ed20 | |||
| 5b9d81e3a8 | |||
| 71e5599300 | |||
| 6967a44620 | |||
| ab4745c70a | |||
| d2c00b4b2c | |||
| d45b8bc2fb | |||
| 2511227c4e | |||
| 5ee60c5259 | |||
| 7a4dbb8260 | |||
| 73b227c49b | |||
| bfbf06f5c5 | |||
| e5a0635418 | |||
| 2fce80677a | |||
| 56ebdd7670 | |||
| 4c37d39e07 | |||
| d1d44195c1 | |||
| 0dbcb175c0 | |||
| ea348b3360 | |||
| 36399cf4aa | |||
| 837090d150 | |||
| d01cb4b6f3 | |||
| 3aadaf1c86 | |||
| 6de443e833 | |||
| f4672cf0c7 | |||
| b66ac935b7 | |||
| 7d704c9d42 | |||
| ebc838fa9d | |||
| 1e5bc0054d | |||
| 43fb735e4e | |||
| 1dde2a008e | |||
| af2ab567ca | |||
| 6928b8a622 | |||
| 205f396942 | |||
| 89c7c2fb07 | |||
| b20b9838d0 | |||
| 2d65c4efbf | |||
| 34e1d25069 | |||
| 90d39f12c1 | |||
| bca7888077 | |||
| cbc6275307 | |||
| b26ce4f56f | |||
| 44428c2517 | |||
| a670103325 | |||
| a2bd0e27f9 | |||
| 7ca018fd7b | |||
| 607a2f28fa | |||
| a42ea35d8b | |||
| 123d38d295 | |||
| 35c874da52 | |||
| ad4a4db160 | |||
| 72d4fab25e | |||
| 7c4342e560 | |||
| 33959403f4 | |||
| f549957c0b | |||
| e5abeba11c | |||
| 8cf1b05042 | |||
| cfcdc8e85e | |||
| d240ae06e3 | |||
| d84237dbb4 | |||
| 7194422c0e | |||
| d20808fb35 | |||
| 51b332f4cf | |||
| a8f73f9a73 | |||
| 4798652ad5 | |||
| 080464de98 | |||
| 8caec74c5c | |||
| 511cecb311 | |||
| 0992d6578c | |||
| 3f1564817c | |||
| b62b97ab57 | |||
| 2eeea3b74d | |||
| f05a5197cd | |||
| 016d05f082 | |||
| 302a362885 | |||
| 81c05859fc | |||
| f1881fdf52 | |||
| d0731120f9 | |||
| 7677b12f74 | |||
| ddaf5aa64e | |||
| 2418ae2d8b | |||
| 0916b62bfe | |||
| 0b22393395 | |||
| 9fa492e20c | |||
| fa46483dd9 | |||
| 18b442eb21 | |||
| 5f34d20287 | |||
| 5905aa6415 | |||
| aaed831420 | |||
| 007a8d50c6 | |||
| 02ce4d5bb6 | |||
| 613258c3a2 | |||
| 4410aa2433 | |||
| 54ad3b9362 | |||
| 2cf2c6af2a | |||
| f5f3e766ad | |||
| fb8b6a01e8 | |||
| db0a26cd64 | |||
| 8b1ca5ab96 | |||
| cb0fb4f3be | |||
| 334146b799 | |||
| 63237b9534 | |||
| 595a2003d5 | |||
| 3afaa6e1ee | |||
| 5731631ebc | |||
| ac445184b6 | |||
| 981b103b90 | |||
| af7b29b6b0 | |||
| 0ff0df632b | |||
| 73e17e8509 | |||
| 317e0d7108 | |||
| dd37a0b5a7 | |||
| 28f172a643 | |||
| 96146a2e2c | |||
| e32942fb35 | |||
| a61d4331f0 | |||
| 62ee2252a3 | |||
| 6fd5098b89 | |||
| 6941e704cd | |||
| 985c8a155a | |||
| d0402f4746 | |||
| 6dc0936d6d | |||
| 38a10cb0de | |||
| badf587be6 | |||
| a995fceb8c | |||
| 2a9c98a83d | |||
| 4cf394f92e | |||
| e388baa464 | |||
| 5cae753e0d | |||
| 86625cf3ec | |||
| 98bb6ce10b | |||
| cbe7f591e3 | |||
| 0078d539de | |||
| e1b532d48a | |||
| f043baed9f | |||
| 8d8ee57cec | |||
| 4607c358ed | |||
| ed1461626b | |||
| ee9bd9bbb2 | |||
| 75da95b38a | |||
| 5896ebd5b7 | |||
| 9e7dfbda5a | |||
| dc84e933c1 | |||
| 3140f54419 | |||
| e9fdadbbd8 | |||
| 8d537a61ed | |||
| ddf23377c3 | |||
| c0138ed849 | |||
| b5115d4aa1 | |||
| 6b9c4ebebd | |||
| 7ed039564b | |||
| 8adfb3a40a | |||
| 9a9b1e9856 | |||
| 8aeb52380e | |||
| 05141b9a1b | |||
| dc0850d339 | |||
| 2bbf896e7c | |||
| faf57741a1 | |||
| fd7d01fc7d | |||
| 8ef9dca6ee | |||
| 4610804de6 | |||
| 6d8836ddfc | |||
| 17944554e6 | |||
| 47a7376632 | |||
| e00fbfddc1 | |||
| 00362bcd57 | |||
| fe42ca207c | |||
| 612e137a60 | |||
| 17913fc0e8 | |||
| 44d256179b | |||
| 3c05429041 | |||
| 6727b95596 | |||
| 08b930d6e6 | |||
| 454a373874 | |||
| 90281b1535 | |||
| e687862043 | |||
| 05412fbfc3 | |||
| aa787f0b53 | |||
| ab033b35d3 | |||
| e383575c80 | |||
| fd12d11fab | |||
| 0fbb446209 | |||
| 4ea64bd7ef | |||
| 7d9a220230 | |||
| 0afa15bb16 | |||
| d66ab01d34 | |||
| 91989a0216 | |||
| 7b4ad20805 | |||
| a1b0616ee6 | |||
| a146a21285 | |||
| 87a5715f30 | |||
| 52a28167c9 | |||
| 1403d49049 | |||
| 9090b415cc | |||
| 3f1606c38f | |||
| 18db66bce3 | |||
| 10077eee60 | |||
| 14568f8cc7 | |||
| 93fb694e25 | |||
| cde24642ac | |||
| b4757b1589 | |||
| f771100a4c | |||
| 0c3ccac21c | |||
| 4c282bb055 | |||
| 4741124d94 | |||
| 9afd99bf7c | |||
| fef54e5276 | |||
| f62c9871c4 | |||
| 0e03b84260 | |||
| f73f3466fd | |||
| 8d91c200a5 | |||
| 9bf75a069e | |||
| ec62cd9083 | |||
| 302b150c36 | |||
| cf022ed1c0 | |||
| d3326409bf | |||
| 3de5e68e68 | |||
| 325dafacbc | |||
| 2f5f429e83 | |||
| fb4482fac7 | |||
| 32f04d4ed8 | |||
| 38644bced6 | |||
| f3d475d53a | |||
| 195c224189 | |||
| f07ec23da9 | |||
| 4b64862eb4 | |||
| eea44f9a6b | |||
| de3f972aa2 | |||
| 6a334c61df | |||
| 63994ec1d4 | |||
| 6011d6fb41 | |||
| 845629ea46 | |||
| 7311dd10ab | |||
| e2e92b6b38 | |||
| 5534493bd1 | |||
| 86fa6326e9 | |||
| be70d2e43b | |||
| e89a0ef486 | |||
| bcf447fe4e | |||
| 90b455aa6c | |||
| f8e5d61fa9 | |||
| bd67195238 | |||
| d78ab5cc2c | |||
| d087780d9f | |||
| 8379f42ec3 | |||
| ff9961b846 | |||
| 5e99d19165 | |||
| 0df412c014 | |||
| e756a00cc9 | |||
| c35131462e | |||
| bad637591a | |||
| 910b69594d | |||
| a154601e86 | |||
| bdeb32e723 | |||
| 2de592f798 | |||
| b5c3d71247 | |||
| c1339b6c65 | |||
| 153aacba03 | |||
| bcbadac995 | |||
| a6e62f4674 | |||
| 77255e015d | |||
| 6cbe94cf20 | |||
| cdf10e1d6a | |||
| a22244a041 | |||
| 9371fccd62 | |||
| b4b6fdc0fc | |||
| 9e3fcb8edd | |||
| 2c7909e502 | |||
| 003c4d62cf | |||
| 10e4804e0a | |||
| 05edfb93dc | |||
| e5006a9896 | |||
| 7d1fcfe895 | |||
| c6e8602184 | |||
| 29873fb3c0 | |||
| 4f096c6c01 | |||
| 9e911e845f | |||
| 377519fd95 | |||
| fb064a22fb | |||
| 7af6d45ca1 | |||
| 54987e4c8d | |||
| 7683a925df | |||
| 824514d922 | |||
| 79a0dae04b | |||
| e176438934 | |||
| 3254d82d11 | |||
| 24d50c921e | |||
| db2f3fc8e5 | |||
| 952736c127 | |||
| 997dac3b9f | |||
| 3f6fa5ba28 | |||
| 5b06c57565 | |||
| 5aa68a49c6 | |||
| 0d13638d70 | |||
| f9dc54cc3b | |||
| f679433ac0 | |||
| 4b31474080 | |||
| f72b43c6bf | |||
| 0a90010c1f | |||
| 81a8f24e27 | |||
| 4712616339 | |||
| 1cfeb193c7 | |||
| 69b402f872 | |||
| deb7e2d15d | |||
| 645b3b8632 | |||
| ee81eb44cd | |||
| fd3552e725 | |||
| 818d9c9f90 | |||
| dc0775f7df | |||
| c0fb22124b | |||
| 97b10b3ac9 | |||
| be522d4dfe | |||
| 33a360b483 | |||
| 2e1b9b27be | |||
| d6fe1123b4 | |||
| 5fcfa2f72f | |||
| 24d1777e63 | |||
| 794dd693cf | |||
| 0cadf07985 | |||
| bb263ce1b0 | |||
| 23d592af1d | |||
| ababa63856 | |||
| fdffb8e88e | |||
| 98642e43c7 | |||
| 8cb7edf41e | |||
| 64f0e687a0 | |||
| 6a54bc8cf3 | |||
| b32d30b789 | |||
| d3b737c19b | |||
| 146bca4b37 | |||
| e3cf9daaed | |||
| 81e5f5479f | |||
| a5eefc712a | |||
| a50d200af4 | |||
| 99db7f1faf | |||
| 4560ec1800 | |||
| d92146d678 | |||
| 70e4bc557b | |||
| c1dd615e11 | |||
| 63cc1647fb | |||
| d9228fb05a | |||
| 806bc1397a | |||
| 7560691fbb | |||
| 8eb4ff41e2 | |||
| 286ab53d26 | |||
| 5d90c308a9 | |||
| 9622a00ea1 | |||
| 7c9ef9b895 | |||
| bfae73cabf | |||
| c0c066904c | |||
| 2eea28da05 | |||
| df84c42b8b | |||
| 860db12200 | |||
| 0bf8341b6c | |||
| 2ec458aa14 | |||
| 5f583e5718 | |||
| deea80e32c | |||
| 37f0197f9a | |||
| dc7c05b03f | |||
| 8a46293e5c | |||
| 935b7a4d9d | |||
| a50f77629c | |||
| ecdc060d81 | |||
| ee9356c358 | |||
| 7fdf162f1e | |||
| 56514a839f | |||
| dbf76a4e84 | |||
| 3f7430d114 | |||
| f3158cbb69 | |||
| 2202e3ed98 | |||
| 844e57e239 | |||
| 5b6df923fc | |||
| 9724ec57f9 | |||
| 2d92243341 | |||
| 6ec15461af | |||
| 2c76039f2c | |||
| c4bde6c707 | |||
| 6384e39576 | |||
| 5edfe1797c | |||
| 4bf452d462 | |||
| f6b0edaf5a | |||
| 18efed891a | |||
| 60a3ae225f | |||
| afd3d34f43 | |||
| 0344862a0c | |||
| 43e6d4a1b8 | |||
| 53c65febed | |||
| cec8bccb03 | |||
| 6c20b3d23f | |||
| 53f54af871 | |||
| caa4357870 | |||
| 3e608c62a0 | |||
| 0afa25e57c | |||
| b3af44652f | |||
| 67321adade | |||
| 6894e626a9 | |||
| 9745215038 | |||
| b72a2f1092 | |||
| 2da8dca167 | |||
| 085a6177f9 | |||
| 01abcac8f2 | |||
| 2a5f537381 | |||
| 07b5b72878 | |||
| 1a1a398962 | |||
| b7d90e8e5e | |||
| 55c38522a4 | |||
| d9b528f3d3 | |||
| 9cd7f1c0c8 | |||
| a350c82893 | |||
| 6a690abf82 | |||
| e19a990b64 | |||
| 975a95e1b0 | |||
| 2af238aed5 | |||
| e81a409234 | |||
| 1c76671ed7 | |||
| 9ece4d658d | |||
| 739b0b136e | |||
| 199ff4b47c | |||
| 65e5552c7d | |||
| a5452fa1b1 | |||
| 889c08691f | |||
| 0a4a0689a0 | |||
| 0daee74cf0 | |||
| 2e6bb8882f | |||
| 365333d425 | |||
| 367048e853 | |||
| 406ca28304 | |||
| f889c53d92 | |||
| b0af1d16d2 | |||
| 4e67b77714 | |||
| b1993847b5 | |||
| cde79f4619 | |||
| cc271819ad | |||
| 8cd64ce3ca | |||
| 9705e58691 | |||
| 3acdab816a | |||
| c31ed14041 | |||
| 7241dbed35 | |||
| 94b358f686 | |||
| 8e19f7e688 | |||
| 7ea06caaa2 | |||
| 1c681b6777 | |||
| 5f480caa3f | |||
| 5d4b61b4c3 | |||
| ab064b4c91 | |||
| 26ecd3dd93 | |||
| a8e2b9d98d | |||
| 4b225db9da | |||
| aba4ccd040 | |||
| f8a6d0ae70 | |||
| 00681840c8 | |||
| 00be3e940a | |||
| c2405bfe14 | |||
| 01409cfdea | |||
| 130f58d9cc | |||
| fb2a12773a | |||
| 167f10c7f7 | |||
| 15d5cb2272 | |||
| e386016349 | |||
| aec925753e | |||
| c3bf30b49c | |||
| c0221ba53d | |||
| af5b17e841 | |||
| b628a5f751 | |||
| d28d8cb9ef | |||
| 9ec316fbe2 | |||
| a407c7708d | |||
| 1466fc2d30 | |||
| 963bcdf9fa | |||
| cfe03317c9 | |||
| 37ba12daaa | |||
| 5c47e9f10a | |||
| 694786d4e0 | |||
| 06a00ca6b5 | |||
| bbc25ddaa0 | |||
| 02a94281c3 | |||
| cbe5faab3b | |||
| cacfbf5713 | |||
| 2faed68af4 | |||
| bec0881018 | |||
| da2a700bcc | |||
| cd3ed9a03b | |||
| f7fad076c2 | |||
| a397271553 | |||
| 83a54ccb20 | |||
| 2e9bab75b1 | |||
| 0dc40bbea3 | |||
| 17f6947648 | |||
| 481651c88d | |||
| ad4903d4ac | |||
| 3a962ca207 | |||
| f29ae3d5a8 | |||
| 37d24a539d | |||
| 622f23c091 | |||
| b70db887b1 | |||
| 7f13af3fcd | |||
| 9afff0f4b2 | |||
| 5a7a6ce522 | |||
| 36b6539044 | |||
| 6c6cd8a280 | |||
| 4df112e712 | |||
| 3d8b8bbfdc | |||
| 076339024f | |||
| e82f0f36d2 | |||
| f4ade209f9 | |||
| b0652595fa | |||
| 332172881e | |||
| e05ac97749 | |||
| 615a83c23f | |||
| d017375f64 | |||
| 0b5235f619 | |||
| 16239c1d31 | |||
| cae7a0586f | |||
| 23f28a8102 | |||
| 34ecec3800 | |||
| d40bd37406 | |||
| 4ed41434e2 | |||
| 6a0b54fa0e | |||
| b83ecfcc19 | |||
| 671bf38083 | |||
| 0f5a414a09 | |||
| 831426948f | |||
| df2c0a0d25 | |||
| d427f69dcd | |||
| cab04e6e2c | |||
| 8969fefe2e | |||
| 5e9fcc5c49 | |||
| 53b23fc2f7 | |||
| eeb3a29ecf | |||
| 4cdfa98a4e | |||
| 9fcec6cbb8 | |||
| a527ac191a | |||
| 8cd3aafd10 | |||
| 5c76a423af | |||
| c80bf99b91 | |||
| 6e5cb0a23e | |||
| ffb98425f1 | |||
| 533e92c711 | |||
| 9f32b05719 | |||
| 2a05aaa4d8 | |||
| 6529febcfa | |||
| bd87d4b4c6 | |||
| 5a0589dd69 | |||
| 5605ae0359 | |||
| 2b3f351ff0 | |||
| 126b9ba2ee | |||
| c0498ebe68 | |||
| 99d52eafe7 | |||
| 2a73318457 | |||
| d8d08a8b1e | |||
| c60769f795 | |||
| 01f8324292 | |||
| c66988cc1c | |||
| fac3d4359b | |||
| d6f10d29ca | |||
| 332735cecf | |||
| b04e335f49 | |||
| 75e50a1cd4 | |||
| 243a0f0e7f | |||
| 7c3ec9e920 | |||
| 4639146f05 | |||
| a354fee792 | |||
| a1cb6b2692 | |||
| 8376415074 | |||
| b25615317b | |||
| 311d268b10 | |||
| 6581620cb0 | |||
| aa963519e9 | |||
| 4a6dddbb48 | |||
| f217230ef4 | |||
| e27b4d78cb | |||
| d41ba61aee | |||
| 35cf01c11e | |||
| 00c9a6fdd9 | |||
| fce66a6a60 | |||
| b023e4cdc7 | |||
| a8f2912b90 | |||
| a2a7ac8fec | |||
| 4e168ff502 | |||
| 51aba87852 | |||
| 4c13e98091 | |||
| 54c849ab60 | |||
| 94ee22fdd4 | |||
| b96eb8ccba | |||
| b8a80460bf | |||
| 7130c2d4c4 | |||
| 62c34c1e95 | |||
| e413f54651 | |||
| 1a4af214bf | |||
| c2891938ab | |||
| 2bed35dd64 | |||
| 0c656cff2b | |||
| e03ba3f5ed | |||
| c6ff8abf11 | |||
| eff6ca3e87 | |||
| 1a5b076a8d | |||
| 90e88fc469 | |||
| 98f6d18bea | |||
| 7d69cac7e7 | |||
| c6a8a4a492 | |||
| ca15e227cd | |||
| 391aff52ce | |||
| 3dc16b392b | |||
| 4d7be047da | |||
| 182e1f3239 | |||
| 87782319f2 | |||
| 6b7f817aa6 | |||
| 82f442ffb8 | |||
| 1924203c19 | |||
| f18ed26005 | |||
| 897cea5b54 | |||
| cd2d51ee40 | |||
| 39ed4bffba | |||
| 6010c7d589 | |||
| 01978730ba | |||
| 451eff83a8 | |||
| 7cb2efca30 | |||
| 33953fcf2b | |||
| 1eec4a2342 | |||
| 2dc4940ca2 | |||
| cd5f1464b6 | |||
| 4aeb51a973 | |||
| 15efe56762 | |||
| 995bc17418 | |||
| c3dcf1401a | |||
| 6f9873d47f | |||
| 28185727e3 | |||
| 48795f6ec3 | |||
| f5021a0fdf | |||
| 7312f330ed | |||
| 2115bc551d | |||
| f6c19af33a | |||
| ebd9eb81f2 | |||
| c87c01cdfe | |||
| 19a94d4a84 | |||
| cca04918a9 | |||
| 777b83f6e0 | |||
| 455bc05c69 | |||
| 37842dc1ef | |||
| 01f3cc845b | |||
| bdba56bef1 | |||
| a5ea632cc2 | |||
| a3b81bead8 | |||
| 026337a350 | |||
| 44b1a74838 | |||
| 7aae2944d4 | |||
| 766a51753d | |||
| 92e5e7c6da | |||
| 154dc898ff | |||
| beb38b6b98 | |||
| f04ba7f143 | |||
| fd0953bfb5 | |||
| b312eb20aa | |||
| 8eb8a2fe97 | |||
| 13be4302c3 | |||
| 5fd45d3e94 | |||
| e88b815dc9 | |||
| 556a4ffcc2 | |||
| 03c5d33eb7 | |||
| f9786aa75a | |||
| b87623cf66 | |||
| 4d24e648ab | |||
| 99f42f66b2 | |||
| 3240b0788b | |||
| 3ab1501a90 | |||
| 7e42e00449 | |||
| 51ea558e19 | |||
| 75bd3228e5 | |||
| 86e4ba7e29 | |||
| 4bbc00b765 | |||
| 32b373bf2c | |||
| cdfc10c854 | |||
| adb472956e | |||
| 60d3cff5e7 | |||
| b208576068 | |||
| 1ee64efc81 | |||
| bb4ccc6355 | |||
| 70e9611f02 | |||
| d05144bdb3 | |||
| bfd92e3883 | |||
| 3b191dccd6 | |||
| c0eda84644 | |||
| 19f382a31a | |||
| 3b205db329 | |||
| d8c5491200 | |||
| b9c8b1c730 | |||
| 684f17f507 | |||
| a0f64f6fa6 | |||
| 06c218c736 | |||
| 1e249a0eec | |||
| 249fccadd3 | |||
| 82957ab162 | |||
| e8727358eb | |||
| 28891f4709 | |||
| 297f971bd5 | |||
| 4bf35cf786 | |||
| 28e19b8898 | |||
| 4ed7969e90 | |||
| ef7d8cca9f | |||
| 1683d98b90 | |||
| ae9fe5d063 | |||
| 6783a1cbc4 | |||
| 7fd7861b4b | |||
| 3e453a7b6d | |||
| fbbf20d820 | |||
| 765404fdc2 | |||
| 67fa196a28 | |||
| 4e3f0ad800 | |||
| 4c67307951 | |||
| 18aa7fe669 | |||
| 8409a4469d | |||
| b75492ec18 | |||
| fef8db6c00 | |||
| a70502fb77 | |||
| e8a9afa221 | |||
| 8fca54e523 | |||
| 8e9588c4ff | |||
| 7bc1d5b643 | |||
| ef14f5f1a1 | |||
| 7caa7247ef | |||
| 04d9d2fd56 | |||
| b4742f205a | |||
| ff36687f53 | |||
| b860a4309b | |||
| f409222f8a | |||
| 1c051933b7 | |||
| c83a2ef56f | |||
| 6d1f8f022e | |||
| 500ddf59fe | |||
| 5e4be0c279 | |||
| 16f730db76 | |||
| 958d8d5f20 | |||
| 7b68c19dc5 | |||
| 88f71c9b5e | |||
| 780ba9c58b | |||
| 079ed216a8 | |||
| 337c25f66b | |||
| eabb6b2951 | |||
| 5d4b19aef2 | |||
| 11941bedad | |||
| 8ba47f3935 | |||
| 9dd8849b21 | |||
| 725d95c079 | |||
| c5bd13ea52 | |||
| 9ecad43f76 | |||
| 953e94da44 | |||
| 805fc69281 | |||
| d620618bb8 | |||
| 6c358fbfad | |||
| a5599eb0d0 | |||
| a8d25f9c01 | |||
| a09793b6ec | |||
| 675a3cdbfb | |||
| abc51a0dad | |||
| 24332a4e23 | |||
| ebc5754684 | |||
| 340b300aa4 | |||
| bf7026cc9f | |||
| 1b04b52509 | |||
| fca334f472 | |||
| d81d644319 | |||
| 400cf1114f | |||
| fec38adc78 | |||
| 993a7d2626 | |||
| dbe09411ac | |||
| 0afc47fcdd | |||
| 4862b285a8 | |||
| 41dd1555d7 | |||
| 0cf3a25ac6 | |||
| 3674b6e2d6 | |||
| 4c9bcb00c3 | |||
| 2067d0bf84 | |||
| c0fa59d10e | |||
| 37add84d59 | |||
| c23019b8c0 | |||
| b4edd35f5f | |||
| 812f85b9a9 | |||
| 77888b7d88 | |||
| 4a38d7512d | |||
| 5d0df18dac | |||
| d18e38800e | |||
| 76e595aaec | |||
| dfb9897fa1 | |||
| 82ad784fcb | |||
| 4bd7077d64 | |||
| 3f6b9cc5ef | |||
| 0742647571 | |||
| 33090419df | |||
| 4042d0e5f1 | |||
| d3a0b41fba | |||
| 2fefea5618 | |||
| d75f7c794f | |||
| 503b91ea87 | |||
| 43db7c309d | |||
| 6e57927409 | |||
| a404f5ded9 | |||
| f6a6aab623 | |||
| 2cfbc0addc | |||
| 07d6ef984e | |||
| 50227ccae6 | |||
| 8f3c636c61 | |||
| 42761bbdbc | |||
| 0f2eba302c | |||
| 83dd58721f | |||
| d658d0b81e | |||
| e04113628a | |||
| b1e92326b6 | |||
| 9ac63bd75f | |||
| f795180c7d | |||
| d1f1ce1f4b | |||
| 334073089f | |||
| df634dc741 | |||
| a76dfde02d | |||
| cc5ccf75a2 | |||
| 36f8349bc7 | |||
| 130a3a2d8e | |||
| bd6fa27970 | |||
| 630bc2971a | |||
| 7182f7803a | |||
| a64a7c414c | |||
| f0cc396a6b | |||
| 5f588a5513 | |||
| 599df7734b | |||
| 49fa02142d | |||
| 333dc00ee2 | |||
| 2bc71e44ad | |||
| 92265da5fb | |||
| 9c1516c086 | |||
| cd7940bdc2 | |||
| 4a5f3e1802 | |||
| 1b5bf4c061 | |||
| 384d02649a | |||
| d51da40a67 | |||
| 3a6bd3711e | |||
| d28d371caf | |||
| 05d96b6077 | |||
| f6197592bb | |||
| aca7f56808 | |||
| 872cc806eb | |||
| 7b847e0541 | |||
| 17b46a13c2 | |||
| ede3a5841b | |||
| 7270f827a9 | |||
| 468812bc09 | |||
| 7bef63aede | |||
| 21dec0d53a | |||
| 52997b3c78 | |||
| 765e1384b5 | |||
| e18f85370f | |||
| a0604a43c0 | |||
| 9cb44c6273 | |||
| eacf6d4970 | |||
| 07ae227cee | |||
| 18ef6218d8 | |||
| 0c7ac816e9 | |||
| 8e204725b2 | |||
| 40acca20b2 | |||
| ae804f92b2 | |||
| 0a6effccae | |||
| 0cf73b1234 | |||
| 8d354755f0 | |||
| 166f598386 | |||
| 6e51739654 | |||
| ec22823e59 | |||
| 87cd10194f | |||
| 933575b480 | |||
| a4218c0c33 | |||
| c67fa39e30 | |||
| 9f7dc8f995 | |||
| d1dd1ad4da | |||
| c7fdea856d | |||
| a7307dbf3a | |||
| 55ff644a8a | |||
| 3d90e03ca9 | |||
| 069e87f9ba | |||
| f3c5d124b5 | |||
| d821e19334 | |||
| d15b4efc97 | |||
| a3ad49a441 | |||
| fb95e465a3 | |||
| ab0a03b313 | |||
| f396ff7b66 | |||
| 52cb47e5c9 | |||
| 003b44c62e | |||
| 92caef5cb7 | |||
| db304631f8 | |||
| eae1820fda | |||
| f70deb32a2 | |||
| 69eea1e895 | |||
| bf4346b4ff | |||
| 7cde6a2068 | |||
| 84b424b02e | |||
| 04b73596ea | |||
| 3916276de8 | |||
| 077d46f319 | |||
| a0fd6d9651 | |||
| 8d505eb848 | |||
| 3f364f47e9 | |||
| b92139f207 | |||
| c7e9a0a493 | |||
| 717dec4e54 | |||
| d3cb20cdae | |||
| 518da075de | |||
| fb31157fe9 | |||
| a5f574062d | |||
| afccb6fe0a | |||
| f916b9fa19 | |||
| d775ba5b3e | |||
| 3372daca84 | |||
| b72ddd7c19 | |||
| f980e2e76d | |||
| ada6d5f1f1 | |||
| 7c6416ac38 | |||
| e833488425 | |||
| 0b8863aaa9 | |||
| 8d30c40fe2 | |||
| d2f2c37531 | |||
| b23a1636b0 | |||
| a73a74d1fc | |||
| d297f87115 | |||
| 88537c1119 | |||
| 141b34391d | |||
| 8b4b440b22 | |||
| 0cccf3c9dd | |||
| e532f67c85 | |||
| 7a2b90055a | |||
| ab2d7bfe50 | |||
| 1e2810b85c | |||
| 164887f8a4 | |||
| b4d3e65a3d | |||
| 3b238c3c8f | |||
| 93111b93c5 | |||
| 6a63c13cd8 | |||
| 3518f7fede | |||
| 79fc2871c9 | |||
| 2d21ce9303 | |||
| 28e63a1029 | |||
| cbfe46201e | |||
| 1b0d39c5b0 | |||
| 446a8f14cb | |||
| 57d448c003 | |||
| eabc73ff49 | |||
| f724421ce7 | |||
| 9134195eb1 | |||
| ee6971284c | |||
| 098fab6aca | |||
| bc2b2bf23b | |||
| eb5bf55aad | |||
| 17a0dddf61 | |||
| f6bd38e3dc | |||
| 12db4f5178 | |||
| f01502ff32 | |||
| 54a47b03c2 | |||
| 537171d788 | |||
| f665203543 | |||
| dfd4b0e89e | |||
| 45c10a8593 | |||
| d929c30882 | |||
| 0ca3066cfc | |||
| 1d30ea2708 | |||
| 6ae21e9e24 | |||
| 5843b3dcc5 | |||
| 1cd367332b | |||
| 9515f5fd7a | |||
| e22f464300 | |||
| 3d0c505178 | |||
| a1f8377dd4 | |||
| 588556c2a6 | |||
| af078aaae0 | |||
| 9dccbb95e8 | |||
| 226f08f62d | |||
| 85159cbc44 | |||
| 201fce0125 | |||
| 3b8d4f3f74 | |||
| 852d109468 | |||
| c5eb63ae7f | |||
| b0ab361ead | |||
| 7b2e1caa47 | |||
| 7957176e59 | |||
| bd7c83b18c | |||
| 27a0e095a3 | |||
| e19315819d | |||
| 002afe3690 | |||
| 9e31bc65db | |||
| 898410b225 | |||
| fe28a91d5c | |||
| be58c00bc7 | |||
| 91b07fe797 | |||
| bac7f8d55c | |||
| bb660d02f5 | |||
| e3d9349d4b | |||
| 78642bcbb2 | |||
| 48e3bf210a | |||
| e9d5fe35fb | |||
| 66f16d4a2d | |||
| 187347e64b | |||
| 5016327bc2 | |||
| ed460761ff | |||
| c49b1e03f2 | |||
| 28d15d0ed5 | |||
| 54db023520 | |||
| 713c1a3470 | |||
| 5bafb88377 | |||
| 95f3836edd | |||
| 0195553a62 | |||
| 5c7554d6cb | |||
| ec32b9237e | |||
| 3edd40de0d | |||
| 88418b0850 | |||
| 1e59cfd2ea | |||
| 42f2a6ef62 | |||
| 3e3bc0e857 | |||
| 290c5ff896 | |||
| 4c0d44a99d | |||
| ef4adfe003 | |||
| 30dfea57b9 | |||
| a0d7f221c0 | |||
| ee916d0022 | |||
| 156d832d2d | |||
| abe3d42004 | |||
| 3f38742dbe | |||
| 2cb62d5f34 | |||
| 256c30e7cd | |||
| c92f60e0f3 | |||
| 9461cc2121 | |||
| 8a744eb55a | |||
| 73188c2471 | |||
| 6e8de37135 | |||
| bb010664ca | |||
| ffc55efe1c | |||
| 8b42f4ac28 | |||
| 4c71a3bb92 | |||
| d88d5c4921 | |||
| 5c62ae316a | |||
| ed58681800 | |||
| 90d2d42478 | |||
| c88cf831fc | |||
| f6aed7deda | |||
| ce204ce413 | |||
| 1ef3e367eb | |||
| 7cd988b777 | |||
| aac88cdd29 | |||
| 664ae5b5ce | |||
| d268e581bd | |||
| ecc8dad2e2 | |||
| df025f0409 | |||
| 5e4412879d | |||
| ce232e0512 | |||
| 5d54449b21 | |||
| 04f003c9f0 | |||
| 9b55632c86 | |||
| bd65679572 | |||
| f93877d723 | |||
| 2b8b499e79 | |||
| 69410fd7c2 | |||
| 176014b706 | |||
| 92984a7bae | |||
| a5d433b516 | |||
| e30094e8fc | |||
| f1b416bba5 | |||
| ec0b8dbcf7 | |||
| 5bfa7bf651 | |||
| e204901d18 | |||
| 482d778bca | |||
| c4ad8f6c12 | |||
| aa763b0f81 | |||
| 58a825976d | |||
| e4e9e89451 | |||
| 2f2e56ff2e | |||
| 2b29b5c86f | |||
| af1cb7c17b | |||
| c5aa382527 | |||
| 78f81eeccd | |||
| 096763ad40 | |||
| 6354911c54 | |||
| a8bb56a109 | |||
| 5047fee431 | |||
| b63c7ab0fe | |||
| c0c86ef601 | |||
| 69c765d44a | |||
| 617ba859fb | |||
| 62db171ed6 | |||
| 66b2f59ca0 | |||
| 6dbf2fda01 | |||
| 234f254f4f | |||
| 3210fc0d20 | |||
| ac68e26c70 | |||
| ce0f581938 | |||
| fc48ff7d9f | |||
| af39d40847 | |||
| fb23766ed3 | |||
| bcb3147d1e | |||
| 940a43747b | |||
| 16c74d10db | |||
| a99c3e3894 | |||
| e621647768 | |||
| 5992156356 | |||
| bed0c5fb8d | |||
| 0362a1b4ea | |||
| cf7c94f9d8 | |||
| c044ecfba2 | |||
| 23a79a7ac5 | |||
| 795dd3f235 | |||
| 35d138175e | |||
| 4c1690dd28 | |||
| 407d5c1d25 | |||
| f46681fdbc | |||
| 95e0309c63 | |||
| 819944cccf | |||
| c595450310 | |||
| 4af61c8cb9 | |||
| 9f391527c2 | |||
| cd168da760 | |||
| f4282cb608 | |||
| 073134d6d3 | |||
| 4baefa61ac | |||
| 0d6d81fb69 | |||
| c96a3ade6b | |||
| 81c9dd84b2 | |||
| fe67461f88 | |||
| aae60e2037 | |||
| 97d5ec6b33 | |||
| 459bf2d8cd | |||
| 43f0f1cbfc | |||
| a3fd6881df | |||
| b27a532bce | |||
| 52f85669f8 | |||
| a891160f98 | |||
| 130bc8a51c | |||
| 4224418e6f | |||
| 4018f95723 | |||
| e6c7a3eae4 | |||
| 2e27efdfbf | |||
| 6efa10643e | |||
| 71e5803695 | |||
| 1107f0e534 | |||
| 0b22d0aa1f | |||
| 353cd16021 | |||
| ac6d1b570d | |||
| 319ea2d01d | |||
| 6fc64937fb | |||
| 323f24a470 | |||
| d98bcc15b8 | |||
| fdd91485fc | |||
| d510ba30f6 | |||
| 4bb0c9b9a3 | |||
| b3e67e5ef6 | |||
| dec890104b | |||
| 5d8c435c5a | |||
| 3cf371242a | |||
| aab7b508cc | |||
| 36def8f96a | |||
| 3c0a654f93 | |||
| 77b4bc9ad4 | |||
| 9f39f1cc2f | |||
| f326be77cd | |||
| 7eba7dbaaa | |||
| dc4434db84 | |||
| 0eed4a2649 | |||
| 7b49c95967 | |||
| 30126b1709 | |||
| 66c7db73e2 | |||
| 07af3acb84 | |||
| b2feccdb90 | |||
| db2f46b46e | |||
| ff7c768287 | |||
| 236fbf061c | |||
| 21b0a153e8 | |||
| 35ca3f3a07 | |||
| 87f72db8ad | |||
| 93b763865b | |||
| b15b5ad9ba | |||
| 364600e545 | |||
| 23b2a2a0c0 | |||
| ef6eec3cf8 | |||
| 94f4682f2f | |||
| f407a3cb54 | |||
| c11c1200e2 | |||
| 0acbf87dde | |||
| 153336d757 | |||
| 570710c556 | |||
| de13d5ea74 | |||
| f36e528086 | |||
| 52ce930c31 | |||
| bb694c9926 | |||
| a8c77c8db3 | |||
| 3263638c57 | |||
| c30e5800df | |||
| 161e0d8ea8 | |||
| 93f68aa29d | |||
| c5ce35ff13 | |||
| 7069c8b636 | |||
| 6149427753 | |||
| 536b762f97 | |||
| b423dcedf7 | |||
| 16cd1fef2d | |||
| c94d0a642d | |||
| 135390788d | |||
| 98e4e38809 | |||
| 6d5a12a21f | |||
| fe3b3b536c | |||
| aa8a6baac4 | |||
| b0982249c3 | |||
| cf91c2484f | |||
| b3a8a69244 | |||
| f51b193876 | |||
| 0846d1f360 | |||
| dd56617c4c | |||
| 03ce847196 | |||
| 1a7a33041c | |||
| 6da8b11301 | |||
| 8cd1ecffc4 | |||
| 7967b71405 | |||
| cd0d5971e2 | |||
| b52b4db989 | |||
| ef5cfb4908 | |||
| ee7781ee67 | |||
| 8c5bb32ec6 |
@@ -1,6 +1,8 @@
|
|||||||
# Git
|
# Git & CI
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
.github
|
||||||
|
.claude
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__
|
__pycache__
|
||||||
@@ -15,6 +17,7 @@ venv/
|
|||||||
.eggs/
|
.eggs/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
*.egg
|
*.egg
|
||||||
|
.uv
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
@@ -28,10 +31,30 @@ tests/
|
|||||||
.coverage
|
.coverage
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
|
.ruff_cache
|
||||||
|
.DS_Store
|
||||||
|
tasks/
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Runtime data (mounted as volume)
|
||||||
|
instance/
|
||||||
|
|
||||||
|
# data/ is a Python package — only exclude non-code files
|
||||||
|
data/*.csv
|
||||||
|
data/*.db
|
||||||
|
|
||||||
|
# Build scripts
|
||||||
|
build-multiarch.sh
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Local Postgres data
|
||||||
|
pgdata/
|
||||||
|
pgdata.bak/
|
||||||
|
|
||||||
# Captured files (don't include in image)
|
# Captured files (don't include in image)
|
||||||
*.cap
|
*.cap
|
||||||
*.pcap
|
*.pcap
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# INTERCEPT CONTROLLER (.env)
|
||||||
|
# =============================================================================
|
||||||
|
# Copy to .env and edit for your setup
|
||||||
|
|
||||||
|
# Container timezone (e.g. America/New_York, Europe/London, Australia/Sydney)
|
||||||
|
TZ=UTC
|
||||||
|
|
||||||
|
# Flask secret key (auto-generated if not set)
|
||||||
|
# INTERCEPT_SECRET_KEY=your-secret-key-here
|
||||||
|
|
||||||
|
# Admin credentials (password auto-generated on first run if not set)
|
||||||
|
# INTERCEPT_ADMIN_USERNAME=admin
|
||||||
|
# INTERCEPT_ADMIN_PASSWORD=your-password-here
|
||||||
|
|
||||||
|
# Postgres password (default: intercept)
|
||||||
|
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||||
|
|
||||||
|
# Auto-start ADS-B when dashboard loads
|
||||||
|
INTERCEPT_ADSB_AUTO_START=false
|
||||||
|
|
||||||
|
# Share observer location across all modules
|
||||||
|
INTERCEPT_SHARED_OBSERVER_LOCATION=true
|
||||||
|
|
||||||
|
# Observer coordinates (uncomment and set to skip GPS prompt)
|
||||||
|
# INTERCEPT_DEFAULT_LAT=40.7128
|
||||||
|
# INTERCEPT_DEFAULT_LON=-74.0060
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AGENT SETTINGS (for docker-compose.agent.yml on remote Pis)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Agent identity
|
||||||
|
AGENT_NAME=sdr-agent-1
|
||||||
|
AGENT_PORT=8020
|
||||||
|
|
||||||
|
# Controller connection (IP of the machine running docker-compose.yml)
|
||||||
|
CONTROLLER_URL=http://192.168.1.100:5050
|
||||||
|
AGENT_API_KEY=changeme
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Force LF line endings for files that must run on Linux (Docker)
|
||||||
|
*.sh text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
buy_me_a_coffee: smittix
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- run: pip install -r requirements-dev.txt
|
||||||
|
- run: ruff check .
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
group: ["a-l", "m-z"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- run: pip install -r requirements-dev.txt
|
||||||
|
- name: Run tests (${{ matrix.group }})
|
||||||
|
run: |
|
||||||
|
if [ "${{ matrix.group }}" = "a-l" ]; then
|
||||||
|
pytest tests/test_[a-l]*.py --tb=short -q
|
||||||
|
else
|
||||||
|
pytest tests/test_[m-z]*.py --tb=short -q
|
||||||
|
fi
|
||||||
|
continue-on-error: true
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
# Set permissions for GITHUB_TOKEN
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Step 1: Check out the repository code
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Step 2: Set up QEMU for multi-arch builds
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
# Step 3: Set up Docker Buildx for advanced features
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
# Step 4: Log in to GitHub Container Registry
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# Step 5: Generate tags and labels from Git metadata
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
# Tag with branch name
|
||||||
|
type=ref,event=branch
|
||||||
|
# Tag with PR number
|
||||||
|
type=ref,event=pr
|
||||||
|
# Tag with semver from git tag
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
# Tag with short SHA
|
||||||
|
type=sha,prefix=
|
||||||
|
|
||||||
|
# Step 6: Build and push the Docker image
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
# Only push on main branch and tags, not PRs
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
# Enable build cache for faster builds
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -14,6 +14,10 @@ uv.lock
|
|||||||
*.log
|
*.log
|
||||||
pager_messages.log
|
pager_messages.log
|
||||||
|
|
||||||
|
# Local data
|
||||||
|
downloads/
|
||||||
|
pgdata/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -30,5 +34,41 @@ dist/
|
|||||||
build/
|
build/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
|
||||||
# Package manager lock files
|
# Package manager lock files & DB files
|
||||||
uv.lock
|
uv.lock
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
intercept.db
|
||||||
|
|
||||||
|
# Instance folder (contains database with user data)
|
||||||
|
instance/
|
||||||
|
|
||||||
|
# Agent configs with real credentials (keep template only)
|
||||||
|
intercept_agent_*.cfg
|
||||||
|
!intercept_agent.cfg
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
/tmp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Weather satellite runtime data (decoded images, samples, SatDump output)
|
||||||
|
data/weather_sat/
|
||||||
|
|
||||||
|
# Radiosonde runtime data (station config, logs)
|
||||||
|
data/radiosonde/
|
||||||
|
|
||||||
|
# SDR capture files (large IQ recordings)
|
||||||
|
data/subghz/captures/
|
||||||
|
|
||||||
|
# Env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Local utility scripts
|
||||||
|
reset-sdr.*
|
||||||
|
.superpowers/
|
||||||
|
docs/superpowers/
|
||||||
|
|
||||||
|
# Git worktrees
|
||||||
|
.worktrees/
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.11.4
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, satellite tracking, ISS SSTV decoding, AIS vessel tracking, weather satellite imagery (NOAA APT & Meteor LRPT), and Meshtastic mesh networking.
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
### Docker (Primary)
|
||||||
|
```bash
|
||||||
|
# Build and run (basic profile)
|
||||||
|
docker compose --profile basic up -d
|
||||||
|
|
||||||
|
# Build and run with ADS-B history (Postgres)
|
||||||
|
docker compose --profile history up -d
|
||||||
|
|
||||||
|
# Rebuild after code changes
|
||||||
|
docker compose --profile basic up -d --build
|
||||||
|
|
||||||
|
# Multi-arch build (amd64 + arm64 for RPi)
|
||||||
|
./build-multiarch.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Setup (Alternative)
|
||||||
|
```bash
|
||||||
|
# First-time setup (interactive wizard with install profiles)
|
||||||
|
./setup.sh
|
||||||
|
|
||||||
|
# Or headless full install
|
||||||
|
./setup.sh --non-interactive
|
||||||
|
|
||||||
|
# Or install specific profiles
|
||||||
|
./setup.sh --profile=core,weather
|
||||||
|
|
||||||
|
# Run with production server (gunicorn + gevent, handles concurrent SSE/WebSocket)
|
||||||
|
sudo ./start.sh
|
||||||
|
|
||||||
|
# Or for quick local dev (Flask dev server)
|
||||||
|
sudo -E venv/bin/python intercept.py
|
||||||
|
|
||||||
|
# Other setup utilities
|
||||||
|
./setup.sh --health-check # Verify installation
|
||||||
|
./setup.sh --postgres-setup # Set up ADS-B history database
|
||||||
|
./setup.sh --menu # Force interactive menu
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
pytest tests/test_bluetooth.py
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
pytest --cov=routes --cov=utils
|
||||||
|
|
||||||
|
# Run a specific test
|
||||||
|
pytest tests/test_bluetooth.py::test_function_name -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting and Formatting
|
||||||
|
```bash
|
||||||
|
# Lint with ruff
|
||||||
|
ruff check .
|
||||||
|
|
||||||
|
# Auto-fix linting issues
|
||||||
|
ruff check --fix .
|
||||||
|
|
||||||
|
# Format with black
|
||||||
|
black .
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
mypy .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Entry Points
|
||||||
|
- `setup.sh` - Menu-driven installer with profile system (wizard, health check, PostgreSQL setup, env configurator, update, uninstall). Sources `.env` on startup via `start.sh`.
|
||||||
|
- `start.sh` - Production startup script (gunicorn + gevent auto-detection, CLI flags, HTTPS, `.env` sourcing, fallback to Flask dev server)
|
||||||
|
- `intercept.py` - Direct Flask dev server entry point (quick local development)
|
||||||
|
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure, conditional gevent monkey-patch
|
||||||
|
|
||||||
|
### Route Blueprints (routes/)
|
||||||
|
Each signal type has its own Flask blueprint:
|
||||||
|
- `pager.py` - POCSAG/FLEX decoding via rtl_fm + multimon-ng
|
||||||
|
- `sensor.py` - 433MHz IoT sensors via rtl_433
|
||||||
|
- `adsb.py` - Aircraft tracking via dump1090 (SBS protocol on port 30003)
|
||||||
|
- `acars.py` - Aircraft datalink messages via acarsdec
|
||||||
|
- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs)
|
||||||
|
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
|
||||||
|
- `satellite.py` - Pass prediction using TLE data
|
||||||
|
- `sstv.py` - ISS SSTV image decoding via slowrx
|
||||||
|
- `weather_sat.py` - NOAA APT & Meteor LRPT via SatDump
|
||||||
|
- `ais.py` - AIS vessel tracking and VHF DSC distress monitoring
|
||||||
|
- `aprs.py` - Amateur packet radio via direwolf
|
||||||
|
- `rtlamr.py` - Utility meter reading
|
||||||
|
- `meshtastic_routes.py` - Meshtastic LoRa mesh networking
|
||||||
|
|
||||||
|
### Core Utilities (utils/)
|
||||||
|
|
||||||
|
**SDR Abstraction Layer** (`utils/sdr/`):
|
||||||
|
- `SDRFactory` with factory pattern for multiple SDR types (RTL-SDR, LimeSDR, HackRF, Airspy, SDRPlay)
|
||||||
|
- Each type has a `CommandBuilder` for generating CLI commands
|
||||||
|
|
||||||
|
**Bluetooth Module** (`utils/bluetooth/`):
|
||||||
|
- Multi-backend: DBus/BlueZ primary, fallback for systems without BlueZ
|
||||||
|
- `aggregator.py` - Merges observations across time
|
||||||
|
- `tracker_signatures.py` - 47K+ known tracker fingerprints (AirTag, Tile, SmartTag)
|
||||||
|
- `heuristics.py` - Behavioral analysis for device classification
|
||||||
|
|
||||||
|
**TSCM (Counter-Surveillance)** (`utils/tscm/`):
|
||||||
|
- `baseline.py` - Snapshot "normal" RF environment
|
||||||
|
- `detector.py` - Compare current scan to baseline, flag anomalies
|
||||||
|
- `device_identity.py` - Track devices despite MAC randomization
|
||||||
|
- `correlation.py` - Cross-reference Bluetooth and WiFi observations
|
||||||
|
|
||||||
|
**WiFi Utilities** (`utils/wifi/`):
|
||||||
|
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
|
||||||
|
- `channel_analyzer.py` - Frequency band analysis
|
||||||
|
|
||||||
|
**Weather Satellite** (`utils/weather_sat.py`):
|
||||||
|
- Singleton `WeatherSatDecoder` using SatDump CLI for NOAA APT and Meteor LRPT
|
||||||
|
- Subprocess management with stdout parsing, image watcher via rglob
|
||||||
|
- Pass prediction using skyfield TLE data
|
||||||
|
|
||||||
|
**SSTV Decoder** (`utils/sstv.py`):
|
||||||
|
- ISS SSTV reception via slowrx with Doppler tracking
|
||||||
|
- Singleton pattern, image gallery with timestamped filenames
|
||||||
|
|
||||||
|
### Key Patterns
|
||||||
|
|
||||||
|
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. Under gunicorn + gevent, each SSE connection is a lightweight greenlet instead of an OS thread.
|
||||||
|
|
||||||
|
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
|
||||||
|
|
||||||
|
**Data Stores**: `DataStore` class with TTL-based automatic cleanup (WiFi: 10min, Bluetooth: 5min, Aircraft: 5min).
|
||||||
|
|
||||||
|
**Input Validation**: Centralized in `utils/validation.py` - always validate frequencies, gains, device indices before spawning processes.
|
||||||
|
|
||||||
|
### External Tool Integrations
|
||||||
|
|
||||||
|
| Tool | Purpose | Integration |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| rtl_fm | FM demodulation | Subprocess, pipes to multimon-ng |
|
||||||
|
| multimon-ng | Pager decoding | Reads from rtl_fm stdout |
|
||||||
|
| rtl_433 | 433MHz sensors | JSON output parsing |
|
||||||
|
| dump1090 | ADS-B decoding | SBS protocol socket (port 30003) |
|
||||||
|
| acarsdec | ACARS messages | Output parsing |
|
||||||
|
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
|
||||||
|
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
|
||||||
|
| slowrx | SSTV decoding | Subprocess with audio pipe |
|
||||||
|
| SatDump | Weather satellites | CLI live mode, NOAA APT + Meteor LRPT |
|
||||||
|
| AIS-catcher | AIS vessel tracking | JSON output parsing |
|
||||||
|
| direwolf | APRS | TNC modem for packet radio |
|
||||||
|
|
||||||
|
### Frontend Structure
|
||||||
|
- **Templates**: `templates/index.html` (main SPA), `templates/partials/modes/*.html` (sidebar panels), `templates/partials/nav.html` (global nav)
|
||||||
|
- **JS Modules**: `static/js/modes/*.js` - IIFE pattern per mode (e.g., `WeatherSat`, `SSTV`, `Meshtastic`)
|
||||||
|
- **CSS**: `static/css/modes/*.css` - scoped styles per mode, CSS variables for theming (`--bg-card`, `--accent-cyan`, `--font-mono`)
|
||||||
|
- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()`
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.). CMD runs `start.sh` (gunicorn + gevent)
|
||||||
|
- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B)
|
||||||
|
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
|
||||||
|
- Data persisted via `./data:/app/data` volume mount
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- `config.py` - Environment variable support with `INTERCEPT_` prefix (e.g., `INTERCEPT_PORT`, `INTERCEPT_WEATHER_SAT_GAIN`)
|
||||||
|
- Database: SQLite in `instance/` directory for settings, baselines, history
|
||||||
|
|
||||||
|
## Testing Notes
|
||||||
|
|
||||||
|
Tests use pytest with extensive mocking of external tools. Key fixtures in `tests/conftest.py`. Mock subprocess calls when testing decoder integration.
|
||||||
@@ -2,6 +2,559 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
|
## [2.27.0] - 2026-05-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Two-window hang** — Opening the app in two browser tabs/windows caused it to become completely unresponsive. Root cause: HTTP/1.1 limits browsers to 6 connections per origin (shared across all tabs). VoiceAlerts was automatically opening 3 SSE streams per window on page load, so two windows produced 8 persistent connections and permanently blocked all regular HTTP requests. VoiceAlerts streams are now opt-in (disabled by default); users can enable them in settings.
|
||||||
|
- **Alert messages split between windows** — The `/alerts/stream` SSE endpoint read from a single queue, so two windows would each receive only half the alerts. Now uses `sse_stream_fanout` so every window gets every alert.
|
||||||
|
- **Bluetooth v2 stream split between windows** — Same single-queue issue in `/api/bluetooth/stream`. Fixed with fanout via `subscribe_fanout_queue`, preserving named SSE events (`device_update`, `scan_started`, etc.).
|
||||||
|
- **ICAO lookup cache unbounded growth** — `_looked_up_icaos` set was never evicted; capped at 50 000 entries with LRU eviction to prevent memory growth under sustained ADS-B load.
|
||||||
|
- **Concurrent ICAO clear race** — `popitem()` on the ICAO dict could raise `RuntimeError` if a clear happened concurrently; guarded with try/except.
|
||||||
|
- **Bluetooth tracker fingerprint stability** — Tracker signature scan was incorrectly resetting stability counters on unchanged payloads; now skips the scan when the BLE payload fingerprint is unchanged.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **UI Tier system** — Three display modes selectable from the nav bar: *Lean* (minimal, no decorative elements), *Standard* (default), and *Enhanced* (full animations and ambient effects). Replaces the old animations toggle.
|
||||||
|
- **Display mode in first-run setup** — The first-run modal now includes a display mode selection step so new users can pick their preferred visual style during initial setup.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- ADS-B SSE snapshot priming moved inside the response generator (avoids blocking before headers are sent).
|
||||||
|
- WiFi network filter combined into a single list pass instead of chained filters.
|
||||||
|
- Bluetooth tracker signature scan skips processing when the BLE payload fingerprint is unchanged.
|
||||||
|
- `DataStore` cleanup minimises lock hold time by collecting expired keys before acquiring the write lock.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.11] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **APRS map ignores configured observer position** — The APRS map always fell back to the centre of the US (39.8°N, 98.6°W) when no live GPS fix was available, ignoring the observer position configured in `.env` (`INTERCEPT_DEFAULT_LAT` / `INTERCEPT_DEFAULT_LON`). Now seeds the APRS user location from the shared observer location on page load, so the map centres correctly and distance calculations work. (#193)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.10] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **APRS stop timeout and inverted SDR device status** — The APRS stop endpoint terminated two processes sequentially (up to 4s) while the frontend timed out at 2.2s, causing console errors and the SDR status panel to show stale state (active after stop, idle during use). Now releases the SDR device immediately and terminates processes in a background thread so the response returns instantly. (#194)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.9] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **ADS-B bias-t support for RTL-SDR Blog V4** — When dump1090 lacks native `--enable-biast` support, the system now falls back to `rtl_biast` (from RTL-SDR Blog drivers) to enable bias-t power before starting dump1090. The Blog V4's built-in LNA requires bias-t to receive ADS-B signals. (#195)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.8] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **acarsdec build failure on macOS** — `HOST_NAME_MAX` is Linux-specific (`<limits.h>`) and undefined on macOS, causing 3 compile errors in `acarsdec.c`. Now patched with `#define HOST_NAME_MAX 255` before building. Also fixed deprecated `-Ofast` flag warning on all macOS architectures (was only patched for arm64). (#187)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.7] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Health check SDR detection on macOS** — `timeout` (GNU coreutils) is not available on macOS, causing `rtl_test` to silently fail and report "No RTL-SDR device found" even when one is connected. Now tries `timeout`, then `gtimeout` (Homebrew coreutils), then falls back to a background process with manual kill. (#188)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.6] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Oversized branded 'i' logo on dashboards** — `.logo span { display: inline }` in dashboard CSS had higher specificity (0,1,1) than `.brand-i { display: inline-block }` (0,1,0), forcing the branded "i" SVG to render as inline which ignores width/height. Added `.logo .brand-i` selector (0,2,0) to retain `inline-block` display. (#189)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.5] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Database errors crash entire UI** — `get_setting()` now catches `sqlite3.OperationalError` and returns the default value instead of propagating the exception. Previously, if the database was inaccessible (e.g. root-owned `instance/` directory from running with `sudo`), the `inject_offline_settings` context processor would crash every page render with a 500 Internal Server Error. (#190)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.4] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Environment Configurator crash** — `read_env_var()` crashed with "Setup failed at line 2333" when `.env` existed but didn't contain the variable being looked up. `grep` returned exit code 1 (no match), which `pipefail` propagated and `set -e` turned into a fatal error. Fixed by appending `|| true` to the pipeline. (#191)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.3] - 2026-03-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **SatDump AVX2 crash** — SatDump now compiles with `-march=x86-64` on x86_64 platforms (Docker and `setup.sh`), preventing "Illegal instruction" crashes on CPUs without AVX2. SIMD plugins still use runtime detection for acceleration on capable hardware. (#185)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.2] - 2026-03-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Docker startup crash** — `.dockerignore` excluded the entire `data/` directory, which is now a Python package (`data.oui`, `data.patterns`, `data.satellites`). Caused `ModuleNotFoundError: No module named 'data.oui'` on container startup. Fixed by only excluding non-code files from `data/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.1] - 2026-03-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Default admin credentials** — Default `ADMIN_PASSWORD` changed from empty string to `admin`, matching the README documentation (`admin:admin`)
|
||||||
|
- **Config credential sync** — Admin password changes in `config.py` or via `INTERCEPT_ADMIN_PASSWORD` env var now sync to the database on restart, without needing to delete the DB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.0] - 2026-03-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **SSE fanout crash** - `_run_fanout` daemon thread no longer crashes with `AttributeError: 'NoneType' object has no attribute 'get'` when source queue becomes None during interpreter shutdown
|
||||||
|
- **Branded logo FOUC** - Added inline `width`/`height` to branded "i" SVG elements across 10 templates to prevent oversized rendering before CSS loads; refresh no longer needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.25.0] - 2026-03-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **SSEManager** - Centralized SSE connection management with exponential backoff reconnection and visual connection status indicator
|
||||||
|
- **Loading button states** - `withLoadingButton()` utility for async action buttons across all modes
|
||||||
|
- **Actionable error reporting** - `reportActionableError()` added to 5 mode JS files for user-friendly error messages
|
||||||
|
- **Destructive action confirmation modals** - Custom modal system replacing 25 native `confirm()` calls
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Accessibility improvements** - aria-labels on interactive elements, form label associations, keyboard-navigable lists
|
||||||
|
- **CSS variable adoption** - Replaced hardcoded hex colors with CSS custom properties across 16+ files
|
||||||
|
- **Inline style extraction** - `classList.toggle()` replaces inline `display` manipulation throughout codebase
|
||||||
|
- **Merged `global-nav.css` into `layout.css`** - Consolidated navigation styles
|
||||||
|
- **Reduced `!important` usage** - Responsive.css `!important` count reduced from 71 to 8
|
||||||
|
- **Standardized breakpoints** - Unified to 480/768/1024/1280px across all responsive styles
|
||||||
|
- **Mobile UX polish** - Improved touch targets, code overflow handling, and responsive layouts
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Deep-linked mode scripts now wait for body parse before executing, preventing initialization failures
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.24.0] - 2026-03-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **WiFi Locate Mode** - Locate WiFi access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones. Hand-off from WiFi detail drawer, environment presets (Free Space/Outdoor/Indoor), and signal-lost detection.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Mobile navigation bar reorganized into labeled groups (SIG, TRK, SPC, WIFI, INTEL, SYS) for better usability
|
||||||
|
- flask-limiter made optional — rate limiting degrades gracefully if package is missing
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Radiosonde setup missing `semver` Python dependency — `setup.sh` now explicitly installs it alongside `requirements.txt`
|
||||||
|
|
||||||
|
## [2.23.0] - 2026-02-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Radiosonde Weather Balloon Tracking** - 400-406 MHz reception via radiosonde_auto_rx with telemetry, map, and station distance tracking
|
||||||
|
- **CW/Morse Code Decoder** - Custom Goertzel tone detection with OOK/AM envelope detection mode for ISM bands
|
||||||
|
- **WeFax (Weather Fax) Decoder** - HF weather fax reception with auto-scheduler, broadcast timeline, and image gallery
|
||||||
|
- **System Health Monitoring** - Telemetry dashboard with process monitoring and system metrics
|
||||||
|
- **HTTPS Support** - TLS via `INTERCEPT_HTTPS` configuration
|
||||||
|
- **ADS-B Voice Alerts** - Text-to-speech notifications for military and emergency aircraft detections
|
||||||
|
- **HackRF TSCM RF Scan** - HackRF support added to TSCM counter-surveillance RF sweep
|
||||||
|
- **Multi-SDR WeFax** - Multiple SDR hardware support for WeFax decoder
|
||||||
|
- **Tool Path Overrides** - `INTERCEPT_*_PATH` environment variables for custom tool locations
|
||||||
|
- **Homebrew Tool Detection** - Native path detection for Apple Silicon Homebrew installations
|
||||||
|
- **Production Server** - `start.sh` with gunicorn + gevent for concurrent SSE/WebSocket handling — eliminates multi-client page load delays
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Morse decoder rebuilt with custom Goertzel decoder, replacing multimon-ng dependency
|
||||||
|
- GPS mode upgraded to textured 3D globe visualization
|
||||||
|
- Destroy lifecycle added to all mode modules to prevent resource leaks
|
||||||
|
- Docker container now uses gunicorn + gevent by default via `start.sh`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- ADS-B device release leak and startup performance regression
|
||||||
|
- ADS-B probe incorrectly treating "No devices found" as success
|
||||||
|
- USB claim race condition after SDR probe
|
||||||
|
- SDR device registry collision when multiple SDR types present
|
||||||
|
- APRS 15-minute startup delay caused by pipe buffering
|
||||||
|
- APRS map centering at [0,0] when GPS unavailable
|
||||||
|
- DSC decoder ITU-R M.493 compliance issues
|
||||||
|
- Weather satellite 0dB SNR — increased sample rate for Meteor LRPT
|
||||||
|
- SSE fanout backlog causing delayed updates across all modes
|
||||||
|
- SSE reconnect packet loss during client reconnection
|
||||||
|
- Waterfall monitor tuning race conditions
|
||||||
|
- Mode FOUC (flash of unstyled content) on initial navigation
|
||||||
|
- Various Morse decoder stability and lifecycle fixes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.22.3] - 2026-02-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Waterfall control panel rendered as unstyled text for up to 20 seconds on first visit — CSS is now loaded eagerly with the rest of the page assets
|
||||||
|
- WebSDR globe failed to render on first page load — initialization now waits for a layout frame before mounting the WebGL renderer, ensuring the container has non-zero dimensions
|
||||||
|
- Waterfall monitor audio took minutes to start — `_waitForPlayback` now only reports success on actual audio playback (`playing`/`timeupdate`), not from the WAV header alone (`loadeddata`/`canplay`)
|
||||||
|
- Waterfall monitor could not be stopped — `stopMonitor()` now pauses audio and updates the UI immediately instead of waiting for the backend stop request (which blocked for 1+ seconds during SDR process cleanup)
|
||||||
|
- Stopping the waterfall no longer shows a stale "WebSocket closed before ready" message — the `onclose` handler now detects intentional closes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.22.1] - 2026-02-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- PWA install prompt not appearing — manifest now includes required PNG icons (192×192, 512×512)
|
||||||
|
- Apple touch icon updated to PNG for iOS Safari compatibility
|
||||||
|
- Service worker cache bumped to bust stale cached assets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.22.0] - 2026-02-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Waterfall Receiver Overhaul** - WebSocket-based I/Q streaming with server-side FFT, click-to-tune, zoom controls, and auto-scaling
|
||||||
|
- **Voice Alerts** - Configurable text-to-speech event notifications across modes
|
||||||
|
- **Signal Fingerprinting** - RF device identification and pattern analysis mode
|
||||||
|
- **SignalID** - Automatic signal classification via SigIDWiki API integration
|
||||||
|
- **PWA Support** - Installable web app with service worker caching and manifest
|
||||||
|
- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes
|
||||||
|
- **ADS-B MSG2 Surface Parsing** - Ground vehicle movement tracking from MSG2 frames
|
||||||
|
- **Cheat Sheets** - Quick reference overlays for keyboard shortcuts and mode controls
|
||||||
|
- App icon (SVG) for PWA and browser tab
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **WebSDR overhaul** - Improved receiver management, audio streaming, and UI
|
||||||
|
- **Mode stop responsiveness** - Faster timeout handling and improved WiFi/Bluetooth scanner shutdown
|
||||||
|
- **Mode transitions** - Smoother navigation with performance instrumentation
|
||||||
|
- **BT Locate** - Refactored JS engine with improved trail management and signal smoothing
|
||||||
|
- **Listening Post** - Refactored with cross-module frequency routing
|
||||||
|
- **SSTV decoder** - State machine improvements and partial image streaming
|
||||||
|
- Analytics mode removed; per-mode analytics panels integrated into existing dashboards
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- ADS-B SSE multi-client fanout stability and update flush timing
|
||||||
|
- WiFi scanner robustness and monitor mode teardown reliability
|
||||||
|
- Agent client reliability improvements for remote sensor nodes
|
||||||
|
- SSTV VIS detector state reporting in signal monitor diagnostics
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Complete documentation audit across README, FEATURES, USAGE, help modal, and GitHub Pages
|
||||||
|
- Fixed license badge (MIT → Apache 2.0) to match actual LICENSE file
|
||||||
|
- Fixed tool name `rtl_amr` → `rtlamr` throughout all docs
|
||||||
|
- Fixed incorrect entry point examples (`python app.py` → `sudo -E venv/bin/python intercept.py`)
|
||||||
|
- Removed duplicate AIS Vessel Tracking section from FEATURES.md
|
||||||
|
- Updated SSTV requirements: pure Python decoder, no external `slowrx` needed
|
||||||
|
- Added ACARS and VDL2 mode descriptions to in-app help modal
|
||||||
|
- GitHub Pages site: corrected Docker command, license, and tool name references
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.21.1] - 2026-02-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- BT Locate map first-load rendering race that could cause blank/late map initialization
|
||||||
|
- BT Locate mode switch timing so Leaflet invalidation runs after panel visibility settles
|
||||||
|
- BT Locate trail restore startup latency by batching historical GPS point rendering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.21.0] - 2026-02-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Analytics panels for operational insights and temporal pattern analysis
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Global map theme refresh with improved contrast and cross-dashboard consistency
|
||||||
|
- Cross-app UX refinements for accessibility, mode consistency, and render performance
|
||||||
|
- BT Locate enhancements including improved continuity, smoothing, and confidence reporting
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Weather satellite auto-scheduler and Mercator tracking reliability issues
|
||||||
|
- Bluetooth/WiFi runtime health issues affecting scanner continuity
|
||||||
|
- ADS-B SSE multi-client fanout stability and remote VDL2 streaming reliability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.15.0] - 2026-02-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT
|
||||||
|
- Click-to-tune, zoom controls, and auto-scaling quantization
|
||||||
|
- Shared waterfall UI across SDR modes with function bar controls
|
||||||
|
- WebSocket frame serialization and connection reuse
|
||||||
|
- **Cross-Module Frequency Routing** - Tune from Listening Post directly to decoders
|
||||||
|
- **Pure Python SSTV Decoder** - Replaces broken slowrx C dependency
|
||||||
|
- Real-time decode progress with partial image streaming
|
||||||
|
- VIS detector state in signal monitor diagnostics
|
||||||
|
- Image gallery with delete and download functionality
|
||||||
|
- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes
|
||||||
|
- **SSTV Image Gallery** - Delete and download decoded images
|
||||||
|
- **USB Device Probe** - Detect broken SDR devices before rtl_fm crashes
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- DMR dsd-fme protocol flags, device label, and tuning controls
|
||||||
|
- DMR frontend/backend state desync causing 409 on start
|
||||||
|
- Digital voice decoder producing no output due to wrong dsd-fme flags
|
||||||
|
- SDR device lock-up from unreleased device registry on process crash
|
||||||
|
- APRS crash on large station count and station list overflow
|
||||||
|
- Settings modal overflowing viewport on smaller screens
|
||||||
|
- Waterfall crash on zoom by reusing WebSocket and adding USB release retry
|
||||||
|
- PD120 SSTV decode hang and false leader tone detection
|
||||||
|
- WebSocket waterfall blocked by login redirect
|
||||||
|
- TSCM sweep KeyError on RiskLevel.NEEDS_REVIEW
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- GSM Spy functionality removed for legal compliance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.14.0] - 2026-02-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **DMR Digital Voice Decoder** - Decode DMR, P25, NXDN, and D-STAR protocols
|
||||||
|
- Integration with dsd-fme (Digital Speech Decoder - Florida Man Edition)
|
||||||
|
- Real-time SSE streaming of sync, call, voice, and slot events
|
||||||
|
- Call history table with talkgroup, source ID, and protocol tracking
|
||||||
|
- Protocol auto-detection or manual selection
|
||||||
|
- Pipeline error diagnostics with rtl_fm stderr capture
|
||||||
|
- **DMR Visual Synthesizer** - Canvas-based signal activity visualization
|
||||||
|
- Spring-physics animated bars reacting to SSE decoder events
|
||||||
|
- Color-coded by event type: cyan (sync), green (call), orange (voice)
|
||||||
|
- Center-outward ripple bursts on sync events
|
||||||
|
- Smooth decay and idle breathing animation
|
||||||
|
- Responsive canvas with window resize handling
|
||||||
|
- **HF SSTV General Mode** - Terrestrial slow-scan TV on shortwave frequencies
|
||||||
|
- Predefined HF SSTV frequencies (14.230, 21.340, 28.680 MHz, etc.)
|
||||||
|
- Modulation support for USB/LSB reception
|
||||||
|
- **WebSDR Integration** - Remote HF/shortwave listening via WebSDR servers
|
||||||
|
- **Listening Post Enhancements** - Improved signal scanner and audio handling
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- APRS rtl_fm startup failure and SDR device conflicts
|
||||||
|
- DSD voice decoder detection for dsd-fme and PulseAudio errors
|
||||||
|
- dsd-fme protocol flags and ncurses disable for headless operation
|
||||||
|
- dsd-fme audio output flag for pipeline compatibility
|
||||||
|
- TSCM sweep scan resilience with per-device error isolation
|
||||||
|
- TSCM WiFi detection using scanner singleton for device availability
|
||||||
|
- TSCM correlation and cluster emission fixes
|
||||||
|
- Detected Threats panel items now clickable to show device details
|
||||||
|
- Proximity radar tooltip flicker on hover
|
||||||
|
- Radar blip flicker by deferring renders during hover
|
||||||
|
- ISS position API priority swap to avoid timeout delays
|
||||||
|
- Updater settings panel error when updater.js is blocked
|
||||||
|
- Missing scapy in optionals dependency group
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.13.1] - 2026-02-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **UI Overhaul** - Revamped styling with slate/cyan theme
|
||||||
|
- Switched app font to JetBrains Mono
|
||||||
|
- Global navigation bar across all dashboards
|
||||||
|
- Cyan-tinted map tiles as default
|
||||||
|
- **Signal Scanner Rewrite** - Switched to rtl_power sweep for better coverage
|
||||||
|
- SNR column added to signal hits table
|
||||||
|
- SNR threshold control for power scan
|
||||||
|
- Improved sweep progress tracking and stability
|
||||||
|
- Frequency-based sweep display with range syncing
|
||||||
|
- **Listening Post Audio** - WAV streaming with retry and fallback
|
||||||
|
- WebSocket audio fallback for listening
|
||||||
|
- User-initiated audio play prompt
|
||||||
|
- Audio pipeline restart for fresh stream headers
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- WiFi connected clients panel now filters to selected AP instead of showing all clients
|
||||||
|
- USB device contention when starting audio pipeline
|
||||||
|
- Dual scrollbar issue on main dashboard
|
||||||
|
- Controls bar alignment in dashboard pages
|
||||||
|
- Mode query routing from dashboard nav
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.13.0] - 2026-02-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **WiFi Client Display** - Connected clients shown in AP detail drawer
|
||||||
|
- Real-time client updates via SSE streaming
|
||||||
|
- Probed SSID badges for connected clients
|
||||||
|
- Signal strength indicators and vendor identification
|
||||||
|
- **Help Modal** - Keyboard shortcuts reference system
|
||||||
|
- **Main Dashboard Button** - Quick navigation from any page
|
||||||
|
- **Settings Modal** - Accessible from all dashboards
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Dashboard CSS improvements and consistency fixes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.12.1] - 2026-02-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **SDR Device Registry** - Prevents decoder conflicts between concurrent modes
|
||||||
|
- **SDR Device Status Panel** - Shows connected SDR devices with ADS-B Bias-T toggle
|
||||||
|
- **Real-time Doppler Tracking** - ISS SSTV reception with Doppler correction
|
||||||
|
- **TCP Connection Support** - Meshtastic devices connectable over TCP
|
||||||
|
- **Shared Observer Location** - Configurable shared location with auto-start options
|
||||||
|
- **slowrx Source Build** - Fallback build for Debian/Ubuntu
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- SDR device type not synced on page refresh
|
||||||
|
- Meshtastic connection type not restored on page refresh
|
||||||
|
- WiFi deep scan polling on agent with normalized scan_type value
|
||||||
|
- Auto-detect RTL-SDR drivers and blacklist instead of prompting
|
||||||
|
- TPMS pressure field mappings for 433MHz sensor display
|
||||||
|
- Agent capabilities cache invalidation after monitor mode toggle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.12.0] - 2026-01-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **ISS SSTV Decoder Mode** - Receive Slow Scan Television transmissions from the ISS
|
||||||
|
- Real-time ISS tracking globe with accurate position via N2YO API
|
||||||
|
- Leaflet world map showing ISS ground track and current position
|
||||||
|
- Location settings for ISS pass predictions
|
||||||
|
- Integration with satellite tracking TLE data
|
||||||
|
- **GitHub Update Notifications** - Automatic new version alerts
|
||||||
|
- Checks for updates on app startup
|
||||||
|
- Unobtrusive notification when new releases are available
|
||||||
|
- Configurable check interval via settings
|
||||||
|
- **Meshtastic Enhancements**
|
||||||
|
- QR code support for easy device sharing
|
||||||
|
- Telemetry display with battery, voltage, and environmental data
|
||||||
|
- Traceroute visualization for mesh network topology
|
||||||
|
- Improved node synchronization between map and top bar
|
||||||
|
- **UI Improvements**
|
||||||
|
- New Space category for satellite and ISS-related modes
|
||||||
|
- Pulsating ring effect for tracked aircraft/vessels
|
||||||
|
- Map marker highlighting for selected aircraft in ADS-B
|
||||||
|
- Consolidated settings and dependencies into single modal
|
||||||
|
- **Auto-Update TLE Data** - Satellite tracking data updates automatically on app startup
|
||||||
|
- **GPS Auto-Connect** - AIS dashboard now connects to gpsd automatically
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Utility Meters** - Added device grouping by ID with consumption trends
|
||||||
|
- **Utility Meters** - Device intelligence and manufacturer information display
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **SoapySDR** - Module detection on macOS with Homebrew
|
||||||
|
- **dump1090** - Build failures in Docker containers
|
||||||
|
- **dump1090** - Build failures on Kali Linux and newer GCC versions
|
||||||
|
- **Flask** - Ensure Flask 3.0+ compatibility in setup script
|
||||||
|
- **psycopg2** - Now optional for Flask/Werkzeug compatibility
|
||||||
|
- **Bias-T** - Setting now properly passed to ADS-B and AIS dashboards
|
||||||
|
- **Dark Mode Maps** - Removed CSS filter that was inverting dark tiles
|
||||||
|
- **Map Tiles** - Fixed CARTO tile URLs and added cache-busting
|
||||||
|
- **Meshtastic** - Traceroute button and dark mode map fixes
|
||||||
|
- **ADS-B Dashboard** - Height adjustment to prevent bottom controls cutoff
|
||||||
|
- **Audio Visualizer** - Now works without spectrum canvas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.11.0] - 2026-01-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Meshtastic Mesh Network Integration** - LoRa mesh communication support
|
||||||
|
- Connect to Meshtastic devices (Heltec, T-Beam, RAK) via USB/Serial
|
||||||
|
- Real-time message streaming via SSE
|
||||||
|
- Channel configuration with encryption key support
|
||||||
|
- Node information display with signal metrics (RSSI, SNR)
|
||||||
|
- Message history with up to 500 messages
|
||||||
|
- **Ubertooth One BLE Scanner** - Advanced Bluetooth scanning
|
||||||
|
- Passive BLE packet capture across all 40 BLE channels
|
||||||
|
- Raw advertising payload access
|
||||||
|
- Integration with existing Bluetooth scanning modes
|
||||||
|
- Automatic detection of Ubertooth hardware
|
||||||
|
- **Offline Mode** - Run iNTERCEPT without internet connectivity
|
||||||
|
- Bundled Leaflet 1.9.4 (JS, CSS, marker images)
|
||||||
|
- Bundled Chart.js 4.4.1
|
||||||
|
- Bundled Inter and JetBrains Mono fonts (woff2)
|
||||||
|
- Local asset status checking and validation
|
||||||
|
- **Settings Modal** - New configuration interface accessible from navigation
|
||||||
|
- Offline tab: Toggle offline mode, configure asset sources
|
||||||
|
- Display tab: Theme and animation preferences
|
||||||
|
- About tab: Version info and links
|
||||||
|
- **Multiple Map Tile Providers** - Choose from:
|
||||||
|
- OpenStreetMap (default)
|
||||||
|
- CartoDB Dark
|
||||||
|
- CartoDB Positron (light)
|
||||||
|
- ESRI World Imagery
|
||||||
|
- Custom tile server URL
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Dashboard Templates** - Conditional asset loading based on offline settings
|
||||||
|
- **Bluetooth Scanner** - Added Ubertooth backend alongside BlueZ/DBus
|
||||||
|
- **Dependencies** - Added meshtastic SDK to requirements.txt
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- Added `routes/meshtastic.py` for Meshtastic API endpoints
|
||||||
|
- Added `utils/meshtastic.py` for device management
|
||||||
|
- Added `utils/bluetooth/ubertooth_scanner.py` for Ubertooth support
|
||||||
|
- Added `routes/offline.py` for offline mode API
|
||||||
|
- Added `static/js/core/settings-manager.js` for client-side settings
|
||||||
|
- Added `static/css/settings.css` for settings modal styles
|
||||||
|
- Added `static/css/modes/meshtastic.css` for Meshtastic UI
|
||||||
|
- Added `static/js/modes/meshtastic.js` for Meshtastic frontend
|
||||||
|
- Added `templates/partials/modes/meshtastic.html` for Meshtastic mode
|
||||||
|
- Added `templates/partials/settings-modal.html` for settings UI
|
||||||
|
- Added `static/vendor/` directory structure for bundled assets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.10.0] - 2026-01-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **AIS Vessel Tracking** - Real-time ship tracking via AIS-catcher
|
||||||
|
- Full-screen dashboard with interactive maritime map
|
||||||
|
- Vessel details: name, MMSI, callsign, destination, ETA
|
||||||
|
- Navigation data: speed, course, heading, rate of turn
|
||||||
|
- Ship type classification and dimensions
|
||||||
|
- Multi-SDR support (RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay)
|
||||||
|
- **VHF DSC Channel 70 Monitoring** - Digital Selective Calling for maritime distress
|
||||||
|
- Real-time decoding of DSC messages (Distress, Urgency, Safety, Routine)
|
||||||
|
- MMSI country identification via Maritime Identification Digits (MID) lookup
|
||||||
|
- Position extraction and map markers for distress alerts
|
||||||
|
- Prominent visual overlay for DISTRESS and URGENCY alerts
|
||||||
|
- Permanent database storage for critical alerts with acknowledgement workflow
|
||||||
|
- **Spy Stations Database** - Number stations and diplomatic HF networks
|
||||||
|
- Comprehensive database from priyom.org
|
||||||
|
- Station profiles with frequencies, schedules, operators
|
||||||
|
- Filter by type (number/diplomatic), country, and mode
|
||||||
|
- Tune integration with Listening Post
|
||||||
|
- Famous stations: UVB-76, Cuban HM01, Israeli E17z
|
||||||
|
- **SDR Device Conflict Detection** - Prevents collisions between AIS and DSC
|
||||||
|
- **DSC Alert Summary** - Dashboard counts for unacknowledged distress/urgency alerts
|
||||||
|
- **AIS-catcher Installation** - Added to setup.sh for Debian and macOS
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **UI Labels** - Renamed "Scanner" to "Listening Post" and "RTLAMR" to "Meters"
|
||||||
|
- **Pager Filter** - Changed from onchange to oninput for real-time filtering
|
||||||
|
- **Vessels Dashboard** - Now includes VHF DSC message panel alongside AIS tracking
|
||||||
|
- **Dependencies** - Added scipy and numpy for DSC signal processing
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **DSC Position Decoder** - Corrected octal literal in quadrant check
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.9.5] - 2026-01-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **MAC-Randomization Resistant Detection** - TSCM now identifies devices using randomized MAC addresses
|
||||||
|
- **Clickable Score Cards** - Click on threat scores to see detailed findings
|
||||||
|
- **Device Detail Expansion** - Click-to-expand device details in TSCM results
|
||||||
|
- **Root Privilege Check** - Warning display when running without required privileges
|
||||||
|
- **Real-time Device Streaming** - Devices stream to dashboard during TSCM sweep
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **TSCM Correlation Engine** - Improved device correlation with comprehensive reporting
|
||||||
|
- **Device Classification System** - Enhanced threat classification and scoring
|
||||||
|
- **WiFi Scanning** - Improved scanning reliability and device naming
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **RF Scanning** - Fixed scanning issues with improved status feedback
|
||||||
|
- **TSCM Modal Readability** - Improved modal styling and close button visibility
|
||||||
|
- **Linux Device Detection** - Added more fallback methods for device detection
|
||||||
|
- **macOS Device Detection** - Fixed TSCM device detection on macOS
|
||||||
|
- **Bluetooth Event Type** - Fixed device type being overwritten
|
||||||
|
- **rtl_433 Bias-T Flag** - Corrected bias-t flag handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.9.0] - 2026-01-10
|
## [2.9.0] - 2026-01-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, satellite tracking, ISS SSTV decoding, AIS vessel tracking, weather satellite imagery (NOAA APT & Meteor LRPT), and Meshtastic mesh networking.
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
### Docker (Primary)
|
||||||
|
```bash
|
||||||
|
# Build and run (basic profile)
|
||||||
|
docker compose --profile basic up -d
|
||||||
|
|
||||||
|
# Build and run with ADS-B history (Postgres)
|
||||||
|
docker compose --profile history up -d
|
||||||
|
|
||||||
|
# Rebuild after code changes
|
||||||
|
docker compose --profile basic up -d --build
|
||||||
|
|
||||||
|
# Multi-arch build (amd64 + arm64 for RPi)
|
||||||
|
./build-multiarch.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Setup (Alternative)
|
||||||
|
```bash
|
||||||
|
# First-time setup (interactive wizard with install profiles)
|
||||||
|
./setup.sh
|
||||||
|
|
||||||
|
# Or headless full install
|
||||||
|
./setup.sh --non-interactive
|
||||||
|
|
||||||
|
# Or install specific profiles
|
||||||
|
./setup.sh --profile=core,weather
|
||||||
|
|
||||||
|
# Run with production server (gunicorn + gevent, handles concurrent SSE/WebSocket)
|
||||||
|
sudo ./start.sh
|
||||||
|
|
||||||
|
# Or for quick local dev (Flask dev server)
|
||||||
|
sudo -E venv/bin/python intercept.py
|
||||||
|
|
||||||
|
# Other setup utilities
|
||||||
|
./setup.sh --health-check # Verify installation
|
||||||
|
./setup.sh --postgres-setup # Set up ADS-B history database
|
||||||
|
./setup.sh --menu # Force interactive menu
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
pytest tests/test_bluetooth.py
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
pytest --cov=routes --cov=utils
|
||||||
|
|
||||||
|
# Run a specific test
|
||||||
|
pytest tests/test_bluetooth.py::test_function_name -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting and Formatting
|
||||||
|
```bash
|
||||||
|
# Lint with ruff
|
||||||
|
ruff check .
|
||||||
|
|
||||||
|
# Auto-fix linting issues
|
||||||
|
ruff check --fix .
|
||||||
|
|
||||||
|
# Format with black
|
||||||
|
black .
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
mypy .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Entry Points
|
||||||
|
- `setup.sh` - Menu-driven installer with profile system (wizard, health check, PostgreSQL setup, env configurator, update, uninstall). Sources `.env` on startup via `start.sh`.
|
||||||
|
- `start.sh` - Production startup script (gunicorn + gevent auto-detection, CLI flags, HTTPS, `.env` sourcing, fallback to Flask dev server)
|
||||||
|
- `intercept.py` - Direct Flask dev server entry point (quick local development)
|
||||||
|
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure, conditional gevent monkey-patch
|
||||||
|
|
||||||
|
### Route Blueprints (routes/)
|
||||||
|
Each signal type has its own Flask blueprint:
|
||||||
|
- `pager.py` - POCSAG/FLEX decoding via rtl_fm + multimon-ng
|
||||||
|
- `sensor.py` - 433MHz IoT sensors via rtl_433
|
||||||
|
- `adsb.py` - Aircraft tracking via dump1090 (SBS protocol on port 30003)
|
||||||
|
- `acars.py` - Aircraft datalink messages via acarsdec
|
||||||
|
- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs)
|
||||||
|
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
|
||||||
|
- `satellite.py` - Pass prediction using TLE data
|
||||||
|
- `sstv.py` - ISS SSTV image decoding via slowrx
|
||||||
|
- `weather_sat.py` - NOAA APT & Meteor LRPT via SatDump
|
||||||
|
- `ais.py` - AIS vessel tracking and VHF DSC distress monitoring
|
||||||
|
- `aprs.py` - Amateur packet radio via direwolf
|
||||||
|
- `rtlamr.py` - Utility meter reading
|
||||||
|
- `meshtastic_routes.py` - Meshtastic LoRa mesh networking
|
||||||
|
|
||||||
|
### Core Utilities (utils/)
|
||||||
|
|
||||||
|
**SDR Abstraction Layer** (`utils/sdr/`):
|
||||||
|
- `SDRFactory` with factory pattern for multiple SDR types (RTL-SDR, LimeSDR, HackRF, Airspy, SDRPlay)
|
||||||
|
- Each type has a `CommandBuilder` for generating CLI commands
|
||||||
|
|
||||||
|
**Bluetooth Module** (`utils/bluetooth/`):
|
||||||
|
- Multi-backend: DBus/BlueZ primary, fallback for systems without BlueZ
|
||||||
|
- `aggregator.py` - Merges observations across time
|
||||||
|
- `tracker_signatures.py` - 47K+ known tracker fingerprints (AirTag, Tile, SmartTag)
|
||||||
|
- `heuristics.py` - Behavioral analysis for device classification
|
||||||
|
|
||||||
|
**TSCM (Counter-Surveillance)** (`utils/tscm/`):
|
||||||
|
- `baseline.py` - Snapshot "normal" RF environment
|
||||||
|
- `detector.py` - Compare current scan to baseline, flag anomalies
|
||||||
|
- `device_identity.py` - Track devices despite MAC randomization
|
||||||
|
- `correlation.py` - Cross-reference Bluetooth and WiFi observations
|
||||||
|
|
||||||
|
**WiFi Utilities** (`utils/wifi/`):
|
||||||
|
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
|
||||||
|
- `channel_analyzer.py` - Frequency band analysis
|
||||||
|
|
||||||
|
**Weather Satellite** (`utils/weather_sat.py`):
|
||||||
|
- Singleton `WeatherSatDecoder` using SatDump CLI for NOAA APT and Meteor LRPT
|
||||||
|
- Subprocess management with stdout parsing, image watcher via rglob
|
||||||
|
- Pass prediction using skyfield TLE data
|
||||||
|
|
||||||
|
**SSTV Decoder** (`utils/sstv.py`):
|
||||||
|
- ISS SSTV reception via slowrx with Doppler tracking
|
||||||
|
- Singleton pattern, image gallery with timestamped filenames
|
||||||
|
|
||||||
|
### Key Patterns
|
||||||
|
|
||||||
|
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. Under gunicorn + gevent, each SSE connection is a lightweight greenlet instead of an OS thread.
|
||||||
|
|
||||||
|
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
|
||||||
|
|
||||||
|
**Data Stores**: `DataStore` class with TTL-based automatic cleanup (WiFi: 10min, Bluetooth: 5min, Aircraft: 5min).
|
||||||
|
|
||||||
|
**Input Validation**: Centralized in `utils/validation.py` - always validate frequencies, gains, device indices before spawning processes.
|
||||||
|
|
||||||
|
### External Tool Integrations
|
||||||
|
|
||||||
|
| Tool | Purpose | Integration |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| rtl_fm | FM demodulation | Subprocess, pipes to multimon-ng |
|
||||||
|
| multimon-ng | Pager decoding | Reads from rtl_fm stdout |
|
||||||
|
| rtl_433 | 433MHz sensors | JSON output parsing |
|
||||||
|
| dump1090 | ADS-B decoding | SBS protocol socket (port 30003) |
|
||||||
|
| acarsdec | ACARS messages | Output parsing |
|
||||||
|
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
|
||||||
|
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
|
||||||
|
| slowrx | SSTV decoding | Subprocess with audio pipe |
|
||||||
|
| SatDump | Weather satellites | CLI live mode, NOAA APT + Meteor LRPT |
|
||||||
|
| AIS-catcher | AIS vessel tracking | JSON output parsing |
|
||||||
|
| direwolf | APRS | TNC modem for packet radio |
|
||||||
|
|
||||||
|
### Frontend Structure
|
||||||
|
- **Templates**: `templates/index.html` (main SPA), `templates/partials/modes/*.html` (sidebar panels), `templates/partials/nav.html` (global nav)
|
||||||
|
- **JS Modules**: `static/js/modes/*.js` - IIFE pattern per mode (e.g., `WeatherSat`, `SSTV`, `Meshtastic`)
|
||||||
|
- **CSS**: `static/css/modes/*.css` - scoped styles per mode, CSS variables for theming (`--bg-card`, `--accent-cyan`, `--font-mono`)
|
||||||
|
- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()`
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.). CMD runs `start.sh` (gunicorn + gevent)
|
||||||
|
- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B)
|
||||||
|
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
|
||||||
|
- Data persisted via `./data:/app/data` volume mount
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- `config.py` - Environment variable support with `INTERCEPT_` prefix (e.g., `INTERCEPT_PORT`, `INTERCEPT_WEATHER_SAT_GAIN`)
|
||||||
|
- Database: SQLite in `instance/` directory for settings, baselines, history
|
||||||
|
|
||||||
|
## Testing Notes
|
||||||
|
|
||||||
|
Tests use pytest with extensive mocking of external tools. Key fixtures in `tests/conftest.py`. Mock subprocess calls when testing decoder integration.
|
||||||
@@ -1,6 +1,213 @@
|
|||||||
# INTERCEPT - Signal Intelligence Platform
|
# INTERCEPT - Signal Intelligence Platform
|
||||||
# Docker container for running the web interface
|
# Docker container for running the web interface
|
||||||
|
# Multi-stage build: builder compiles tools, runtime keeps only what's needed
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Stage 1: Builder — compile all tools from source
|
||||||
|
###############################################################################
|
||||||
|
FROM python:3.11-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /tmp/build
|
||||||
|
|
||||||
|
# Install ALL build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
git \
|
||||||
|
pkg-config \
|
||||||
|
cmake \
|
||||||
|
librtlsdr-dev \
|
||||||
|
libusb-1.0-0-dev \
|
||||||
|
libncurses-dev \
|
||||||
|
libsndfile1-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libasound2-dev \
|
||||||
|
libsoapysdr-dev \
|
||||||
|
libhackrf-dev \
|
||||||
|
liblimesuite-dev \
|
||||||
|
libfftw3-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libtiff-dev \
|
||||||
|
libjemalloc-dev \
|
||||||
|
libvolk-dev \
|
||||||
|
libnng-dev \
|
||||||
|
libzstd-dev \
|
||||||
|
libsqlite3-dev \
|
||||||
|
libcurl4-openssl-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
libzmq3-dev \
|
||||||
|
libpulse-dev \
|
||||||
|
libfftw3-bin \
|
||||||
|
liblapack-dev \
|
||||||
|
libglib2.0-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create staging directory for all built artifacts
|
||||||
|
RUN mkdir -p /staging/usr/bin /staging/usr/local/bin /staging/usr/local/lib /staging/opt
|
||||||
|
|
||||||
|
# Build dump1090
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
||||||
|
&& cd dump1090 \
|
||||||
|
&& sed -i 's/-Werror//g' Makefile \
|
||||||
|
&& make BLADERF=no RTLSDR=yes \
|
||||||
|
&& cp dump1090 /staging/usr/bin/dump1090-fa \
|
||||||
|
&& ln -s /usr/bin/dump1090-fa /staging/usr/bin/dump1090 \
|
||||||
|
&& rm -rf /tmp/dump1090
|
||||||
|
|
||||||
|
# Build AIS-catcher
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone https://github.com/jvde-github/AIS-catcher.git \
|
||||||
|
&& cd AIS-catcher \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& cp AIS-catcher /staging/usr/bin/AIS-catcher \
|
||||||
|
&& rm -rf /tmp/AIS-catcher
|
||||||
|
|
||||||
|
# Build readsb
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/wiedehopf/readsb.git \
|
||||||
|
&& cd readsb \
|
||||||
|
&& make BLADERF=no PLUTOSDR=no SOAPYSDR=yes \
|
||||||
|
&& cp readsb /staging/usr/bin/readsb \
|
||||||
|
&& rm -rf /tmp/readsb
|
||||||
|
|
||||||
|
# Build rx_tools
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone https://github.com/rxseger/rx_tools.git \
|
||||||
|
&& cd rx_tools \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& DESTDIR=/staging make install \
|
||||||
|
&& rm -rf /tmp/rx_tools
|
||||||
|
|
||||||
|
# Build acarsdec
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
||||||
|
&& cd acarsdec \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||||
|
&& make \
|
||||||
|
&& cp acarsdec /staging/usr/bin/acarsdec \
|
||||||
|
&& rm -rf /tmp/acarsdec
|
||||||
|
|
||||||
|
# Build libacars (required by dumpvdl2)
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/szpajder/libacars.git \
|
||||||
|
&& cd libacars \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
&& cp -a /usr/local/lib/libacars* /staging/usr/local/lib/ \
|
||||||
|
&& rm -rf /tmp/libacars
|
||||||
|
|
||||||
|
# Build dumpvdl2 (VDL2 aircraft datalink decoder)
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/szpajder/dumpvdl2.git \
|
||||||
|
&& cd dumpvdl2 \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& cp src/dumpvdl2 /staging/usr/bin/dumpvdl2 \
|
||||||
|
&& rm -rf /tmp/dumpvdl2
|
||||||
|
|
||||||
|
# Build slowrx (SSTV decoder) — pinned to known-good commit
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone https://github.com/windytan/slowrx.git \
|
||||||
|
&& cd slowrx \
|
||||||
|
&& git checkout ca6d7012 \
|
||||||
|
&& make \
|
||||||
|
&& install -m 0755 slowrx /staging/usr/local/bin/slowrx \
|
||||||
|
&& rm -rf /tmp/slowrx
|
||||||
|
|
||||||
|
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
|
||||||
|
# Split into compile (heavy, cached) and staging (light, safe to change) layers
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
|
||||||
|
&& cd SatDump \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& ARCH_FLAGS=""; if [ "$(uname -m)" = "x86_64" ]; then ARCH_FLAGS="-march=x86-64"; fi \
|
||||||
|
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib \
|
||||||
|
-DCMAKE_C_FLAGS="$ARCH_FLAGS" \
|
||||||
|
-DCMAKE_CXX_FLAGS="$ARCH_FLAGS" .. \
|
||||||
|
&& make -j$(nproc) \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
# Ensure SatDump plugins are in the expected path (handles multiarch differences)
|
||||||
|
&& mkdir -p /usr/local/lib/satdump/plugins \
|
||||||
|
&& if [ -z "$(ls /usr/local/lib/satdump/plugins/*.so 2>/dev/null)" ]; then \
|
||||||
|
for dir in /usr/local/lib/*/satdump/plugins /usr/lib/*/satdump/plugins /usr/lib/satdump/plugins; do \
|
||||||
|
if [ -d "$dir" ] && [ -n "$(ls "$dir"/*.so 2>/dev/null)" ]; then \
|
||||||
|
ln -sf "$dir"/*.so /usr/local/lib/satdump/plugins/; \
|
||||||
|
break; \
|
||||||
|
fi; \
|
||||||
|
done; \
|
||||||
|
fi \
|
||||||
|
&& rm -rf /tmp/SatDump
|
||||||
|
|
||||||
|
# Stage SatDump artifacts (separate layer so compile cache survives staging changes)
|
||||||
|
# On arm64 cmake installs to /usr/{bin,lib,share}; on x86 to /usr/local/{bin,lib,share}
|
||||||
|
RUN mkdir -p /staging/usr/local/share /staging/usr/local/lib/satdump/plugins \
|
||||||
|
# Binary
|
||||||
|
&& (cp -a /usr/local/bin/satdump /staging/usr/local/bin/ 2>/dev/null \
|
||||||
|
|| cp -a /usr/bin/satdump /staging/usr/local/bin/) \
|
||||||
|
# Core shared library
|
||||||
|
&& (cp -a /usr/local/lib/libsatdump* /staging/usr/local/lib/ 2>/dev/null \
|
||||||
|
|| cp -a /usr/lib/libsatdump* /staging/usr/local/lib/) \
|
||||||
|
# Plugins
|
||||||
|
&& (cp -a /usr/local/lib/satdump/plugins/*.so /staging/usr/local/lib/satdump/plugins/ 2>/dev/null \
|
||||||
|
|| cp -a /usr/lib/satdump/plugins/*.so /staging/usr/local/lib/satdump/plugins/ 2>/dev/null \
|
||||||
|
|| true) \
|
||||||
|
# Pipeline definitions and resources
|
||||||
|
&& (cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null \
|
||||||
|
|| cp -a /usr/share/satdump /staging/usr/local/share/) \
|
||||||
|
# Verify
|
||||||
|
&& test -x /staging/usr/local/bin/satdump \
|
||||||
|
&& ls /staging/usr/local/share/satdump/pipelines/*.json >/dev/null 2>&1 \
|
||||||
|
&& echo "SatDump staging OK: $(ls /staging/usr/local/share/satdump/pipelines/*.json | wc -l) pipeline files"
|
||||||
|
|
||||||
|
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
|
||||||
|
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/greatscottgadgets/hackrf.git \
|
||||||
|
&& cd hackrf/host \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
&& cp -a /usr/local/bin/hackrf_* /staging/usr/local/bin/ 2>/dev/null || true \
|
||||||
|
&& cp -a /usr/local/lib/libhackrf* /staging/usr/local/lib/ 2>/dev/null || true \
|
||||||
|
&& rm -rf /tmp/hackrf
|
||||||
|
|
||||||
|
# Install radiosonde_auto_rx (weather balloon decoder)
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git \
|
||||||
|
&& cd radiosonde_auto_rx/auto_rx \
|
||||||
|
&& pip install --no-cache-dir -r requirements.txt semver \
|
||||||
|
&& bash build.sh \
|
||||||
|
&& mkdir -p /staging/opt/radiosonde_auto_rx/auto_rx \
|
||||||
|
&& cp -r . /staging/opt/radiosonde_auto_rx/auto_rx/ \
|
||||||
|
&& chmod +x /staging/opt/radiosonde_auto_rx/auto_rx/auto_rx.py \
|
||||||
|
&& rm -rf /tmp/radiosonde_auto_rx
|
||||||
|
|
||||||
|
# Build rtlamr (utility meter decoder - requires Go)
|
||||||
|
RUN cd /tmp \
|
||||||
|
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
||||||
|
&& export PATH="$PATH:/usr/local/go/bin" \
|
||||||
|
&& export GOPATH=/tmp/gopath \
|
||||||
|
&& go install github.com/bemasher/rtlamr@latest \
|
||||||
|
&& cp /tmp/gopath/bin/rtlamr /staging/usr/bin/rtlamr \
|
||||||
|
&& rm -rf /usr/local/go /tmp/gopath
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Stage 2: Runtime — lean image with only runtime dependencies
|
||||||
|
###############################################################################
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
LABEL maintainer="INTERCEPT Project"
|
LABEL maintainer="INTERCEPT Project"
|
||||||
@@ -9,18 +216,30 @@ LABEL description="Signal Intelligence Platform for SDR monitoring"
|
|||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies for SDR tools
|
# Pre-accept tshark non-root capture prompt for non-interactive install
|
||||||
|
RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections
|
||||||
|
|
||||||
|
# Install ONLY runtime dependencies (no -dev packages, no build tools)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
# RTL-SDR tools
|
# RTL-SDR tools
|
||||||
rtl-sdr \
|
rtl-sdr \
|
||||||
librtlsdr-dev \
|
|
||||||
libusb-1.0-0-dev \
|
|
||||||
# 433MHz decoder
|
# 433MHz decoder
|
||||||
rtl-433 \
|
rtl-433 \
|
||||||
# Pager decoder
|
# Pager decoder
|
||||||
multimon-ng \
|
multimon-ng \
|
||||||
# Audio tools for Listening Post
|
# Audio tools for Listening Post
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
|
# SSTV decoder runtime libs
|
||||||
|
libsndfile1 \
|
||||||
|
# SatDump runtime libs (weather satellite decoding)
|
||||||
|
libpng16-16 \
|
||||||
|
libtiff6 \
|
||||||
|
libjemalloc2 \
|
||||||
|
libfftw3-double3 \
|
||||||
|
libfftw3-single3 \
|
||||||
|
libvolk-bin \
|
||||||
|
libnng1 \
|
||||||
|
libzstd1 \
|
||||||
# WiFi tools (aircrack-ng suite)
|
# WiFi tools (aircrack-ng suite)
|
||||||
aircrack-ng \
|
aircrack-ng \
|
||||||
iw \
|
iw \
|
||||||
@@ -29,19 +248,38 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
bluez \
|
bluez \
|
||||||
bluetooth \
|
bluetooth \
|
||||||
# GPS support
|
# GPS support
|
||||||
|
gpsd \
|
||||||
gpsd-clients \
|
gpsd-clients \
|
||||||
|
# APRS
|
||||||
|
direwolf \
|
||||||
|
# WiFi Extra
|
||||||
|
hcxdumptool \
|
||||||
|
hcxtools \
|
||||||
|
# SDR Hardware & SoapySDR
|
||||||
|
soapysdr-tools \
|
||||||
|
soapysdr-module-rtlsdr \
|
||||||
|
soapysdr-module-hackrf \
|
||||||
|
soapysdr-module-lms7 \
|
||||||
|
soapysdr-module-airspy \
|
||||||
|
airspy \
|
||||||
|
limesuite \
|
||||||
# Utilities
|
# Utilities
|
||||||
curl \
|
curl \
|
||||||
procps \
|
procps \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install dump1090 for ADS-B (package name varies by distribution)
|
# Copy compiled binaries and libraries from builder stage
|
||||||
RUN apt-get update && \
|
COPY --from=builder /staging/usr/bin/ /usr/bin/
|
||||||
(apt-get install -y --no-install-recommends dump1090-mutability || \
|
COPY --from=builder /staging/usr/local/bin/ /usr/local/bin/
|
||||||
apt-get install -y --no-install-recommends dump1090-fa || \
|
COPY --from=builder /staging/usr/local/lib/ /usr/local/lib/
|
||||||
apt-get install -y --no-install-recommends dump1090 || \
|
COPY --from=builder /staging/usr/local/share/ /usr/local/share/
|
||||||
echo "Note: dump1090 not available in repos, ADS-B features limited") && \
|
COPY --from=builder /staging/opt/ /opt/
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
# Copy radiosonde Python dependencies installed during builder stage
|
||||||
|
COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/
|
||||||
|
|
||||||
|
# Refresh shared library cache for custom-built libraries
|
||||||
|
RUN ldconfig
|
||||||
|
|
||||||
# Copy requirements first for better caching
|
# Copy requirements first for better caching
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
@@ -50,11 +288,15 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Strip Windows CRLF from shell scripts (git autocrlf can re-introduce them)
|
||||||
|
RUN find . -name '*.sh' -exec sed -i 's/\r$//' {} +
|
||||||
|
|
||||||
# Create data directory for persistence
|
# Create data directory for persistence
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs
|
||||||
|
|
||||||
# Expose web interface port
|
# Expose web interface port
|
||||||
EXPOSE 5050
|
EXPOSE 5050
|
||||||
|
EXPOSE 5443
|
||||||
|
|
||||||
# Environment variables with defaults
|
# Environment variables with defaults
|
||||||
ENV INTERCEPT_HOST=0.0.0.0 \
|
ENV INTERCEPT_HOST=0.0.0.0 \
|
||||||
@@ -67,4 +309,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|||||||
CMD curl -sf http://localhost:5050/health || exit 1
|
CMD curl -sf http://localhost:5050/health || exit 1
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
CMD ["python", "intercept.py"]
|
CMD ["/bin/bash", "start.sh"]
|
||||||
|
|||||||
@@ -1,21 +1,200 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 smittix
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
1. Definitions.
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
the copyright owner that is granting the License.
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by the Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding any notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. Please also get an OpenPGP
|
||||||
|
key and encrypt outgoing communications.
|
||||||
|
|
||||||
|
Copyright 2025 smittix
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
# INTERCEPT
|
<p align="center">
|
||||||
|
<img src="static/images/readme-banner.svg" alt="iNTERCEPT — Signal Intelligence Platform" width="100%">
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License">
|
<img src="https://img.shields.io/badge/license-Apache--2.0-green.svg" alt="Apache 2.0 License">
|
||||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Support the developer of this open-source project
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.buymeacoffee.com/smittix" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||||
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>Signal Intelligence Platform</strong><br>
|
<strong>Signal Intelligence Platform</strong><br>
|
||||||
A web-based interface for software-defined radio tools.
|
A web-based interface for software-defined radio tools.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="static/images/screenshots/logo-banner.png" alt="Screenshot">
|
<img src="static/images/screenshots/intercept-main.png" alt="Screenshot">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -21,39 +30,233 @@
|
|||||||
|
|
||||||
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
||||||
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
||||||
|
- **Sub-GHz Analyzer** - RF capture and protocol decoding for 300-928 MHz ISM bands via HackRF
|
||||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
||||||
- **Listening Post** - Frequency scanner with audio monitoring
|
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
||||||
- **Satellite Tracking** - Pass prediction using TLE data
|
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
||||||
|
- **VDL2** - VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2
|
||||||
|
- **Listening Post** - Wideband frequency scanner with real-time audio monitoring
|
||||||
|
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler
|
||||||
|
- **WebSDR** - Remote HF/shortwave listening via KiwiSDR network
|
||||||
|
- **ISS SSTV** - Slow-scan TV image reception from the International Space Station
|
||||||
|
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
|
||||||
|
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
|
||||||
|
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
|
||||||
|
- **Utility Meters** - Electric, gas, and water meter reading via rtlamr
|
||||||
|
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||||
- **Bluetooth Scanning** - Device discovery and tracker detection
|
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||||
|
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
|
||||||
|
- **WiFi Locate** - Locate WiFi access points by BSSID with real-time signal meter, distance estimation, and proximity audio
|
||||||
|
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
|
||||||
|
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
||||||
|
- **Meshtastic** - LoRa mesh network integration
|
||||||
|
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
|
||||||
|
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||||
|
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||||
|
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||||
|
- **Drone Intelligence** - Multi-vector UAV detection via ASTM F3411 Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF, and HackRF 2.4/5.8 GHz scanning with live contact map and risk scoring
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation / Debian / Ubuntu / MacOS
|
## CW / Morse Decoder Notes
|
||||||
|
|
||||||
```
|
Live backend:
|
||||||
|
- Uses `rtl_fm` piped into `multimon-ng` (`MORSE_CW`) for real-time decode.
|
||||||
|
|
||||||
**1. Clone and run:**
|
Recommended baseline settings:
|
||||||
```bash
|
- **Tone**: `700 Hz`
|
||||||
git clone https://github.com/smittix/intercept.git
|
- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals)
|
||||||
cd intercept
|
- **Threshold Mode**: `Auto`
|
||||||
./setup.sh
|
- **WPM Mode**: `Auto`
|
||||||
sudo python3 intercept.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker (Alternative)
|
Auto Tone Track behavior:
|
||||||
|
- Continuously measures nearby tone energy around the configured CW pitch.
|
||||||
|
- Steers the detector toward the strongest valid CW tone when signal-to-noise is sufficient.
|
||||||
|
- Use **Hold Tone Lock** to freeze tracking once the desired signal is centered.
|
||||||
|
|
||||||
|
Troubleshooting (no decode / noisy decode):
|
||||||
|
- Confirm demod path is **USB/CW-compatible** and frequency is tuned correctly.
|
||||||
|
- If multiple SDRs are connected and the selected one has no PCM output, Morse startup now auto-tries other detected SDR devices and reports the active device/serial in status logs.
|
||||||
|
- Match **tone** and **bandwidth** to the actual sidetone/pitch.
|
||||||
|
- Try **Threshold Auto** first; if needed, switch to manual threshold and recalibrate.
|
||||||
|
- Use **Reset/Calibrate** after major frequency or band condition changes.
|
||||||
|
- Raise **Minimum Signal Gate** to suppress random noise keying.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation / Debian / Ubuntu / macOS
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/smittix/intercept.git
|
git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
docker-compose up -d
|
./setup.sh # Interactive menu (first run launches setup wizard)
|
||||||
|
sudo ./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
|
On first run, `setup.sh` launches a **guided wizard** that detects your OS, lets you choose install profiles, sets up the Python environment, and optionally configures environment variables and PostgreSQL.
|
||||||
|
|
||||||
|
On subsequent runs, it opens an **interactive menu**:
|
||||||
|
|
||||||
|
```
|
||||||
|
INTERCEPT Setup Menu
|
||||||
|
════════════════════════════════════════
|
||||||
|
1) Install / Add Modules
|
||||||
|
2) System Health Check
|
||||||
|
3) Database Setup (ADS-B History)
|
||||||
|
4) Update Tools
|
||||||
|
5) Environment Configurator
|
||||||
|
6) Uninstall / Cleanup
|
||||||
|
7) View Status
|
||||||
|
0) Exit
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Production vs Dev server:** `start.sh` auto-detects gunicorn + gevent and runs a production server with cooperative greenlets — handles multiple SSE/WebSocket clients without blocking. Falls back to Flask dev server if gunicorn is not installed. For quick local development, you can still use `sudo -E venv/bin/python intercept.py` directly.
|
||||||
|
|
||||||
|
### Install Profiles
|
||||||
|
|
||||||
|
Choose what to install during the wizard or via menu option 1:
|
||||||
|
|
||||||
|
| # | Profile | Tools |
|
||||||
|
|---|---------|-------|
|
||||||
|
| 1 | Core SIGINT | rtl_sdr, multimon-ng, rtl_433, dump1090, acarsdec, dumpvdl2, ffmpeg, gpsd |
|
||||||
|
| 2 | Maritime & Radio | AIS-catcher, direwolf |
|
||||||
|
| 3 | Weather & Space | SatDump, radiosonde_auto_rx |
|
||||||
|
| 4 | RF Security | aircrack-ng, HackRF, BlueZ, hcxtools, Ubertooth, SoapySDR |
|
||||||
|
| 5 | Full SIGINT | All of the above |
|
||||||
|
| 6 | Custom | Per-tool checklist |
|
||||||
|
|
||||||
|
Multiple profiles can be combined (e.g. enter `1 3` for Core + Weather).
|
||||||
|
|
||||||
|
### CLI Flags
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./setup.sh --non-interactive # Headless full install (same as legacy behavior)
|
||||||
|
./setup.sh --profile=core,weather # Install specific profiles
|
||||||
|
./setup.sh --health-check # Check system health and exit
|
||||||
|
./setup.sh --postgres-setup # Run PostgreSQL setup and exit
|
||||||
|
./setup.sh --menu # Force interactive menu
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/smittix/intercept.git
|
||||||
|
cd intercept
|
||||||
|
docker compose --profile basic up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Docker requires privileged mode for USB SDR access. SDR devices are passed through via `/dev/bus/usb`.
|
||||||
|
|
||||||
|
#### Multi-Architecture Builds (amd64 + arm64)
|
||||||
|
|
||||||
|
Cross-compile on an x64 machine and push to a registry. This is much faster than building natively on an RPi.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One-time setup on your x64 build machine
|
||||||
|
docker run --privileged --rm tonistiigi/binfmt --install all
|
||||||
|
docker buildx create --name intercept-builder --use --bootstrap
|
||||||
|
|
||||||
|
# Build and push for both architectures
|
||||||
|
REGISTRY=ghcr.io/youruser ./build-multiarch.sh --push
|
||||||
|
|
||||||
|
# On the RPi5, just pull and run
|
||||||
|
INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest docker compose --profile basic up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Build script options:
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `--push` | Push to container registry |
|
||||||
|
| `--load` | Load into local Docker (single platform only) |
|
||||||
|
| `--arm64-only` | Build arm64 only (for RPi deployment) |
|
||||||
|
| `--amd64-only` | Build amd64 only |
|
||||||
|
|
||||||
|
Environment variables: `REGISTRY`, `IMAGE_NAME`, `IMAGE_TAG`
|
||||||
|
|
||||||
|
#### Using a Pre-built Image
|
||||||
|
|
||||||
|
If you've pushed to a registry, you can skip building entirely on the target machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set in .env or export
|
||||||
|
INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest
|
||||||
|
|
||||||
|
# Then just run
|
||||||
|
docker compose --profile basic up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Configuration
|
||||||
|
|
||||||
|
Use the **Environment Configurator** (menu option 5) to interactively set any `INTERCEPT_*` variable. Settings are saved to a `.env` file that `start.sh` sources automatically on startup.
|
||||||
|
|
||||||
|
You can also create or edit `.env` manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env (auto-loaded by start.sh)
|
||||||
|
INTERCEPT_PORT=5050
|
||||||
|
INTERCEPT_ADSB_AUTO_START=true
|
||||||
|
INTERCEPT_DEFAULT_LAT=51.5074
|
||||||
|
INTERCEPT_DEFAULT_LON=-0.1278
|
||||||
|
```
|
||||||
|
|
||||||
|
### ADS-B History (Optional)
|
||||||
|
|
||||||
|
The ADS-B history feature persists aircraft messages to PostgreSQL for long-term analysis.
|
||||||
|
|
||||||
|
**Automated setup (local install):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./setup.sh --postgres-setup
|
||||||
|
# Or use menu option 3: Database Setup
|
||||||
|
```
|
||||||
|
|
||||||
|
This will install PostgreSQL if needed, create the database/user/tables, and write the connection settings to `.env`.
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile history up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the following environment variables (in `.env`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
|
INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||||
|
INTERCEPT_ADSB_DB_PORT=5432
|
||||||
|
INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||||
|
INTERCEPT_ADSB_DB_USER=intercept
|
||||||
|
INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||||
|
```
|
||||||
|
|
||||||
|
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open **/adsb/history** for the reporting dashboard.
|
||||||
|
|
||||||
|
### System Health Check
|
||||||
|
|
||||||
|
Verify your installation is complete and working:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./setup.sh --health-check
|
||||||
|
# Or use menu option 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Checks installed tools, SDR devices, port availability, permissions, Python venv, `.env` configuration, and PostgreSQL connectivity.
|
||||||
|
|
||||||
### Open the Interface
|
### Open the Interface
|
||||||
|
|
||||||
After starting, open **http://localhost:5050** in your browser.
|
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
||||||
|
|
||||||
|
The credentials can be changed in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -81,14 +284,16 @@ Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
|
|||||||
## Discord Server
|
## Discord Server
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
|
<a href="https://discord.gg/EyeksEJmWE">Join our Discord</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
|
- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
|
||||||
|
- [Distributed Agents](docs/DISTRIBUTED_AGENTS.md) - Remote sensor node deployment
|
||||||
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
|
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
|
||||||
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
|
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
|
||||||
- [Security](docs/SECURITY.md) - Network security and best practices
|
- [Security](docs/SECURITY.md) - Network security and best practices
|
||||||
@@ -109,7 +314,7 @@ This project was developed using AI as a coding partner, combining human directi
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License - see [LICENSE](LICENSE)
|
Apache 2.0 License - see [LICENSE](LICENSE)
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
@@ -121,9 +326,22 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
[multimon-ng](https://github.com/EliasOenal/multimon-ng) |
|
[multimon-ng](https://github.com/EliasOenal/multimon-ng) |
|
||||||
[rtl_433](https://github.com/merbanan/rtl_433) |
|
[rtl_433](https://github.com/merbanan/rtl_433) |
|
||||||
[dump1090](https://github.com/flightaware/dump1090) |
|
[dump1090](https://github.com/flightaware/dump1090) |
|
||||||
|
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
||||||
|
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||||
|
[direwolf](https://github.com/wb2osz/direwolf) |
|
||||||
|
[rtlamr](https://github.com/bemasher/rtlamr) |
|
||||||
|
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
|
||||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||||
[Leaflet.js](https://leafletjs.com/) |
|
[Leaflet.js](https://leafletjs.com/) |
|
||||||
[Celestrak](https://celestrak.org/)
|
[SatDump](https://github.com/SatDump/SatDump) |
|
||||||
|
[Celestrak](https://celestrak.org/) |
|
||||||
|
[Priyom.org](https://priyom.org/)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "2026-01-04_e27bf619",
|
|
||||||
"downloaded": "2026-01-07T14:55:20.680977Z"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# DSC (Digital Selective Calling) decoder wrapper
|
||||||
|
# Invokes the Python DSC decoder module
|
||||||
|
|
||||||
|
# Get the directory where this script is located
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
# Set PYTHONPATH to include project root
|
||||||
|
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH}"
|
||||||
|
|
||||||
|
# Run the decoder module
|
||||||
|
exec python3 -m utils.dsc.decoder "$@"
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# INTERCEPT - Multi-architecture Docker image builder
|
||||||
|
#
|
||||||
|
# Builds for both linux/amd64 and linux/arm64 using Docker buildx.
|
||||||
|
# Run this on your x64 machine to cross-compile the arm64 image
|
||||||
|
# instead of building natively on the RPi5.
|
||||||
|
#
|
||||||
|
# Prerequisites (one-time setup):
|
||||||
|
# docker run --privileged --rm tonistiigi/binfmt --install all
|
||||||
|
# docker buildx create --name intercept-builder --use --bootstrap
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./build-multiarch.sh # Build both platforms, load locally
|
||||||
|
# ./build-multiarch.sh --push # Build and push to registry
|
||||||
|
# ./build-multiarch.sh --arm64-only # Build arm64 only (for RPi)
|
||||||
|
# REGISTRY=ghcr.io/user ./build-multiarch.sh --push
|
||||||
|
#
|
||||||
|
# Environment variables:
|
||||||
|
# REGISTRY - Container registry (default: docker.io/library)
|
||||||
|
# IMAGE_NAME - Image name (default: intercept)
|
||||||
|
# IMAGE_TAG - Image tag (default: latest)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
REGISTRY="${REGISTRY:-}"
|
||||||
|
IMAGE_NAME="${IMAGE_NAME:-intercept}"
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||||
|
BUILDER_NAME="intercept-builder"
|
||||||
|
PLATFORMS="linux/amd64,linux/arm64"
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
PUSH=false
|
||||||
|
LOAD=false
|
||||||
|
ARM64_ONLY=false
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case $arg in
|
||||||
|
--push) PUSH=true ;;
|
||||||
|
--load) LOAD=true ;;
|
||||||
|
--arm64-only)
|
||||||
|
ARM64_ONLY=true
|
||||||
|
PLATFORMS="linux/arm64"
|
||||||
|
;;
|
||||||
|
--amd64-only)
|
||||||
|
PLATFORMS="linux/amd64"
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--push] [--load] [--arm64-only] [--amd64-only]"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --push Push to container registry"
|
||||||
|
echo " --load Load into local Docker (single platform only)"
|
||||||
|
echo " --arm64-only Build arm64 only (for RPi5 deployment)"
|
||||||
|
echo " --amd64-only Build amd64 only"
|
||||||
|
echo ""
|
||||||
|
echo "Environment variables:"
|
||||||
|
echo " REGISTRY Container registry (e.g. ghcr.io/username)"
|
||||||
|
echo " IMAGE_NAME Image name (default: intercept)"
|
||||||
|
echo " IMAGE_TAG Image tag (default: latest)"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 --push # Build both, push"
|
||||||
|
echo " REGISTRY=ghcr.io/myuser $0 --push # Push to GHCR"
|
||||||
|
echo " $0 --arm64-only --load # Build arm64, load locally"
|
||||||
|
echo " $0 --arm64-only --push && ssh rpi docker pull # Build + deploy to RPi"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $arg"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Build full image reference
|
||||||
|
if [ -n "$REGISTRY" ]; then
|
||||||
|
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
|
else
|
||||||
|
FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo " INTERCEPT Multi-Architecture Builder"
|
||||||
|
echo "============================================"
|
||||||
|
echo " Image: ${FULL_IMAGE}"
|
||||||
|
echo " Platforms: ${PLATFORMS}"
|
||||||
|
echo " Push: ${PUSH}"
|
||||||
|
echo "============================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if buildx builder exists, create if not
|
||||||
|
if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
|
||||||
|
echo "Creating buildx builder: ${BUILDER_NAME}"
|
||||||
|
docker buildx create --name "$BUILDER_NAME" --use --bootstrap
|
||||||
|
|
||||||
|
# Check for QEMU support
|
||||||
|
if ! docker run --rm --privileged tonistiigi/binfmt --install all >/dev/null 2>&1; then
|
||||||
|
echo "WARNING: QEMU binfmt setup may have failed."
|
||||||
|
echo "Run: docker run --privileged --rm tonistiigi/binfmt --install all"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
docker buildx use "$BUILDER_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
BUILD_CMD="docker buildx build --platform ${PLATFORMS} --tag ${FULL_IMAGE}"
|
||||||
|
|
||||||
|
if [ "$PUSH" = true ]; then
|
||||||
|
BUILD_CMD="${BUILD_CMD} --push"
|
||||||
|
echo "Will push to: ${FULL_IMAGE}"
|
||||||
|
elif [ "$LOAD" = true ]; then
|
||||||
|
# --load only works with single platform
|
||||||
|
if echo "$PLATFORMS" | grep -q ","; then
|
||||||
|
echo "ERROR: --load only works with a single platform."
|
||||||
|
echo "Use --arm64-only or --amd64-only with --load."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
BUILD_CMD="${BUILD_CMD} --load"
|
||||||
|
echo "Will load into local Docker"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Building..."
|
||||||
|
echo "Command: ${BUILD_CMD} ."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
$BUILD_CMD .
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================"
|
||||||
|
echo " Build complete!"
|
||||||
|
if [ "$PUSH" = true ]; then
|
||||||
|
echo " Image pushed to: ${FULL_IMAGE}"
|
||||||
|
echo ""
|
||||||
|
echo " Pull on RPi5:"
|
||||||
|
echo " docker pull ${FULL_IMAGE}"
|
||||||
|
fi
|
||||||
|
echo "============================================"
|
||||||
@@ -7,18 +7,388 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.9.0"
|
VERSION = "2.27.0"
|
||||||
|
|
||||||
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "2.27.0",
|
||||||
|
"date": "May 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix: two-window hang caused by browser HTTP/1.1 connection pool exhaustion",
|
||||||
|
"Fix: SSE alert and Bluetooth streams now fan out to all windows (no more split messages)",
|
||||||
|
"Feat: UI tier system — lean, standard, enhanced display modes via nav toggle",
|
||||||
|
"Feat: first-run setup modal includes display mode selection",
|
||||||
|
"Perf: ADS-B SSE snapshot priming moved into generator; WiFi filter combined into single pass",
|
||||||
|
"Perf: Bluetooth tracker signature scan skips unchanged fingerprints",
|
||||||
|
"Fix: ICAO lookup cache capped at 50k entries with LRU eviction",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.13",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix TSCM sweep module variable scoping and stale progress bar",
|
||||||
|
"Fix 5GHz WiFi scanning failures in deep scan and band detection",
|
||||||
|
"Fix ADS-B remote mode incorrectly stopping other SDR services",
|
||||||
|
"Fix radiosonde false 'missing' report at end of setup",
|
||||||
|
"Satellite tracker: TLE auto-refresh, polar plot fixes, pass calculation improvements",
|
||||||
|
"Fix weather satellite handoff (remove defunct METEOR-M2)",
|
||||||
|
"Add multi-arch Docker CI workflow (amd64 + arm64)",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.12",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"AIS and ADS-B dashboards now use configured observer position from .env",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.11",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"APRS map now centres on configured observer position from .env",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.8",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix acarsdec build failure on macOS (HOST_NAME_MAX undefined)",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.7",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix health check SDR detection on macOS (timeout command not available)",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.6",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix oversized branded 'i' logo on Aircraft & Vessel dashboards",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.5",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix database errors crashing the entire UI — pages now degrade gracefully",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.4",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix Environment Configurator crash when .env exists but variable is missing",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.3",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix SatDump AVX2 crash on older CPUs — build now targets baseline x86-64",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.2",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix Docker startup crash — data/ Python package was excluded by .dockerignore",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.1",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix default admin credentials — now matches README (admin:admin)",
|
||||||
|
"Admin password changes in config.py / env vars now sync to DB on restart",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.0",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix SSE fanout thread crash when source queue is None during shutdown",
|
||||||
|
"Fix branded 'i' logo FOUC (flash of unstyled content) on first page load",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.25.0",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"UI/UX overhaul — SSEManager with exponential backoff and connection status indicator",
|
||||||
|
"Accessibility improvements — aria-labels, form label associations, keyboard list navigation",
|
||||||
|
"Destructive action confirmation modals replace native confirm() dialogs",
|
||||||
|
"CSS variable adoption, inline style extraction, and reduced !important usage",
|
||||||
|
"Loading button states, actionable error reporting, and mobile UX polish",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.24.0",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"WiFi Locate mode — locate access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones",
|
||||||
|
"Mobile navigation reorganized into labeled groups for better usability",
|
||||||
|
"flask-limiter made optional for graceful degradation",
|
||||||
|
"Radiosonde setup fix — missing semver dependency",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.23.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Radiosonde weather balloon tracking mode with telemetry, map, and station distance",
|
||||||
|
"CW/Morse code decoder with Goertzel tone detection and OOK envelope mode",
|
||||||
|
"WeFax (Weather Fax) decoder with auto-scheduler and broadcast timeline",
|
||||||
|
"System Health monitoring mode with telemetry dashboard",
|
||||||
|
"HTTPS support, HackRF TSCM RF scan, ADS-B voice alerts",
|
||||||
|
"Production server (start.sh) with gunicorn + gevent for concurrent multi-client support",
|
||||||
|
"Multi-SDR support for WeFax, tool path overrides, native Homebrew detection",
|
||||||
|
"GPS mode upgraded to textured 3D globe",
|
||||||
|
"Destroy lifecycle added to all mode modules to prevent resource leaks",
|
||||||
|
"Dozens of bug fixes across ADS-B, APRS, SSE, Morse, waterfall, and more",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.22.3",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Waterfall control panel no longer shows as unstyled text on first visit",
|
||||||
|
"WebSDR globe renders correctly on first page load without requiring a refresh",
|
||||||
|
"Waterfall monitor audio no longer takes minutes to start — playback detection now waits for real audio data instead of just the WAV header",
|
||||||
|
"Waterfall monitor stop is now instant — audio pauses and UI updates immediately instead of waiting for backend cleanup",
|
||||||
|
"Stopping the waterfall no longer shows a stale 'WebSocket closed before ready' message",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.22.1",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Waterfall receiver overhaul: WebSocket I/Q streaming with server-side FFT, click-to-tune, and zoom controls",
|
||||||
|
"Voice alerts for configurable event notifications across modes",
|
||||||
|
"Signal fingerprinting mode for RF device identification and pattern analysis",
|
||||||
|
"SignalID integration via SigIDWiki API for automatic signal classification",
|
||||||
|
"PWA support: installable web app with service worker and manifest",
|
||||||
|
"Mode stop responsiveness improvements with faster timeout handling",
|
||||||
|
"Navigation performance instrumentation and smoother mode transitions",
|
||||||
|
"Pager, sensor, and SSTV real-time signal scope visualization",
|
||||||
|
"ADS-B MSG2 surface movement parsing for ground vehicle tracking",
|
||||||
|
"WebSDR major overhaul with improved receiver management and audio streaming",
|
||||||
|
"Documentation audit: fixed license, tool names, entry points, and SSTV decoder references",
|
||||||
|
"Help modal updated with ACARS and VDL2 mode descriptions",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.21.1",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"BT Locate map first-load fix with render stabilization retries during initial mode open",
|
||||||
|
"BT Locate trail restore optimization for faster startup when historical GPS points exist",
|
||||||
|
"BT Locate mode-switch map invalidation timing fix to prevent delayed/blank map render",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.21.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Global map theme refresh with improved contrast and cross-dashboard consistency",
|
||||||
|
"Cross-app UX updates for accessibility, mode consistency, and render performance",
|
||||||
|
"Weather satellite reliability fixes for auto-scheduler and Mercator pass tracking",
|
||||||
|
"Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements",
|
||||||
|
"ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding",
|
||||||
|
"Analytics enhancements with operational insights and temporal pattern panels",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.20.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
|
||||||
|
"Kp index, solar wind, X-ray flux charts with Chart.js visualization",
|
||||||
|
"HF band conditions, D-RAP absorption maps, aurora forecast, and solar imagery",
|
||||||
|
"NOAA Space Weather Scales (G/S/R), flare probability, and active solar regions",
|
||||||
|
"No SDR hardware required — all data from public APIs with server-side caching",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.19.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"VDL2 mode with modal message viewer, consolidated into ADS-B dashboard",
|
||||||
|
"ADS-B: trails enabled by default, radar modes removed, CSV export added",
|
||||||
|
"Bundled Roboto Condensed font for offline mode with SVG icon overhaul",
|
||||||
|
"Help modal updated with all modes and correct SVG icons",
|
||||||
|
"Setup script overhauled for reliability and macOS compatibility",
|
||||||
|
"GPS fix for preserving satellites across DOP-only SKY messages",
|
||||||
|
"Fix gpsd deadlock causing GPS connect to hang",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.18.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Bluetooth: service data inspector, appearance codes, MAC cluster tracking, and behavioral flags",
|
||||||
|
"Bluetooth: IRK badge display, distance estimation with confidence, and signal stability metrics",
|
||||||
|
"ACARS: SoapySDR device support for SDRplay, LimeSDR, Airspy, and other non-RTL backends",
|
||||||
|
"ADS-B: stale dump1090 process cleanup via PID file tracking",
|
||||||
|
"GPS: error state indicator and UI refinements",
|
||||||
|
"Proximity radar and signal card UI improvements",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.17.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"BT Locate: SAR Bluetooth device location with GPS-tagged signal trail and proximity alerts",
|
||||||
|
"IRK auto-detection: extract Identity Resolving Keys from paired devices (macOS/Linux)",
|
||||||
|
"GPS mode: real-time position tracking with live map, speed, altitude, and satellite info",
|
||||||
|
"Bluetooth scanner lifecycle fix for bleak scan timeout tracking",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.16.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Sub-GHz analyzer with real-time RF capture and protocol decoding",
|
||||||
|
"Weather satellite auto-scheduler with polar plot and ground track map",
|
||||||
|
"SatDump support for local (non-Docker) installs via setup.sh",
|
||||||
|
"Shared waterfall UI across SDR modes",
|
||||||
|
"Listening post audio stuttering fix and SDR race condition fixes",
|
||||||
|
"Multi-arch Docker build support (amd64 + arm64)",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.15.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Real-time WebSocket waterfall with I/Q capture and server-side FFT",
|
||||||
|
"Cross-module frequency routing from Listening Post to decoders",
|
||||||
|
"Pure Python SSTV decoder replacing broken slowrx dependency",
|
||||||
|
"Real-time signal scope for pager, sensor, and SSTV modes",
|
||||||
|
"USB-level device probe to prevent cryptic rtl_fm crashes",
|
||||||
|
"SDR device lock-up fix from unreleased device registry on crash",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.14.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"HF SSTV general mode with predefined shortwave frequencies",
|
||||||
|
"WebSDR integration for remote HF/shortwave listening",
|
||||||
|
"Listening Post signal scanner and audio pipeline improvements",
|
||||||
|
"TSCM sweep resilience, WiFi detection, and correlation fixes",
|
||||||
|
"APRS rtl_fm startup and SDR device conflict fixes",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.13.1",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"UI overhaul with slate/cyan theme and JetBrains Mono font",
|
||||||
|
"Signal scanner rewritten with rtl_power sweep and SNR filtering",
|
||||||
|
"Listening Post audio streaming via WAV with retry/fallback",
|
||||||
|
"WiFi connected clients panel now filters to selected AP",
|
||||||
|
"Global navigation bar across all dashboards",
|
||||||
|
"Fixed USB device contention when starting audio pipeline",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.13.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"WiFi client display in AP detail drawer with real-time SSE updates",
|
||||||
|
"Help modal system with keyboard shortcuts reference",
|
||||||
|
"Global navbar and settings modal accessible from all dashboards",
|
||||||
|
"Probed SSID badges for connected clients",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.12.1",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"SDR device registry to prevent decoder conflicts",
|
||||||
|
"SDR device status panel and ADS-B Bias-T toggle",
|
||||||
|
"Real-time Doppler tracking for ISS SSTV reception",
|
||||||
|
"TCP connection support for Meshtastic",
|
||||||
|
"Shared observer location with auto-start options",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.12.0",
|
||||||
|
"date": "January 2026",
|
||||||
|
"highlights": [
|
||||||
|
"ISS SSTV decoder with real-time ISS tracking globe",
|
||||||
|
"GitHub update notifications for new releases",
|
||||||
|
"Meshtastic QR code support and telemetry display",
|
||||||
|
"New Space category with reorganized UI",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.11.0",
|
||||||
|
"date": "January 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Meshtastic LoRa mesh network integration",
|
||||||
|
"Ubertooth One BLE scanning support",
|
||||||
|
"Offline mode with bundled assets",
|
||||||
|
"Settings modal with tile provider configuration",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.10.0",
|
||||||
|
"date": "January 2026",
|
||||||
|
"highlights": [
|
||||||
|
"AIS vessel tracking with VHF DSC distress monitoring",
|
||||||
|
"Spy Stations database (number stations & diplomatic HF)",
|
||||||
|
"MMSI country identification and distress alert overlays",
|
||||||
|
"SDR device conflict detection for AIS/DSC",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.9.5",
|
||||||
|
"date": "January 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Enhanced TSCM with MAC-randomization resistant detection",
|
||||||
|
"Clickable score cards and device detail expansion",
|
||||||
|
"RF scanning improvements with status feedback",
|
||||||
|
"Root privilege check and warning display",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.9.0",
|
||||||
|
"date": "January 2026",
|
||||||
|
"highlights": [
|
||||||
|
"New dropdown navigation menus for cleaner UI",
|
||||||
|
"TSCM baseline recording now captures device data",
|
||||||
|
"Device identity engine integration for threat detection",
|
||||||
|
"Welcome screen with mode selection",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.8.0",
|
||||||
|
"date": "December 2025",
|
||||||
|
"highlights": [
|
||||||
|
"Added TSCM counter-surveillance mode",
|
||||||
|
"WiFi/Bluetooth device correlation engine",
|
||||||
|
"Tracker detection (AirTag, Tile, SmartTag)",
|
||||||
|
"Risk scoring and threat classification",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _get_env(key: str, default: str) -> str:
|
def _get_env(key: str, default: str) -> str:
|
||||||
"""Get environment variable with default."""
|
"""Get environment variable with default."""
|
||||||
return os.environ.get(f'INTERCEPT_{key}', default)
|
return os.environ.get(f"INTERCEPT_{key}", default)
|
||||||
|
|
||||||
|
|
||||||
def _get_env_int(key: str, default: int) -> int:
|
def _get_env_int(key: str, default: int) -> int:
|
||||||
"""Get environment variable as integer with default."""
|
"""Get environment variable as integer with default."""
|
||||||
try:
|
try:
|
||||||
return int(os.environ.get(f'INTERCEPT_{key}', str(default)))
|
return int(os.environ.get(f"INTERCEPT_{key}", str(default)))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
@@ -26,68 +396,130 @@ def _get_env_int(key: str, default: int) -> int:
|
|||||||
def _get_env_float(key: str, default: float) -> float:
|
def _get_env_float(key: str, default: float) -> float:
|
||||||
"""Get environment variable as float with default."""
|
"""Get environment variable as float with default."""
|
||||||
try:
|
try:
|
||||||
return float(os.environ.get(f'INTERCEPT_{key}', str(default)))
|
return float(os.environ.get(f"INTERCEPT_{key}", str(default)))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
def _get_env_bool(key: str, default: bool) -> bool:
|
def _get_env_bool(key: str, default: bool) -> bool:
|
||||||
"""Get environment variable as boolean with default."""
|
"""Get environment variable as boolean with default."""
|
||||||
val = os.environ.get(f'INTERCEPT_{key}', '').lower()
|
val = os.environ.get(f"INTERCEPT_{key}", "").lower()
|
||||||
if val in ('true', '1', 'yes', 'on'):
|
if val in ("true", "1", "yes", "on"):
|
||||||
return True
|
return True
|
||||||
if val in ('false', '0', 'no', 'off'):
|
if val in ("false", "0", "no", "off"):
|
||||||
return False
|
return False
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
# Logging configuration
|
# Logging configuration
|
||||||
_log_level_str = _get_env('LOG_LEVEL', 'WARNING').upper()
|
_log_level_str = _get_env("LOG_LEVEL", "WARNING").upper()
|
||||||
LOG_LEVEL = getattr(logging, _log_level_str, logging.WARNING)
|
LOG_LEVEL = getattr(logging, _log_level_str, logging.WARNING)
|
||||||
LOG_FORMAT = _get_env('LOG_FORMAT', '%(asctime)s - %(levelname)s - %(message)s')
|
LOG_FORMAT = _get_env("LOG_FORMAT", "%(asctime)s - %(levelname)s - %(message)s")
|
||||||
|
|
||||||
# Server settings
|
# Server settings
|
||||||
HOST = _get_env('HOST', '0.0.0.0')
|
HOST = _get_env("HOST", "0.0.0.0")
|
||||||
PORT = _get_env_int('PORT', 5050)
|
PORT = _get_env_int("PORT", 5050)
|
||||||
DEBUG = _get_env_bool('DEBUG', False)
|
DEBUG = _get_env_bool("DEBUG", False)
|
||||||
THREADED = _get_env_bool('THREADED', True)
|
THREADED = _get_env_bool("THREADED", True)
|
||||||
|
|
||||||
|
# HTTPS / SSL settings
|
||||||
|
HTTPS = _get_env_bool("HTTPS", False)
|
||||||
|
SSL_CERT = _get_env("SSL_CERT", "")
|
||||||
|
SSL_KEY = _get_env("SSL_KEY", "")
|
||||||
|
|
||||||
# Default RTL-SDR settings
|
# Default RTL-SDR settings
|
||||||
DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40')
|
DEFAULT_GAIN = _get_env("DEFAULT_GAIN", "40")
|
||||||
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
|
DEFAULT_DEVICE = _get_env("DEFAULT_DEVICE", "0")
|
||||||
|
|
||||||
# Pager defaults
|
# Pager defaults
|
||||||
DEFAULT_PAGER_FREQ = _get_env('PAGER_FREQ', '929.6125M')
|
DEFAULT_PAGER_FREQ = _get_env("PAGER_FREQ", "929.6125M")
|
||||||
|
|
||||||
# Timeouts
|
# Timeouts
|
||||||
PROCESS_TIMEOUT = _get_env_int('PROCESS_TIMEOUT', 5)
|
PROCESS_TIMEOUT = _get_env_int("PROCESS_TIMEOUT", 5)
|
||||||
SOCKET_TIMEOUT = _get_env_int('SOCKET_TIMEOUT', 5)
|
SOCKET_TIMEOUT = _get_env_int("SOCKET_TIMEOUT", 5)
|
||||||
SSE_TIMEOUT = _get_env_int('SSE_TIMEOUT', 1)
|
SSE_TIMEOUT = _get_env_int("SSE_TIMEOUT", 1)
|
||||||
|
|
||||||
# WiFi settings
|
# WiFi settings
|
||||||
WIFI_UPDATE_INTERVAL = _get_env_float('WIFI_UPDATE_INTERVAL', 2.0)
|
WIFI_UPDATE_INTERVAL = _get_env_float("WIFI_UPDATE_INTERVAL", 2.0)
|
||||||
AIRODUMP_HEADER_LINES = _get_env_int('AIRODUMP_HEADER_LINES', 2)
|
AIRODUMP_HEADER_LINES = _get_env_int("AIRODUMP_HEADER_LINES", 2)
|
||||||
|
|
||||||
# Bluetooth settings
|
# Bluetooth settings
|
||||||
BT_SCAN_TIMEOUT = _get_env_int('BT_SCAN_TIMEOUT', 10)
|
BT_SCAN_TIMEOUT = _get_env_int("BT_SCAN_TIMEOUT", 10)
|
||||||
BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
|
BT_UPDATE_INTERVAL = _get_env_float("BT_UPDATE_INTERVAL", 2.0)
|
||||||
|
|
||||||
# ADS-B settings
|
# ADS-B settings
|
||||||
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
ADSB_SBS_PORT = _get_env_int("ADSB_SBS_PORT", 30003)
|
||||||
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
|
ADSB_UPDATE_INTERVAL = _get_env_float("ADSB_UPDATE_INTERVAL", 1.0)
|
||||||
|
ADSB_AUTO_START = _get_env_bool("ADSB_AUTO_START", False)
|
||||||
|
ADSB_HISTORY_ENABLED = _get_env_bool("ADSB_HISTORY_ENABLED", False)
|
||||||
|
ADSB_DB_HOST = _get_env("ADSB_DB_HOST", "localhost")
|
||||||
|
ADSB_DB_PORT = _get_env_int("ADSB_DB_PORT", 5432)
|
||||||
|
ADSB_DB_NAME = _get_env("ADSB_DB_NAME", "intercept_adsb")
|
||||||
|
ADSB_DB_USER = _get_env("ADSB_DB_USER", "intercept")
|
||||||
|
ADSB_DB_PASSWORD = _get_env("ADSB_DB_PASSWORD", "intercept")
|
||||||
|
ADSB_HISTORY_BATCH_SIZE = _get_env_int("ADSB_HISTORY_BATCH_SIZE", 500)
|
||||||
|
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float("ADSB_HISTORY_FLUSH_INTERVAL", 1.0)
|
||||||
|
ADSB_HISTORY_QUEUE_SIZE = _get_env_int("ADSB_HISTORY_QUEUE_SIZE", 50000)
|
||||||
|
|
||||||
|
# Observer location settings
|
||||||
|
SHARED_OBSERVER_LOCATION_ENABLED = _get_env_bool("SHARED_OBSERVER_LOCATION", True)
|
||||||
|
DEFAULT_LATITUDE = _get_env_float("DEFAULT_LAT", 0.0)
|
||||||
|
DEFAULT_LONGITUDE = _get_env_float("DEFAULT_LON", 0.0)
|
||||||
|
|
||||||
# Satellite settings
|
# Satellite settings
|
||||||
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
SATELLITE_UPDATE_INTERVAL = _get_env_int("SATELLITE_UPDATE_INTERVAL", 30)
|
||||||
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
SATELLITE_TRAJECTORY_POINTS = _get_env_int("SATELLITE_TRAJECTORY_POINTS", 30)
|
||||||
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
SATELLITE_ORBIT_MINUTES = _get_env_int("SATELLITE_ORBIT_MINUTES", 45)
|
||||||
|
|
||||||
|
# Weather satellite settings
|
||||||
|
WEATHER_SAT_DEFAULT_GAIN = _get_env_float("WEATHER_SAT_GAIN", 30.0)
|
||||||
|
WEATHER_SAT_SAMPLE_RATE = _get_env_int("WEATHER_SAT_SAMPLE_RATE", 2400000)
|
||||||
|
WEATHER_SAT_MIN_ELEVATION = _get_env_float("WEATHER_SAT_MIN_ELEVATION", 15.0)
|
||||||
|
WEATHER_SAT_PREDICTION_HOURS = _get_env_int("WEATHER_SAT_PREDICTION_HOURS", 24)
|
||||||
|
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int("WEATHER_SAT_SCHEDULE_REFRESH_MINUTES", 30)
|
||||||
|
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int("WEATHER_SAT_CAPTURE_BUFFER_SECONDS", 30)
|
||||||
|
|
||||||
|
# WeFax (Weather Fax) settings
|
||||||
|
WEFAX_DEFAULT_GAIN = _get_env_float("WEFAX_GAIN", 40.0)
|
||||||
|
WEFAX_SAMPLE_RATE = _get_env_int("WEFAX_SAMPLE_RATE", 22050)
|
||||||
|
WEFAX_DEFAULT_IOC = _get_env_int("WEFAX_IOC", 576)
|
||||||
|
WEFAX_DEFAULT_LPM = _get_env_int("WEFAX_LPM", 120)
|
||||||
|
WEFAX_SCHEDULE_REFRESH_MINUTES = _get_env_int("WEFAX_SCHEDULE_REFRESH_MINUTES", 30)
|
||||||
|
WEFAX_CAPTURE_BUFFER_SECONDS = _get_env_int("WEFAX_CAPTURE_BUFFER_SECONDS", 30)
|
||||||
|
|
||||||
|
# SubGHz transceiver settings (HackRF)
|
||||||
|
SUBGHZ_DEFAULT_FREQUENCY = _get_env_float("SUBGHZ_FREQUENCY", 433.92)
|
||||||
|
SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int("SUBGHZ_SAMPLE_RATE", 2000000)
|
||||||
|
SUBGHZ_DEFAULT_LNA_GAIN = _get_env_int("SUBGHZ_LNA_GAIN", 32)
|
||||||
|
SUBGHZ_DEFAULT_VGA_GAIN = _get_env_int("SUBGHZ_VGA_GAIN", 20)
|
||||||
|
SUBGHZ_DEFAULT_TX_GAIN = _get_env_int("SUBGHZ_TX_GAIN", 20)
|
||||||
|
SUBGHZ_MAX_TX_DURATION = _get_env_int("SUBGHZ_MAX_TX_DURATION", 10)
|
||||||
|
SUBGHZ_SWEEP_START_MHZ = _get_env_float("SUBGHZ_SWEEP_START", 300.0)
|
||||||
|
SUBGHZ_SWEEP_END_MHZ = _get_env_float("SUBGHZ_SWEEP_END", 928.0)
|
||||||
|
|
||||||
|
# Radiosonde settings
|
||||||
|
RADIOSONDE_FREQ_MIN = _get_env_float("RADIOSONDE_FREQ_MIN", 400.0)
|
||||||
|
RADIOSONDE_FREQ_MAX = _get_env_float("RADIOSONDE_FREQ_MAX", 406.0)
|
||||||
|
RADIOSONDE_DEFAULT_GAIN = _get_env_float("RADIOSONDE_GAIN", 40.0)
|
||||||
|
RADIOSONDE_UDP_PORT = _get_env_int("RADIOSONDE_UDP_PORT", 55673)
|
||||||
|
|
||||||
|
# Update checking
|
||||||
|
GITHUB_REPO = _get_env("GITHUB_REPO", "smittix/intercept")
|
||||||
|
UPDATE_CHECK_ENABLED = _get_env_bool("UPDATE_CHECK_ENABLED", True)
|
||||||
|
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int("UPDATE_CHECK_INTERVAL_HOURS", 6)
|
||||||
|
|
||||||
|
# Alerting
|
||||||
|
ALERT_WEBHOOK_URL = _get_env("ALERT_WEBHOOK_URL", "")
|
||||||
|
ALERT_WEBHOOK_SECRET = _get_env("ALERT_WEBHOOK_SECRET", "")
|
||||||
|
ALERT_WEBHOOK_TIMEOUT = _get_env_int("ALERT_WEBHOOK_TIMEOUT", 5)
|
||||||
|
|
||||||
|
# Admin credentials
|
||||||
|
ADMIN_USERNAME = _get_env("ADMIN_USERNAME", "admin")
|
||||||
|
ADMIN_PASSWORD = _get_env("ADMIN_PASSWORD", "admin")
|
||||||
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
"""Configure application logging."""
|
"""Configure application logging."""
|
||||||
logging.basicConfig(
|
logging.basicConfig(level=LOG_LEVEL, format=LOG_FORMAT, stream=sys.stderr)
|
||||||
level=LOG_LEVEL,
|
|
||||||
format=LOG_FORMAT,
|
|
||||||
stream=sys.stderr
|
|
||||||
)
|
|
||||||
# Suppress Flask development server warning
|
# Suppress Flask development server warning
|
||||||
logging.getLogger('werkzeug').setLevel(LOG_LEVEL)
|
logging.getLogger("werkzeug").setLevel(LOG_LEVEL)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Data modules for INTERCEPT
|
# Data modules for INTERCEPT
|
||||||
from .oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
from .oui import OUI_DATABASE, get_manufacturer, load_oui_database
|
||||||
from .satellites import TLE_SATELLITES
|
|
||||||
from .patterns import (
|
from .patterns import (
|
||||||
AIRTAG_PREFIXES,
|
AIRTAG_PREFIXES,
|
||||||
TILE_PREFIXES,
|
|
||||||
SAMSUNG_TRACKER,
|
|
||||||
DRONE_SSID_PATTERNS,
|
|
||||||
DRONE_OUI_PREFIXES,
|
DRONE_OUI_PREFIXES,
|
||||||
|
DRONE_SSID_PATTERNS,
|
||||||
|
SAMSUNG_TRACKER,
|
||||||
|
TILE_PREFIXES,
|
||||||
)
|
)
|
||||||
|
from .satellites import TLE_SATELLITES
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import json
|
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.oui')
|
logger = logging.getLogger("intercept.oui")
|
||||||
|
|
||||||
|
|
||||||
def load_oui_database() -> dict[str, str] | None:
|
def load_oui_database() -> dict[str, str] | None:
|
||||||
"""Load OUI database from external JSON file, with fallback to built-in."""
|
"""Load OUI database from external JSON file, with fallback to built-in."""
|
||||||
oui_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'oui_database.json')
|
oui_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "oui_database.json")
|
||||||
try:
|
try:
|
||||||
if os.path.exists(oui_file):
|
if os.path.exists(oui_file):
|
||||||
with open(oui_file, 'r') as f:
|
with open(oui_file) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
# Remove comment fields
|
# Remove comment fields
|
||||||
return {k: v for k, v in data.items() if not k.startswith('_')}
|
return {k: v for k, v in data.items() if not k.startswith("_")}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error loading oui_database.json: {e}, using built-in database")
|
logger.warning(f"Error loading oui_database.json: {e}, using built-in database")
|
||||||
return None # Will fall back to built-in
|
return None # Will fall back to built-in
|
||||||
@@ -24,143 +24,414 @@ def load_oui_database() -> dict[str, str] | None:
|
|||||||
def get_manufacturer(mac: str) -> str:
|
def get_manufacturer(mac: str) -> str:
|
||||||
"""Look up manufacturer from MAC address OUI."""
|
"""Look up manufacturer from MAC address OUI."""
|
||||||
prefix = mac[:8].upper()
|
prefix = mac[:8].upper()
|
||||||
return OUI_DATABASE.get(prefix, 'Unknown')
|
return OUI_DATABASE.get(prefix, "Unknown")
|
||||||
|
|
||||||
|
|
||||||
# OUI Database for manufacturer lookup (expanded)
|
# OUI Database for manufacturer lookup (expanded)
|
||||||
OUI_DATABASE = {
|
OUI_DATABASE = {
|
||||||
# Apple (extensive list)
|
# Apple (extensive list)
|
||||||
'00:25:DB': 'Apple', '04:52:F3': 'Apple', '0C:3E:9F': 'Apple', '10:94:BB': 'Apple',
|
"00:25:DB": "Apple",
|
||||||
'14:99:E2': 'Apple', '20:78:F0': 'Apple', '28:6A:BA': 'Apple', '3C:22:FB': 'Apple',
|
"04:52:F3": "Apple",
|
||||||
'40:98:AD': 'Apple', '48:D7:05': 'Apple', '4C:57:CA': 'Apple', '54:4E:90': 'Apple',
|
"0C:3E:9F": "Apple",
|
||||||
'5C:97:F3': 'Apple', '60:F8:1D': 'Apple', '68:DB:CA': 'Apple', '70:56:81': 'Apple',
|
"10:94:BB": "Apple",
|
||||||
'78:7B:8A': 'Apple', '7C:D1:C3': 'Apple', '84:FC:FE': 'Apple', '8C:2D:AA': 'Apple',
|
"14:99:E2": "Apple",
|
||||||
'90:B0:ED': 'Apple', '98:01:A7': 'Apple', '98:D6:BB': 'Apple', 'A4:D1:D2': 'Apple',
|
"20:78:F0": "Apple",
|
||||||
'AC:BC:32': 'Apple', 'B0:34:95': 'Apple', 'B8:C1:11': 'Apple', 'C8:69:CD': 'Apple',
|
"28:6A:BA": "Apple",
|
||||||
'D0:03:4B': 'Apple', 'DC:A9:04': 'Apple', 'E0:C7:67': 'Apple', 'F0:18:98': 'Apple',
|
"3C:22:FB": "Apple",
|
||||||
'F4:5C:89': 'Apple', '78:4F:43': 'Apple', '00:CD:FE': 'Apple', '04:4B:ED': 'Apple',
|
"40:98:AD": "Apple",
|
||||||
'04:D3:CF': 'Apple', '08:66:98': 'Apple', '0C:74:C2': 'Apple', '10:DD:B1': 'Apple',
|
"48:D7:05": "Apple",
|
||||||
'14:10:9F': 'Apple', '18:EE:69': 'Apple', '1C:36:BB': 'Apple', '24:A0:74': 'Apple',
|
"4C:57:CA": "Apple",
|
||||||
'28:37:37': 'Apple', '2C:BE:08': 'Apple', '34:08:BC': 'Apple', '38:C9:86': 'Apple',
|
"54:4E:90": "Apple",
|
||||||
'3C:06:30': 'Apple', '44:D8:84': 'Apple', '48:A9:1C': 'Apple', '4C:32:75': 'Apple',
|
"5C:97:F3": "Apple",
|
||||||
'50:32:37': 'Apple', '54:26:96': 'Apple', '58:B0:35': 'Apple', '5C:F7:E6': 'Apple',
|
"60:F8:1D": "Apple",
|
||||||
'64:A3:CB': 'Apple', '68:FE:F7': 'Apple', '6C:4D:73': 'Apple', '70:DE:E2': 'Apple',
|
"68:DB:CA": "Apple",
|
||||||
'74:E2:F5': 'Apple', '78:67:D7': 'Apple', '7C:04:D0': 'Apple', '80:E6:50': 'Apple',
|
"70:56:81": "Apple",
|
||||||
'84:78:8B': 'Apple', '88:66:A5': 'Apple', '8C:85:90': 'Apple', '94:E9:6A': 'Apple',
|
"78:7B:8A": "Apple",
|
||||||
'9C:F4:8E': 'Apple', 'A0:99:9B': 'Apple', 'A4:83:E7': 'Apple', 'A8:5C:2C': 'Apple',
|
"7C:D1:C3": "Apple",
|
||||||
'AC:1F:74': 'Apple', 'B0:19:C6': 'Apple', 'B4:F1:DA': 'Apple', 'BC:52:B7': 'Apple',
|
"84:FC:FE": "Apple",
|
||||||
'C0:A5:3E': 'Apple', 'C4:B3:01': 'Apple', 'CC:20:E8': 'Apple', 'D0:C5:F3': 'Apple',
|
"8C:2D:AA": "Apple",
|
||||||
'D4:61:9D': 'Apple', 'D8:1C:79': 'Apple', 'E0:5F:45': 'Apple', 'E4:C6:3D': 'Apple',
|
"90:B0:ED": "Apple",
|
||||||
'F0:B4:79': 'Apple', 'F4:0F:24': 'Apple', 'F8:4D:89': 'Apple', 'FC:D8:48': 'Apple',
|
"98:01:A7": "Apple",
|
||||||
|
"98:D6:BB": "Apple",
|
||||||
|
"A4:D1:D2": "Apple",
|
||||||
|
"AC:BC:32": "Apple",
|
||||||
|
"B0:34:95": "Apple",
|
||||||
|
"B8:C1:11": "Apple",
|
||||||
|
"C8:69:CD": "Apple",
|
||||||
|
"D0:03:4B": "Apple",
|
||||||
|
"DC:A9:04": "Apple",
|
||||||
|
"E0:C7:67": "Apple",
|
||||||
|
"F0:18:98": "Apple",
|
||||||
|
"F4:5C:89": "Apple",
|
||||||
|
"78:4F:43": "Apple",
|
||||||
|
"00:CD:FE": "Apple",
|
||||||
|
"04:4B:ED": "Apple",
|
||||||
|
"04:D3:CF": "Apple",
|
||||||
|
"08:66:98": "Apple",
|
||||||
|
"0C:74:C2": "Apple",
|
||||||
|
"10:DD:B1": "Apple",
|
||||||
|
"14:10:9F": "Apple",
|
||||||
|
"18:EE:69": "Apple",
|
||||||
|
"1C:36:BB": "Apple",
|
||||||
|
"24:A0:74": "Apple",
|
||||||
|
"28:37:37": "Apple",
|
||||||
|
"2C:BE:08": "Apple",
|
||||||
|
"34:08:BC": "Apple",
|
||||||
|
"38:C9:86": "Apple",
|
||||||
|
"3C:06:30": "Apple",
|
||||||
|
"44:D8:84": "Apple",
|
||||||
|
"48:A9:1C": "Apple",
|
||||||
|
"4C:32:75": "Apple",
|
||||||
|
"50:32:37": "Apple",
|
||||||
|
"54:26:96": "Apple",
|
||||||
|
"58:B0:35": "Apple",
|
||||||
|
"5C:F7:E6": "Apple",
|
||||||
|
"64:A3:CB": "Apple",
|
||||||
|
"68:FE:F7": "Apple",
|
||||||
|
"6C:4D:73": "Apple",
|
||||||
|
"70:DE:E2": "Apple",
|
||||||
|
"74:E2:F5": "Apple",
|
||||||
|
"78:67:D7": "Apple",
|
||||||
|
"7C:04:D0": "Apple",
|
||||||
|
"80:E6:50": "Apple",
|
||||||
|
"84:78:8B": "Apple",
|
||||||
|
"88:66:A5": "Apple",
|
||||||
|
"8C:85:90": "Apple",
|
||||||
|
"94:E9:6A": "Apple",
|
||||||
|
"9C:F4:8E": "Apple",
|
||||||
|
"A0:99:9B": "Apple",
|
||||||
|
"A4:83:E7": "Apple",
|
||||||
|
"A8:5C:2C": "Apple",
|
||||||
|
"AC:1F:74": "Apple",
|
||||||
|
"B0:19:C6": "Apple",
|
||||||
|
"B4:F1:DA": "Apple",
|
||||||
|
"BC:52:B7": "Apple",
|
||||||
|
"C0:A5:3E": "Apple",
|
||||||
|
"C4:B3:01": "Apple",
|
||||||
|
"CC:20:E8": "Apple",
|
||||||
|
"D0:C5:F3": "Apple",
|
||||||
|
"D4:61:9D": "Apple",
|
||||||
|
"D8:1C:79": "Apple",
|
||||||
|
"E0:5F:45": "Apple",
|
||||||
|
"E4:C6:3D": "Apple",
|
||||||
|
"F0:B4:79": "Apple",
|
||||||
|
"F4:0F:24": "Apple",
|
||||||
|
"F8:4D:89": "Apple",
|
||||||
|
"FC:D8:48": "Apple",
|
||||||
# Samsung
|
# Samsung
|
||||||
'00:1B:66': 'Samsung', '00:21:19': 'Samsung', '00:26:37': 'Samsung', '5C:0A:5B': 'Samsung',
|
"00:1B:66": "Samsung",
|
||||||
'8C:71:F8': 'Samsung', 'C4:73:1E': 'Samsung', '38:2C:4A': 'Samsung', '00:1E:4C': 'Samsung',
|
"00:21:19": "Samsung",
|
||||||
'00:12:47': 'Samsung', '00:15:99': 'Samsung', '00:17:D5': 'Samsung', '00:1D:F6': 'Samsung',
|
"00:26:37": "Samsung",
|
||||||
'00:21:D1': 'Samsung', '00:24:54': 'Samsung', '00:26:5D': 'Samsung', '08:D4:2B': 'Samsung',
|
"5C:0A:5B": "Samsung",
|
||||||
'10:D5:42': 'Samsung', '14:49:E0': 'Samsung', '18:3A:2D': 'Samsung', '1C:66:AA': 'Samsung',
|
"8C:71:F8": "Samsung",
|
||||||
'24:4B:81': 'Samsung', '28:98:7B': 'Samsung', '2C:AE:2B': 'Samsung', '30:96:FB': 'Samsung',
|
"C4:73:1E": "Samsung",
|
||||||
'34:C3:AC': 'Samsung', '38:01:95': 'Samsung', '3C:5A:37': 'Samsung', '40:0E:85': 'Samsung',
|
"38:2C:4A": "Samsung",
|
||||||
'44:4E:1A': 'Samsung', '4C:BC:A5': 'Samsung', '50:01:BB': 'Samsung', '50:A4:D0': 'Samsung',
|
"00:1E:4C": "Samsung",
|
||||||
'54:88:0E': 'Samsung', '58:C3:8B': 'Samsung', '5C:2E:59': 'Samsung', '60:D0:A9': 'Samsung',
|
"00:12:47": "Samsung",
|
||||||
'64:B3:10': 'Samsung', '68:48:98': 'Samsung', '6C:2F:2C': 'Samsung', '70:F9:27': 'Samsung',
|
"00:15:99": "Samsung",
|
||||||
'74:45:8A': 'Samsung', '78:47:1D': 'Samsung', '7C:0B:C6': 'Samsung', '84:11:9E': 'Samsung',
|
"00:17:D5": "Samsung",
|
||||||
'88:32:9B': 'Samsung', '8C:77:12': 'Samsung', '90:18:7C': 'Samsung', '94:35:0A': 'Samsung',
|
"00:1D:F6": "Samsung",
|
||||||
'98:52:B1': 'Samsung', '9C:02:98': 'Samsung', 'A0:0B:BA': 'Samsung', 'A4:7B:85': 'Samsung',
|
"00:21:D1": "Samsung",
|
||||||
'A8:06:00': 'Samsung', 'AC:5F:3E': 'Samsung', 'B0:72:BF': 'Samsung', 'B4:79:A7': 'Samsung',
|
"00:24:54": "Samsung",
|
||||||
'BC:44:86': 'Samsung', 'C0:97:27': 'Samsung', 'C4:42:02': 'Samsung', 'CC:07:AB': 'Samsung',
|
"00:26:5D": "Samsung",
|
||||||
'D0:22:BE': 'Samsung', 'D4:87:D8': 'Samsung', 'D8:90:E8': 'Samsung', 'E4:7C:F9': 'Samsung',
|
"08:D4:2B": "Samsung",
|
||||||
'E8:50:8B': 'Samsung', 'F0:25:B7': 'Samsung', 'F4:7B:5E': 'Samsung', 'FC:A1:3E': 'Samsung',
|
"10:D5:42": "Samsung",
|
||||||
|
"14:49:E0": "Samsung",
|
||||||
|
"18:3A:2D": "Samsung",
|
||||||
|
"1C:66:AA": "Samsung",
|
||||||
|
"24:4B:81": "Samsung",
|
||||||
|
"28:98:7B": "Samsung",
|
||||||
|
"2C:AE:2B": "Samsung",
|
||||||
|
"30:96:FB": "Samsung",
|
||||||
|
"34:C3:AC": "Samsung",
|
||||||
|
"38:01:95": "Samsung",
|
||||||
|
"3C:5A:37": "Samsung",
|
||||||
|
"40:0E:85": "Samsung",
|
||||||
|
"44:4E:1A": "Samsung",
|
||||||
|
"4C:BC:A5": "Samsung",
|
||||||
|
"50:01:BB": "Samsung",
|
||||||
|
"50:A4:D0": "Samsung",
|
||||||
|
"54:88:0E": "Samsung",
|
||||||
|
"58:C3:8B": "Samsung",
|
||||||
|
"5C:2E:59": "Samsung",
|
||||||
|
"60:D0:A9": "Samsung",
|
||||||
|
"64:B3:10": "Samsung",
|
||||||
|
"68:48:98": "Samsung",
|
||||||
|
"6C:2F:2C": "Samsung",
|
||||||
|
"70:F9:27": "Samsung",
|
||||||
|
"74:45:8A": "Samsung",
|
||||||
|
"78:47:1D": "Samsung",
|
||||||
|
"7C:0B:C6": "Samsung",
|
||||||
|
"84:11:9E": "Samsung",
|
||||||
|
"88:32:9B": "Samsung",
|
||||||
|
"8C:77:12": "Samsung",
|
||||||
|
"90:18:7C": "Samsung",
|
||||||
|
"94:35:0A": "Samsung",
|
||||||
|
"98:52:B1": "Samsung",
|
||||||
|
"9C:02:98": "Samsung",
|
||||||
|
"A0:0B:BA": "Samsung",
|
||||||
|
"A4:7B:85": "Samsung",
|
||||||
|
"A8:06:00": "Samsung",
|
||||||
|
"AC:5F:3E": "Samsung",
|
||||||
|
"B0:72:BF": "Samsung",
|
||||||
|
"B4:79:A7": "Samsung",
|
||||||
|
"BC:44:86": "Samsung",
|
||||||
|
"C0:97:27": "Samsung",
|
||||||
|
"C4:42:02": "Samsung",
|
||||||
|
"CC:07:AB": "Samsung",
|
||||||
|
"D0:22:BE": "Samsung",
|
||||||
|
"D4:87:D8": "Samsung",
|
||||||
|
"D8:90:E8": "Samsung",
|
||||||
|
"E4:7C:F9": "Samsung",
|
||||||
|
"E8:50:8B": "Samsung",
|
||||||
|
"F0:25:B7": "Samsung",
|
||||||
|
"F4:7B:5E": "Samsung",
|
||||||
|
"FC:A1:3E": "Samsung",
|
||||||
# Google
|
# Google
|
||||||
'54:60:09': 'Google', '00:1A:11': 'Google', 'F4:F5:D8': 'Google', '94:EB:2C': 'Google',
|
"54:60:09": "Google",
|
||||||
'64:B5:C6': 'Google', '3C:5A:B4': 'Google', 'F8:8F:CA': 'Google', '20:DF:B9': 'Google',
|
"00:1A:11": "Google",
|
||||||
'54:27:1E': 'Google', '58:CB:52': 'Google', 'A4:77:33': 'Google', 'F4:0E:22': 'Google',
|
"F4:F5:D8": "Google",
|
||||||
|
"94:EB:2C": "Google",
|
||||||
|
"64:B5:C6": "Google",
|
||||||
|
"3C:5A:B4": "Google",
|
||||||
|
"F8:8F:CA": "Google",
|
||||||
|
"20:DF:B9": "Google",
|
||||||
|
"54:27:1E": "Google",
|
||||||
|
"58:CB:52": "Google",
|
||||||
|
"A4:77:33": "Google",
|
||||||
|
"F4:0E:22": "Google",
|
||||||
# Sony
|
# Sony
|
||||||
'00:13:A9': 'Sony', '00:1D:28': 'Sony', '00:24:BE': 'Sony', '04:5D:4B': 'Sony',
|
"00:13:A9": "Sony",
|
||||||
'08:A9:5A': 'Sony', '10:4F:A8': 'Sony', '24:21:AB': 'Sony', '30:52:CB': 'Sony',
|
"00:1D:28": "Sony",
|
||||||
'40:B8:37': 'Sony', '58:48:22': 'Sony', '70:9E:29': 'Sony', '84:00:D2': 'Sony',
|
"00:24:BE": "Sony",
|
||||||
'AC:9B:0A': 'Sony', 'B4:52:7D': 'Sony', 'BC:60:A7': 'Sony', 'FC:0F:E6': 'Sony',
|
"04:5D:4B": "Sony",
|
||||||
|
"08:A9:5A": "Sony",
|
||||||
|
"10:4F:A8": "Sony",
|
||||||
|
"24:21:AB": "Sony",
|
||||||
|
"30:52:CB": "Sony",
|
||||||
|
"40:B8:37": "Sony",
|
||||||
|
"58:48:22": "Sony",
|
||||||
|
"70:9E:29": "Sony",
|
||||||
|
"84:00:D2": "Sony",
|
||||||
|
"AC:9B:0A": "Sony",
|
||||||
|
"B4:52:7D": "Sony",
|
||||||
|
"BC:60:A7": "Sony",
|
||||||
|
"FC:0F:E6": "Sony",
|
||||||
# Bose
|
# Bose
|
||||||
'00:0C:8A': 'Bose', '04:52:C7': 'Bose', '08:DF:1F': 'Bose', '2C:41:A1': 'Bose',
|
"00:0C:8A": "Bose",
|
||||||
'4C:87:5D': 'Bose', '60:AB:D2': 'Bose', '88:C9:E8': 'Bose', 'D8:9C:67': 'Bose',
|
"04:52:C7": "Bose",
|
||||||
|
"08:DF:1F": "Bose",
|
||||||
|
"2C:41:A1": "Bose",
|
||||||
|
"4C:87:5D": "Bose",
|
||||||
|
"60:AB:D2": "Bose",
|
||||||
|
"88:C9:E8": "Bose",
|
||||||
|
"D8:9C:67": "Bose",
|
||||||
# JBL/Harman
|
# JBL/Harman
|
||||||
'00:1D:DF': 'JBL', '08:AE:D6': 'JBL', '20:3C:AE': 'JBL', '44:5E:F3': 'JBL',
|
"00:1D:DF": "JBL",
|
||||||
'50:C9:71': 'JBL', '74:5E:1C': 'JBL', '88:C6:26': 'JBL', 'AC:12:2F': 'JBL',
|
"08:AE:D6": "JBL",
|
||||||
|
"20:3C:AE": "JBL",
|
||||||
|
"44:5E:F3": "JBL",
|
||||||
|
"50:C9:71": "JBL",
|
||||||
|
"74:5E:1C": "JBL",
|
||||||
|
"88:C6:26": "JBL",
|
||||||
|
"AC:12:2F": "JBL",
|
||||||
# Beats (Apple subsidiary)
|
# Beats (Apple subsidiary)
|
||||||
'00:61:71': 'Beats', '48:D6:D5': 'Beats', '9C:64:8B': 'Beats', 'A4:E9:75': 'Beats',
|
"00:61:71": "Beats",
|
||||||
|
"48:D6:D5": "Beats",
|
||||||
|
"9C:64:8B": "Beats",
|
||||||
|
"A4:E9:75": "Beats",
|
||||||
# Jabra/GN Audio
|
# Jabra/GN Audio
|
||||||
'00:13:17': 'Jabra', '1C:48:F9': 'Jabra', '50:C2:ED': 'Jabra', '70:BF:92': 'Jabra',
|
"00:13:17": "Jabra",
|
||||||
'74:5C:4B': 'Jabra', '94:16:25': 'Jabra', 'D0:81:7A': 'Jabra', 'E8:EE:CC': 'Jabra',
|
"1C:48:F9": "Jabra",
|
||||||
|
"50:C2:ED": "Jabra",
|
||||||
|
"70:BF:92": "Jabra",
|
||||||
|
"74:5C:4B": "Jabra",
|
||||||
|
"94:16:25": "Jabra",
|
||||||
|
"D0:81:7A": "Jabra",
|
||||||
|
"E8:EE:CC": "Jabra",
|
||||||
# Sennheiser
|
# Sennheiser
|
||||||
'00:1B:66': 'Sennheiser', '00:22:27': 'Sennheiser', 'B8:AD:3E': 'Sennheiser',
|
"00:1B:66": "Sennheiser",
|
||||||
|
"00:22:27": "Sennheiser",
|
||||||
|
"B8:AD:3E": "Sennheiser",
|
||||||
# Xiaomi
|
# Xiaomi
|
||||||
'04:CF:8C': 'Xiaomi', '0C:1D:AF': 'Xiaomi', '10:2A:B3': 'Xiaomi', '18:59:36': 'Xiaomi',
|
"04:CF:8C": "Xiaomi",
|
||||||
'20:47:DA': 'Xiaomi', '28:6C:07': 'Xiaomi', '34:CE:00': 'Xiaomi', '38:A4:ED': 'Xiaomi',
|
"0C:1D:AF": "Xiaomi",
|
||||||
'44:23:7C': 'Xiaomi', '50:64:2B': 'Xiaomi', '58:44:98': 'Xiaomi', '64:09:80': 'Xiaomi',
|
"10:2A:B3": "Xiaomi",
|
||||||
'74:23:44': 'Xiaomi', '78:02:F8': 'Xiaomi', '7C:1C:4E': 'Xiaomi', '84:F3:EB': 'Xiaomi',
|
"18:59:36": "Xiaomi",
|
||||||
'8C:BE:BE': 'Xiaomi', '98:FA:E3': 'Xiaomi', 'A4:77:58': 'Xiaomi', 'AC:C1:EE': 'Xiaomi',
|
"20:47:DA": "Xiaomi",
|
||||||
'B0:E2:35': 'Xiaomi', 'C4:0B:CB': 'Xiaomi', 'C8:47:8C': 'Xiaomi', 'D4:97:0B': 'Xiaomi',
|
"28:6C:07": "Xiaomi",
|
||||||
'E4:46:DA': 'Xiaomi', 'F0:B4:29': 'Xiaomi', 'FC:64:BA': 'Xiaomi',
|
"34:CE:00": "Xiaomi",
|
||||||
|
"38:A4:ED": "Xiaomi",
|
||||||
|
"44:23:7C": "Xiaomi",
|
||||||
|
"50:64:2B": "Xiaomi",
|
||||||
|
"58:44:98": "Xiaomi",
|
||||||
|
"64:09:80": "Xiaomi",
|
||||||
|
"74:23:44": "Xiaomi",
|
||||||
|
"78:02:F8": "Xiaomi",
|
||||||
|
"7C:1C:4E": "Xiaomi",
|
||||||
|
"84:F3:EB": "Xiaomi",
|
||||||
|
"8C:BE:BE": "Xiaomi",
|
||||||
|
"98:FA:E3": "Xiaomi",
|
||||||
|
"A4:77:58": "Xiaomi",
|
||||||
|
"AC:C1:EE": "Xiaomi",
|
||||||
|
"B0:E2:35": "Xiaomi",
|
||||||
|
"C4:0B:CB": "Xiaomi",
|
||||||
|
"C8:47:8C": "Xiaomi",
|
||||||
|
"D4:97:0B": "Xiaomi",
|
||||||
|
"E4:46:DA": "Xiaomi",
|
||||||
|
"F0:B4:29": "Xiaomi",
|
||||||
|
"FC:64:BA": "Xiaomi",
|
||||||
# Huawei
|
# Huawei
|
||||||
'00:18:82': 'Huawei', '00:1E:10': 'Huawei', '00:25:68': 'Huawei', '04:B0:E7': 'Huawei',
|
"00:18:82": "Huawei",
|
||||||
'08:63:61': 'Huawei', '10:1B:54': 'Huawei', '18:DE:D7': 'Huawei', '20:A6:80': 'Huawei',
|
"00:1E:10": "Huawei",
|
||||||
'28:31:52': 'Huawei', '34:12:98': 'Huawei', '3C:47:11': 'Huawei', '48:00:31': 'Huawei',
|
"00:25:68": "Huawei",
|
||||||
'4C:50:77': 'Huawei', '5C:7D:5E': 'Huawei', '60:DE:44': 'Huawei', '70:72:3C': 'Huawei',
|
"04:B0:E7": "Huawei",
|
||||||
'78:F5:57': 'Huawei', '80:B6:86': 'Huawei', '88:53:D4': 'Huawei', '94:04:9C': 'Huawei',
|
"08:63:61": "Huawei",
|
||||||
'A4:99:47': 'Huawei', 'B4:15:13': 'Huawei', 'BC:76:70': 'Huawei', 'C8:D1:5E': 'Huawei',
|
"10:1B:54": "Huawei",
|
||||||
'DC:D2:FC': 'Huawei', 'E4:68:A3': 'Huawei', 'F4:63:1F': 'Huawei',
|
"18:DE:D7": "Huawei",
|
||||||
|
"20:A6:80": "Huawei",
|
||||||
|
"28:31:52": "Huawei",
|
||||||
|
"34:12:98": "Huawei",
|
||||||
|
"3C:47:11": "Huawei",
|
||||||
|
"48:00:31": "Huawei",
|
||||||
|
"4C:50:77": "Huawei",
|
||||||
|
"5C:7D:5E": "Huawei",
|
||||||
|
"60:DE:44": "Huawei",
|
||||||
|
"70:72:3C": "Huawei",
|
||||||
|
"78:F5:57": "Huawei",
|
||||||
|
"80:B6:86": "Huawei",
|
||||||
|
"88:53:D4": "Huawei",
|
||||||
|
"94:04:9C": "Huawei",
|
||||||
|
"A4:99:47": "Huawei",
|
||||||
|
"B4:15:13": "Huawei",
|
||||||
|
"BC:76:70": "Huawei",
|
||||||
|
"C8:D1:5E": "Huawei",
|
||||||
|
"DC:D2:FC": "Huawei",
|
||||||
|
"E4:68:A3": "Huawei",
|
||||||
|
"F4:63:1F": "Huawei",
|
||||||
# OnePlus/BBK
|
# OnePlus/BBK
|
||||||
'64:A2:F9': 'OnePlus', 'C0:EE:FB': 'OnePlus', '94:65:2D': 'OnePlus',
|
"64:A2:F9": "OnePlus",
|
||||||
|
"C0:EE:FB": "OnePlus",
|
||||||
|
"94:65:2D": "OnePlus",
|
||||||
# Fitbit
|
# Fitbit
|
||||||
'2C:09:4D': 'Fitbit', 'C4:D9:87': 'Fitbit', 'E4:88:6D': 'Fitbit',
|
"2C:09:4D": "Fitbit",
|
||||||
|
"C4:D9:87": "Fitbit",
|
||||||
|
"E4:88:6D": "Fitbit",
|
||||||
# Garmin
|
# Garmin
|
||||||
'00:1C:D1': 'Garmin', 'C4:AC:59': 'Garmin', 'E8:0F:C8': 'Garmin',
|
"00:1C:D1": "Garmin",
|
||||||
|
"C4:AC:59": "Garmin",
|
||||||
|
"E8:0F:C8": "Garmin",
|
||||||
# Microsoft
|
# Microsoft
|
||||||
'00:50:F2': 'Microsoft', '28:18:78': 'Microsoft', '60:45:BD': 'Microsoft',
|
"00:50:F2": "Microsoft",
|
||||||
'7C:1E:52': 'Microsoft', '98:5F:D3': 'Microsoft', 'B4:0E:DE': 'Microsoft',
|
"28:18:78": "Microsoft",
|
||||||
|
"60:45:BD": "Microsoft",
|
||||||
|
"7C:1E:52": "Microsoft",
|
||||||
|
"98:5F:D3": "Microsoft",
|
||||||
|
"B4:0E:DE": "Microsoft",
|
||||||
# Intel
|
# Intel
|
||||||
'00:1B:21': 'Intel', '00:1C:C0': 'Intel', '00:1E:64': 'Intel', '00:21:5C': 'Intel',
|
"00:1B:21": "Intel",
|
||||||
'08:D4:0C': 'Intel', '18:1D:EA': 'Intel', '34:02:86': 'Intel', '40:74:E0': 'Intel',
|
"00:1C:C0": "Intel",
|
||||||
'48:51:B7': 'Intel', '58:A0:23': 'Intel', '64:D4:DA': 'Intel', '80:19:34': 'Intel',
|
"00:1E:64": "Intel",
|
||||||
'8C:8D:28': 'Intel', 'A4:4E:31': 'Intel', 'B4:6B:FC': 'Intel', 'C8:D0:83': 'Intel',
|
"00:21:5C": "Intel",
|
||||||
|
"08:D4:0C": "Intel",
|
||||||
|
"18:1D:EA": "Intel",
|
||||||
|
"34:02:86": "Intel",
|
||||||
|
"40:74:E0": "Intel",
|
||||||
|
"48:51:B7": "Intel",
|
||||||
|
"58:A0:23": "Intel",
|
||||||
|
"64:D4:DA": "Intel",
|
||||||
|
"80:19:34": "Intel",
|
||||||
|
"8C:8D:28": "Intel",
|
||||||
|
"A4:4E:31": "Intel",
|
||||||
|
"B4:6B:FC": "Intel",
|
||||||
|
"C8:D0:83": "Intel",
|
||||||
# Qualcomm/Atheros
|
# Qualcomm/Atheros
|
||||||
'00:03:7F': 'Qualcomm', '00:24:E4': 'Qualcomm', '04:F0:21': 'Qualcomm',
|
"00:03:7F": "Qualcomm",
|
||||||
'1C:4B:D6': 'Qualcomm', '88:71:B1': 'Qualcomm', 'A0:65:18': 'Qualcomm',
|
"00:24:E4": "Qualcomm",
|
||||||
|
"04:F0:21": "Qualcomm",
|
||||||
|
"1C:4B:D6": "Qualcomm",
|
||||||
|
"88:71:B1": "Qualcomm",
|
||||||
|
"A0:65:18": "Qualcomm",
|
||||||
# Broadcom
|
# Broadcom
|
||||||
'00:10:18': 'Broadcom', '00:1A:2B': 'Broadcom', '20:10:7A': 'Broadcom',
|
"00:10:18": "Broadcom",
|
||||||
|
"00:1A:2B": "Broadcom",
|
||||||
|
"20:10:7A": "Broadcom",
|
||||||
# Realtek
|
# Realtek
|
||||||
'00:0A:EB': 'Realtek', '00:E0:4C': 'Realtek', '48:02:2A': 'Realtek',
|
"00:0A:EB": "Realtek",
|
||||||
'52:54:00': 'Realtek', '80:EA:96': 'Realtek',
|
"00:E0:4C": "Realtek",
|
||||||
|
"48:02:2A": "Realtek",
|
||||||
|
"52:54:00": "Realtek",
|
||||||
|
"80:EA:96": "Realtek",
|
||||||
# Logitech
|
# Logitech
|
||||||
'00:1F:20': 'Logitech', '34:88:5D': 'Logitech', '6C:B7:49': 'Logitech',
|
"00:1F:20": "Logitech",
|
||||||
|
"34:88:5D": "Logitech",
|
||||||
|
"6C:B7:49": "Logitech",
|
||||||
# Lenovo
|
# Lenovo
|
||||||
'00:09:2D': 'Lenovo', '28:D2:44': 'Lenovo', '54:EE:75': 'Lenovo', '98:FA:9B': 'Lenovo',
|
"00:09:2D": "Lenovo",
|
||||||
|
"28:D2:44": "Lenovo",
|
||||||
|
"54:EE:75": "Lenovo",
|
||||||
|
"98:FA:9B": "Lenovo",
|
||||||
# Dell
|
# Dell
|
||||||
'00:14:22': 'Dell', '00:1A:A0': 'Dell', '18:DB:F2': 'Dell', '34:17:EB': 'Dell',
|
"00:14:22": "Dell",
|
||||||
'78:2B:CB': 'Dell', 'A4:BA:DB': 'Dell', 'E4:B9:7A': 'Dell',
|
"00:1A:A0": "Dell",
|
||||||
|
"18:DB:F2": "Dell",
|
||||||
|
"34:17:EB": "Dell",
|
||||||
|
"78:2B:CB": "Dell",
|
||||||
|
"A4:BA:DB": "Dell",
|
||||||
|
"E4:B9:7A": "Dell",
|
||||||
# HP
|
# HP
|
||||||
'00:0F:61': 'HP', '00:14:C2': 'HP', '10:1F:74': 'HP', '28:80:23': 'HP',
|
"00:0F:61": "HP",
|
||||||
'38:63:BB': 'HP', '5C:B9:01': 'HP', '80:CE:62': 'HP', 'A0:D3:C1': 'HP',
|
"00:14:C2": "HP",
|
||||||
|
"10:1F:74": "HP",
|
||||||
|
"28:80:23": "HP",
|
||||||
|
"38:63:BB": "HP",
|
||||||
|
"5C:B9:01": "HP",
|
||||||
|
"80:CE:62": "HP",
|
||||||
|
"A0:D3:C1": "HP",
|
||||||
# Tile
|
# Tile
|
||||||
'F8:E4:E3': 'Tile', 'C4:E7:BE': 'Tile', 'DC:54:D7': 'Tile', 'E4:B0:21': 'Tile',
|
"F8:E4:E3": "Tile",
|
||||||
|
"C4:E7:BE": "Tile",
|
||||||
|
"DC:54:D7": "Tile",
|
||||||
|
"E4:B0:21": "Tile",
|
||||||
# Raspberry Pi
|
# Raspberry Pi
|
||||||
'B8:27:EB': 'Raspberry Pi', 'DC:A6:32': 'Raspberry Pi', 'E4:5F:01': 'Raspberry Pi',
|
"B8:27:EB": "Raspberry Pi",
|
||||||
|
"DC:A6:32": "Raspberry Pi",
|
||||||
|
"E4:5F:01": "Raspberry Pi",
|
||||||
# Amazon
|
# Amazon
|
||||||
'00:FC:8B': 'Amazon', '10:CE:A9': 'Amazon', '34:D2:70': 'Amazon', '40:B4:CD': 'Amazon',
|
"00:FC:8B": "Amazon",
|
||||||
'44:65:0D': 'Amazon', '68:54:FD': 'Amazon', '74:C2:46': 'Amazon', '84:D6:D0': 'Amazon',
|
"10:CE:A9": "Amazon",
|
||||||
'A0:02:DC': 'Amazon', 'AC:63:BE': 'Amazon', 'B4:7C:9C': 'Amazon', 'FC:65:DE': 'Amazon',
|
"34:D2:70": "Amazon",
|
||||||
|
"40:B4:CD": "Amazon",
|
||||||
|
"44:65:0D": "Amazon",
|
||||||
|
"68:54:FD": "Amazon",
|
||||||
|
"74:C2:46": "Amazon",
|
||||||
|
"84:D6:D0": "Amazon",
|
||||||
|
"A0:02:DC": "Amazon",
|
||||||
|
"AC:63:BE": "Amazon",
|
||||||
|
"B4:7C:9C": "Amazon",
|
||||||
|
"FC:65:DE": "Amazon",
|
||||||
# Skullcandy
|
# Skullcandy
|
||||||
'00:01:00': 'Skullcandy', '88:E6:03': 'Skullcandy',
|
"00:01:00": "Skullcandy",
|
||||||
|
"88:E6:03": "Skullcandy",
|
||||||
# Bang & Olufsen
|
# Bang & Olufsen
|
||||||
'00:21:3E': 'Bang & Olufsen', '78:C5:E5': 'Bang & Olufsen',
|
"00:21:3E": "Bang & Olufsen",
|
||||||
|
"78:C5:E5": "Bang & Olufsen",
|
||||||
# Audio-Technica
|
# Audio-Technica
|
||||||
'A0:E9:DB': 'Audio-Technica', 'EC:81:93': 'Audio-Technica',
|
"A0:E9:DB": "Audio-Technica",
|
||||||
|
"EC:81:93": "Audio-Technica",
|
||||||
# Plantronics/Poly
|
# Plantronics/Poly
|
||||||
'00:1D:DF': 'Plantronics', 'B0:B4:48': 'Plantronics', 'E8:FC:AF': 'Plantronics',
|
"00:1D:DF": "Plantronics",
|
||||||
|
"B0:B4:48": "Plantronics",
|
||||||
|
"E8:FC:AF": "Plantronics",
|
||||||
# Anker
|
# Anker
|
||||||
'AC:89:95': 'Anker', 'E8:AB:FA': 'Anker',
|
"AC:89:95": "Anker",
|
||||||
|
"E8:AB:FA": "Anker",
|
||||||
# Misc/Generic
|
# Misc/Generic
|
||||||
'00:00:0A': 'Omron', '00:1A:7D': 'Cyber-Blue', '00:1E:3D': 'Alps Electric',
|
"00:00:0A": "Omron",
|
||||||
'00:0B:57': 'Silicon Wave', '00:02:72': 'CC&C',
|
"00:1A:7D": "Cyber-Blue",
|
||||||
|
"00:1E:3D": "Alps Electric",
|
||||||
|
"00:0B:57": "Silicon Wave",
|
||||||
|
"00:02:72": "CC&C",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Try to load from external file (easier to update)
|
# Try to load from external file (easier to update)
|
||||||
|
|||||||
@@ -1,18 +1,50 @@
|
|||||||
# TLE data for satellite tracking (updated periodically)
|
# TLE data for satellite tracking (updated periodically)
|
||||||
TLE_SATELLITES = {
|
# To update: click "Update TLE" in satellite dashboard or SSTV mode
|
||||||
'ISS': ('ISS (ZARYA)',
|
# Data source: CelesTrak (celestrak.org)
|
||||||
'1 25544U 98067A 24001.00000000 .00000000 00000-0 00000-0 0 0000',
|
TLE_SATELLITES = {
|
||||||
'2 25544 51.6400 0.0000 0000000 0.0000 0.0000 15.50000000000000'),
|
"ISS": (
|
||||||
'NOAA-20': ('NOAA 20 (JPSS-1)',
|
"ISS (ZARYA)",
|
||||||
'1 43013U 17073A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
"1 25544U 98067A 23321.52083333 .00016717 00000-0 30171-3 0 9992",
|
||||||
'2 43013 98.7400 0.0000 0001000 0.0000 0.0000 14.19000000000000'),
|
"2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123456",
|
||||||
'NOAA-21': ('NOAA 21 (JPSS-2)',
|
),
|
||||||
'1 54234U 22150A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
"NOAA-15": (
|
||||||
'2 54234 98.7100 0.0000 0001000 0.0000 0.0000 14.19000000000000'),
|
"NOAA 15",
|
||||||
'METEOR-M2': ('METEOR-M 2',
|
"1 25338U 98030A 25028.84157420 .00000535 00000+0 26168-3 0 9999",
|
||||||
'1 40069U 14037A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
"2 25338 98.5676 356.1853 0009968 282.2567 77.7505 14.26225252390049",
|
||||||
'2 40069 98.5400 0.0000 0005000 0.0000 0.0000 14.21000000000000'),
|
),
|
||||||
'METEOR-M2-3': ('METEOR-M2 3',
|
"NOAA-18": (
|
||||||
'1 57166U 23091A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
"NOAA 18",
|
||||||
'2 57166 98.7700 0.0000 0002000 0.0000 0.0000 14.23000000000000'),
|
"1 28654U 05018A 25028.87364583 .00000454 00000+0 25082-3 0 9996",
|
||||||
}
|
"2 28654 98.8801 59.1618 0013609 281.7181 78.2479 14.13003043 24668",
|
||||||
|
),
|
||||||
|
"NOAA-19": (
|
||||||
|
"NOAA 19",
|
||||||
|
"1 33591U 09005A 25028.82370718 .00000425 00000+0 24556-3 0 9998",
|
||||||
|
"2 33591 99.0905 25.2347 0013428 265.3457 94.6190 14.13019285827447",
|
||||||
|
),
|
||||||
|
"NOAA-20": (
|
||||||
|
"NOAA 20 (JPSS-1)",
|
||||||
|
"1 43013U 17073A 26141.21646093 .00000052 00000+0 45436-4 0 9996",
|
||||||
|
"2 43013 98.7764 80.9203 0001233 42.6389 317.4882 14.19506117440643",
|
||||||
|
),
|
||||||
|
"NOAA-21": (
|
||||||
|
"NOAA 21 (JPSS-2)",
|
||||||
|
"1 54234U 22150A 26141.25034758 .00000025 00000+0 32664-4 0 9997",
|
||||||
|
"2 54234 98.7052 80.4933 0000516 290.1874 69.9247 14.19559916182728",
|
||||||
|
),
|
||||||
|
"METEOR-M2": (
|
||||||
|
"METEOR-M 2",
|
||||||
|
"1 40069U 14037A 26141.25652306 .00000366 00000+0 18646-3 0 9999",
|
||||||
|
"2 40069 98.5106 117.9520 0006860 109.5984 250.5935 14.21454410615491",
|
||||||
|
),
|
||||||
|
"METEOR-M2-3": (
|
||||||
|
"METEOR-M2 3",
|
||||||
|
"1 57166U 23091A 26141.32851392 -.00000014 00000+0 12575-4 0 9996",
|
||||||
|
"2 57166 98.6097 196.8537 0002910 239.0757 121.0137 14.24044204150691",
|
||||||
|
),
|
||||||
|
"METEOR-M2-4": (
|
||||||
|
"METEOR-M2 4",
|
||||||
|
"1 59051U 24039A 26141.24240655 .00000007 00000+0 22827-4 0 9991",
|
||||||
|
"2 59051 98.6997 100.8818 0005969 244.5272 115.5289 14.22426426115439",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
"""
|
||||||
|
TSCM (Technical Surveillance Countermeasures) Frequency Database
|
||||||
|
|
||||||
|
Known surveillance device frequencies, sweep presets, and threat signatures
|
||||||
|
for counter-surveillance operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Known Surveillance Frequencies (MHz)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
SURVEILLANCE_FREQUENCIES = {
|
||||||
|
'wireless_mics': [
|
||||||
|
{'start': 49.0, 'end': 50.0, 'name': '49 MHz Wireless Mics', 'risk': 'medium'},
|
||||||
|
{'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Mics', 'risk': 'medium'},
|
||||||
|
{'start': 170.0, 'end': 216.0, 'name': 'VHF High Band Wireless', 'risk': 'medium'},
|
||||||
|
{'start': 470.0, 'end': 698.0, 'name': 'UHF TV Band Wireless', 'risk': 'medium'},
|
||||||
|
{'start': 902.0, 'end': 928.0, 'name': '900 MHz ISM Wireless', 'risk': 'high'},
|
||||||
|
{'start': 1880.0, 'end': 1920.0, 'name': 'DECT Wireless', 'risk': 'high'},
|
||||||
|
],
|
||||||
|
|
||||||
|
'wireless_cameras': [
|
||||||
|
{'start': 900.0, 'end': 930.0, 'name': '900 MHz Video TX', 'risk': 'high'},
|
||||||
|
{'start': 1200.0, 'end': 1300.0, 'name': '1.2 GHz Video', 'risk': 'high'},
|
||||||
|
{'start': 2400.0, 'end': 2483.5, 'name': '2.4 GHz WiFi Cameras', 'risk': 'high'},
|
||||||
|
{'start': 5150.0, 'end': 5850.0, 'name': '5.8 GHz Video', 'risk': 'high'},
|
||||||
|
],
|
||||||
|
|
||||||
|
'gps_trackers': [
|
||||||
|
{'start': 824.0, 'end': 849.0, 'name': 'Cellular 850 Uplink', 'risk': 'high'},
|
||||||
|
{'start': 869.0, 'end': 894.0, 'name': 'Cellular 850 Downlink', 'risk': 'high'},
|
||||||
|
{'start': 1710.0, 'end': 1755.0, 'name': 'AWS Uplink', 'risk': 'high'},
|
||||||
|
{'start': 1850.0, 'end': 1910.0, 'name': 'PCS Uplink', 'risk': 'high'},
|
||||||
|
{'start': 1930.0, 'end': 1990.0, 'name': 'PCS Downlink', 'risk': 'high'},
|
||||||
|
],
|
||||||
|
|
||||||
|
'body_worn': [
|
||||||
|
{'start': 49.0, 'end': 50.0, 'name': '49 MHz Body Wires', 'risk': 'critical'},
|
||||||
|
{'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Wires', 'risk': 'critical'},
|
||||||
|
{'start': 150.0, 'end': 174.0, 'name': 'VHF High Band', 'risk': 'critical'},
|
||||||
|
{'start': 380.0, 'end': 400.0, 'name': 'TETRA Band', 'risk': 'high'},
|
||||||
|
{'start': 406.0, 'end': 420.0, 'name': 'Federal/Government', 'risk': 'critical'},
|
||||||
|
{'start': 450.0, 'end': 470.0, 'name': 'UHF Business Band', 'risk': 'high'},
|
||||||
|
],
|
||||||
|
|
||||||
|
'common_bugs': [
|
||||||
|
{'start': 88.0, 'end': 108.0, 'name': 'FM Broadcast Band Bugs', 'risk': 'low'},
|
||||||
|
{'start': 140.0, 'end': 150.0, 'name': 'Low VHF Bugs', 'risk': 'high'},
|
||||||
|
{'start': 418.0, 'end': 419.0, 'name': '418 MHz ISM', 'risk': 'medium'},
|
||||||
|
{'start': 433.0, 'end': 434.8, 'name': '433 MHz ISM Band', 'risk': 'medium'},
|
||||||
|
{'start': 868.0, 'end': 870.0, 'name': '868 MHz ISM (Europe)', 'risk': 'medium'},
|
||||||
|
{'start': 315.0, 'end': 316.0, 'name': '315 MHz ISM (US)', 'risk': 'medium'},
|
||||||
|
],
|
||||||
|
|
||||||
|
'ism_bands': [
|
||||||
|
{'start': 26.96, 'end': 27.41, 'name': 'CB Radio / ISM 27 MHz', 'risk': 'low'},
|
||||||
|
{'start': 40.66, 'end': 40.70, 'name': 'ISM 40 MHz', 'risk': 'low'},
|
||||||
|
{'start': 315.0, 'end': 316.0, 'name': 'ISM 315 MHz (US)', 'risk': 'medium'},
|
||||||
|
{'start': 433.05, 'end': 434.79, 'name': 'ISM 433 MHz (EU)', 'risk': 'medium'},
|
||||||
|
{'start': 868.0, 'end': 868.6, 'name': 'ISM 868 MHz (EU)', 'risk': 'medium'},
|
||||||
|
{'start': 902.0, 'end': 928.0, 'name': 'ISM 915 MHz (US)', 'risk': 'medium'},
|
||||||
|
{'start': 2400.0, 'end': 2483.5, 'name': 'ISM 2.4 GHz', 'risk': 'medium'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Sweep Presets
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
SWEEP_PRESETS = {
|
||||||
|
'quick': {
|
||||||
|
'name': 'Quick Scan',
|
||||||
|
'description': 'Fast 2-minute check of most common bug frequencies',
|
||||||
|
'duration_seconds': 120,
|
||||||
|
'ranges': [
|
||||||
|
{'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'},
|
||||||
|
{'start': 433.0, 'end': 435.0, 'step': 0.025, 'name': '433 MHz ISM'},
|
||||||
|
{'start': 868.0, 'end': 870.0, 'step': 0.025, 'name': '868 MHz ISM'},
|
||||||
|
],
|
||||||
|
'wifi': True,
|
||||||
|
'bluetooth': True,
|
||||||
|
'rf': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'standard': {
|
||||||
|
'name': 'Standard Sweep',
|
||||||
|
'description': 'Comprehensive 5-minute sweep of common surveillance bands',
|
||||||
|
'duration_seconds': 300,
|
||||||
|
'ranges': [
|
||||||
|
{'start': 25.0, 'end': 50.0, 'step': 0.1, 'name': 'HF/Low VHF'},
|
||||||
|
{'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'},
|
||||||
|
{'start': 140.0, 'end': 175.0, 'step': 0.025, 'name': 'VHF'},
|
||||||
|
{'start': 380.0, 'end': 450.0, 'step': 0.025, 'name': 'UHF Low'},
|
||||||
|
{'start': 868.0, 'end': 930.0, 'step': 0.05, 'name': 'ISM 868/915'},
|
||||||
|
],
|
||||||
|
'wifi': True,
|
||||||
|
'bluetooth': True,
|
||||||
|
'rf': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'full': {
|
||||||
|
'name': 'Full Spectrum',
|
||||||
|
'description': 'Complete 15-minute spectrum sweep (24 MHz - 1.7 GHz)',
|
||||||
|
'duration_seconds': 900,
|
||||||
|
'ranges': [
|
||||||
|
{'start': 24.0, 'end': 1700.0, 'step': 0.1, 'name': 'Full Spectrum'},
|
||||||
|
],
|
||||||
|
'wifi': True,
|
||||||
|
'bluetooth': True,
|
||||||
|
'rf': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'wireless_cameras': {
|
||||||
|
'name': 'Wireless Cameras',
|
||||||
|
'description': 'Focus on video transmission frequencies',
|
||||||
|
'duration_seconds': 180,
|
||||||
|
'ranges': [
|
||||||
|
{'start': 900.0, 'end': 930.0, 'step': 0.1, 'name': '900 MHz Video'},
|
||||||
|
{'start': 1200.0, 'end': 1300.0, 'step': 0.5, 'name': '1.2 GHz Video'},
|
||||||
|
],
|
||||||
|
'wifi': True, # WiFi cameras
|
||||||
|
'bluetooth': False,
|
||||||
|
'rf': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'body_worn': {
|
||||||
|
'name': 'Body-Worn Devices',
|
||||||
|
'description': 'Detect body wires and covert transmitters',
|
||||||
|
'duration_seconds': 240,
|
||||||
|
'ranges': [
|
||||||
|
{'start': 49.0, 'end': 50.0, 'step': 0.01, 'name': '49 MHz'},
|
||||||
|
{'start': 72.0, 'end': 76.0, 'step': 0.01, 'name': 'VHF Low'},
|
||||||
|
{'start': 150.0, 'end': 174.0, 'step': 0.0125, 'name': 'VHF High'},
|
||||||
|
{'start': 406.0, 'end': 420.0, 'step': 0.0125, 'name': 'Federal'},
|
||||||
|
{'start': 450.0, 'end': 470.0, 'step': 0.0125, 'name': 'UHF'},
|
||||||
|
],
|
||||||
|
'wifi': False,
|
||||||
|
'bluetooth': True, # BLE bugs
|
||||||
|
'rf': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'gps_trackers': {
|
||||||
|
'name': 'GPS Trackers',
|
||||||
|
'description': 'Detect cellular-based GPS tracking devices',
|
||||||
|
'duration_seconds': 180,
|
||||||
|
'ranges': [
|
||||||
|
{'start': 824.0, 'end': 894.0, 'step': 0.1, 'name': 'Cellular 850'},
|
||||||
|
{'start': 1850.0, 'end': 1990.0, 'step': 0.1, 'name': 'PCS Band'},
|
||||||
|
],
|
||||||
|
'wifi': False,
|
||||||
|
'bluetooth': True, # BLE trackers
|
||||||
|
'rf': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'bluetooth_only': {
|
||||||
|
'name': 'Bluetooth/BLE Trackers',
|
||||||
|
'description': 'Focus on BLE tracking devices (AirTag, Tile, etc.)',
|
||||||
|
'duration_seconds': 60,
|
||||||
|
'ranges': [],
|
||||||
|
'wifi': False,
|
||||||
|
'bluetooth': True,
|
||||||
|
'rf': False,
|
||||||
|
},
|
||||||
|
|
||||||
|
'wifi_only': {
|
||||||
|
'name': 'WiFi Devices',
|
||||||
|
'description': 'Scan for hidden WiFi cameras and access points',
|
||||||
|
'duration_seconds': 60,
|
||||||
|
'ranges': [],
|
||||||
|
'wifi': True,
|
||||||
|
'bluetooth': False,
|
||||||
|
'rf': False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Known Tracker Signatures
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
BLE_TRACKER_SIGNATURES = {
|
||||||
|
'apple_airtag': {
|
||||||
|
'name': 'Apple AirTag',
|
||||||
|
'company_id': 0x004C,
|
||||||
|
'patterns': ['findmy', 'airtag'],
|
||||||
|
'risk': 'high',
|
||||||
|
'description': 'Apple Find My network tracker',
|
||||||
|
},
|
||||||
|
'tile': {
|
||||||
|
'name': 'Tile Tracker',
|
||||||
|
'company_id': 0x00ED,
|
||||||
|
'patterns': ['tile'],
|
||||||
|
'oui_prefixes': ['C4:E7', 'DC:54', 'E6:43'],
|
||||||
|
'risk': 'high',
|
||||||
|
'description': 'Tile Bluetooth tracker',
|
||||||
|
},
|
||||||
|
'samsung_smarttag': {
|
||||||
|
'name': 'Samsung SmartTag',
|
||||||
|
'company_id': 0x0075,
|
||||||
|
'patterns': ['smarttag', 'smartthings'],
|
||||||
|
'risk': 'high',
|
||||||
|
'description': 'Samsung SmartThings tracker',
|
||||||
|
},
|
||||||
|
'chipolo': {
|
||||||
|
'name': 'Chipolo',
|
||||||
|
'company_id': 0x0A09,
|
||||||
|
'patterns': ['chipolo'],
|
||||||
|
'risk': 'high',
|
||||||
|
'description': 'Chipolo Bluetooth tracker',
|
||||||
|
},
|
||||||
|
'generic_beacon': {
|
||||||
|
'name': 'Unknown BLE Beacon',
|
||||||
|
'company_id': None,
|
||||||
|
'patterns': [],
|
||||||
|
'risk': 'medium',
|
||||||
|
'description': 'Unidentified BLE beacon device',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Threat Classification
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
THREAT_TYPES = {
|
||||||
|
'new_device': {
|
||||||
|
'name': 'New Device',
|
||||||
|
'description': 'Device not present in baseline',
|
||||||
|
'default_severity': 'medium',
|
||||||
|
},
|
||||||
|
'tracker': {
|
||||||
|
'name': 'Tracking Device',
|
||||||
|
'description': 'Known BLE tracker detected',
|
||||||
|
'default_severity': 'high',
|
||||||
|
},
|
||||||
|
'unknown_signal': {
|
||||||
|
'name': 'Unknown Signal',
|
||||||
|
'description': 'Unidentified RF transmission',
|
||||||
|
'default_severity': 'medium',
|
||||||
|
},
|
||||||
|
'burst_transmission': {
|
||||||
|
'name': 'Burst Transmission',
|
||||||
|
'description': 'Intermittent/store-and-forward signal detected',
|
||||||
|
'default_severity': 'high',
|
||||||
|
},
|
||||||
|
'hidden_camera': {
|
||||||
|
'name': 'Potential Hidden Camera',
|
||||||
|
'description': 'WiFi camera or video transmitter detected',
|
||||||
|
'default_severity': 'critical',
|
||||||
|
},
|
||||||
|
'gsm_bug': {
|
||||||
|
'name': 'GSM/Cellular Bug',
|
||||||
|
'description': 'Cellular transmission in non-phone device context',
|
||||||
|
'default_severity': 'critical',
|
||||||
|
},
|
||||||
|
'rogue_ap': {
|
||||||
|
'name': 'Rogue Access Point',
|
||||||
|
'description': 'Unauthorized WiFi access point',
|
||||||
|
'default_severity': 'high',
|
||||||
|
},
|
||||||
|
'anomaly': {
|
||||||
|
'name': 'Signal Anomaly',
|
||||||
|
'description': 'Unusual signal pattern or behavior',
|
||||||
|
'default_severity': 'low',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
SEVERITY_LEVELS = {
|
||||||
|
'critical': {
|
||||||
|
'level': 4,
|
||||||
|
'color': '#ff0000',
|
||||||
|
'description': 'Immediate action required - active surveillance likely',
|
||||||
|
},
|
||||||
|
'high': {
|
||||||
|
'level': 3,
|
||||||
|
'color': '#ff6600',
|
||||||
|
'description': 'Strong indicator of surveillance device',
|
||||||
|
},
|
||||||
|
'medium': {
|
||||||
|
'level': 2,
|
||||||
|
'color': '#ffcc00',
|
||||||
|
'description': 'Potential threat - requires investigation',
|
||||||
|
},
|
||||||
|
'low': {
|
||||||
|
'level': 1,
|
||||||
|
'color': '#00cc00',
|
||||||
|
'description': 'Minor anomaly - low probability of threat',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WiFi Camera Detection Patterns
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
WIFI_CAMERA_PATTERNS = {
|
||||||
|
'ssid_patterns': [
|
||||||
|
'cam', 'camera', 'ipcam', 'webcam', 'dvr', 'nvr',
|
||||||
|
'hikvision', 'dahua', 'reolink', 'wyze', 'ring',
|
||||||
|
'arlo', 'nest', 'blink', 'eufy', 'yi',
|
||||||
|
],
|
||||||
|
'oui_manufacturers': [
|
||||||
|
'Hikvision',
|
||||||
|
'Dahua',
|
||||||
|
'Axis Communications',
|
||||||
|
'Hanwha Techwin',
|
||||||
|
'Vivotek',
|
||||||
|
'Ubiquiti',
|
||||||
|
'Wyze Labs',
|
||||||
|
'Amazon Technologies', # Ring
|
||||||
|
'Google', # Nest
|
||||||
|
],
|
||||||
|
'mac_prefixes': {
|
||||||
|
'C0:25:E9': 'TP-Link Camera',
|
||||||
|
'A4:DA:22': 'TP-Link Camera',
|
||||||
|
'78:8C:B5': 'TP-Link Camera',
|
||||||
|
'D4:6E:0E': 'TP-Link Camera',
|
||||||
|
'2C:AA:8E': 'Wyze Camera',
|
||||||
|
'AC:CF:85': 'Hikvision',
|
||||||
|
'54:C4:15': 'Hikvision',
|
||||||
|
'C0:56:E3': 'Hikvision',
|
||||||
|
'3C:EF:8C': 'Dahua',
|
||||||
|
'A0:BD:1D': 'Dahua',
|
||||||
|
'E4:24:6C': 'Dahua',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Utility Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_frequency_risk(frequency_mhz: float) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Determine the risk level for a given frequency.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (risk_level, category_name)
|
||||||
|
"""
|
||||||
|
for _category, ranges in SURVEILLANCE_FREQUENCIES.items():
|
||||||
|
for freq_range in ranges:
|
||||||
|
if freq_range['start'] <= frequency_mhz <= freq_range['end']:
|
||||||
|
return freq_range['risk'], freq_range['name']
|
||||||
|
|
||||||
|
return 'low', 'Unknown Band'
|
||||||
|
|
||||||
|
|
||||||
|
def get_sweep_preset(preset_name: str) -> dict | None:
|
||||||
|
"""Get a sweep preset by name."""
|
||||||
|
return SWEEP_PRESETS.get(preset_name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_sweep_presets() -> dict:
|
||||||
|
"""Get all available sweep presets."""
|
||||||
|
return {
|
||||||
|
name: {
|
||||||
|
'name': preset['name'],
|
||||||
|
'description': preset['description'],
|
||||||
|
'duration_seconds': preset['duration_seconds'],
|
||||||
|
}
|
||||||
|
for name, preset in SWEEP_PRESETS.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_known_tracker(device_name: str | None, manufacturer_data: bytes | str | None = None) -> dict | None:
|
||||||
|
"""
|
||||||
|
Check if a BLE device matches known tracker signatures.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_name: Device name to check against patterns
|
||||||
|
manufacturer_data: Manufacturer data as bytes or hex string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tracker info dict if match found, None otherwise
|
||||||
|
"""
|
||||||
|
if device_name:
|
||||||
|
name_lower = device_name.lower()
|
||||||
|
for _tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||||
|
for pattern in tracker_info.get('patterns', []):
|
||||||
|
if pattern in name_lower:
|
||||||
|
return tracker_info
|
||||||
|
|
||||||
|
if manufacturer_data:
|
||||||
|
# Convert hex string to bytes if needed
|
||||||
|
mfr_bytes = manufacturer_data
|
||||||
|
if isinstance(manufacturer_data, str):
|
||||||
|
try:
|
||||||
|
mfr_bytes = bytes.fromhex(manufacturer_data)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(mfr_bytes) >= 2:
|
||||||
|
company_id = int.from_bytes(mfr_bytes[:2], 'little')
|
||||||
|
for _tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||||
|
if tracker_info.get('company_id') == company_id:
|
||||||
|
return tracker_info
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_potential_camera(ssid: str | None = None, mac: str | None = None, vendor: str | None = None) -> bool:
|
||||||
|
"""Check if a WiFi device might be a hidden camera."""
|
||||||
|
if ssid:
|
||||||
|
ssid_lower = ssid.lower()
|
||||||
|
for pattern in WIFI_CAMERA_PATTERNS['ssid_patterns']:
|
||||||
|
if pattern in ssid_lower:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if mac:
|
||||||
|
mac_prefix = mac[:8].upper()
|
||||||
|
if mac_prefix in WIFI_CAMERA_PATTERNS['mac_prefixes']:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if vendor:
|
||||||
|
vendor_lower = vendor.lower()
|
||||||
|
for manufacturer in WIFI_CAMERA_PATTERNS['oui_manufacturers']:
|
||||||
|
if manufacturer.lower() in vendor_lower:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_threat_severity(threat_type: str, context: dict | None = None) -> str:
|
||||||
|
"""
|
||||||
|
Determine threat severity based on type and context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
threat_type: Type of threat from THREAT_TYPES
|
||||||
|
context: Optional context dict with signal_strength, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Severity level string
|
||||||
|
"""
|
||||||
|
threat_info = THREAT_TYPES.get(threat_type, {})
|
||||||
|
base_severity = threat_info.get('default_severity', 'medium')
|
||||||
|
|
||||||
|
if context:
|
||||||
|
# Upgrade severity based on signal strength (closer = more concerning)
|
||||||
|
signal = context.get('signal_strength')
|
||||||
|
if signal and signal > -50: # Very strong signal
|
||||||
|
if base_severity == 'medium':
|
||||||
|
return 'high'
|
||||||
|
elif base_severity == 'high':
|
||||||
|
return 'critical'
|
||||||
|
|
||||||
|
return base_severity
|
||||||
@@ -0,0 +1,733 @@
|
|||||||
|
{
|
||||||
|
"stations": [
|
||||||
|
{
|
||||||
|
"name": "USCG Kodiak",
|
||||||
|
"callsign": "NOJ",
|
||||||
|
"country": "US",
|
||||||
|
"city": "Kodiak, AK",
|
||||||
|
"coordinates": [57.78, -152.50],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 2054, "description": "Night"},
|
||||||
|
{"khz": 4298, "description": "Primary"},
|
||||||
|
{"khz": 8459, "description": "Day"},
|
||||||
|
{"khz": 12412.5, "description": "Extended"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "03:40", "duration_min": 148, "content": "Chart Series 1"},
|
||||||
|
{"utc": "09:50", "duration_min": 138, "content": "Chart Series 2"},
|
||||||
|
{"utc": "15:40", "duration_min": 148, "content": "Chart Series 3"},
|
||||||
|
{"utc": "21:50", "duration_min": 98, "content": "Chart Series 4"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "USCG Boston",
|
||||||
|
"callsign": "NMF",
|
||||||
|
"country": "US",
|
||||||
|
"city": "Boston, MA",
|
||||||
|
"coordinates": [42.36, -71.04],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 4235, "description": "Night"},
|
||||||
|
{"khz": 6340.5, "description": "Primary"},
|
||||||
|
{"khz": 9110, "description": "Day"},
|
||||||
|
{"khz": 12750, "description": "Extended"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "02:30", "duration_min": 20, "content": "Wind/Wave Analysis"},
|
||||||
|
{"utc": "04:38", "duration_min": 20, "content": "Sea State Analysis"},
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "09:30", "duration_min": 20, "content": "48-Hour Surface Prog"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "14:00", "duration_min": 20, "content": "24-Hour Surface Prog"},
|
||||||
|
{"utc": "16:00", "duration_min": 20, "content": "Sea State Analysis"},
|
||||||
|
{"utc": "18:10", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "22:00", "duration_min": 20, "content": "Satellite Image"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "USCG New Orleans",
|
||||||
|
"callsign": "NMG",
|
||||||
|
"country": "US",
|
||||||
|
"city": "New Orleans, LA",
|
||||||
|
"coordinates": [29.95, -90.07],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 4317.9, "description": "Night"},
|
||||||
|
{"khz": 8503.9, "description": "Primary"},
|
||||||
|
{"khz": 12789.9, "description": "Day"},
|
||||||
|
{"khz": 17146.4, "description": "Extended"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "03:00", "duration_min": 20, "content": "24-Hour Surface Prog"},
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "09:00", "duration_min": 20, "content": "Sea State Analysis"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "15:00", "duration_min": 20, "content": "48-Hour Surface Prog"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "21:00", "duration_min": 20, "content": "Tropical Analysis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "USCG Pt. Reyes",
|
||||||
|
"callsign": "NMC",
|
||||||
|
"country": "US",
|
||||||
|
"city": "Pt. Reyes, CA",
|
||||||
|
"coordinates": [38.07, -122.97],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 4346, "description": "Night"},
|
||||||
|
{"khz": 8682, "description": "Primary"},
|
||||||
|
{"khz": 12786, "description": "Day"},
|
||||||
|
{"khz": 17151.2, "description": "Extended"},
|
||||||
|
{"khz": 22527, "description": "DX"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "01:40", "duration_min": 20, "content": "Wind/Wave Analysis"},
|
||||||
|
{"utc": "06:55", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "11:20", "duration_min": 20, "content": "48-Hour Surface Prog"},
|
||||||
|
{"utc": "14:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "18:40", "duration_min": 20, "content": "Sea State Analysis"},
|
||||||
|
{"utc": "23:20", "duration_min": 20, "content": "Satellite Image"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "USCG Honolulu",
|
||||||
|
"callsign": "KVM70",
|
||||||
|
"country": "US",
|
||||||
|
"city": "Honolulu, HI",
|
||||||
|
"coordinates": [21.31, -157.86],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 9982.5, "description": "Primary"},
|
||||||
|
{"khz": 11090, "description": "Day"},
|
||||||
|
{"khz": 16135, "description": "Extended"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "05:19", "duration_min": 20, "content": "Surface Prog"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "17:19", "duration_min": 20, "content": "Sea State Analysis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RN Northwood",
|
||||||
|
"callsign": "GYA",
|
||||||
|
"country": "GB",
|
||||||
|
"city": "Northwood, London",
|
||||||
|
"coordinates": [51.63, -0.42],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 2618.5, "description": "Night"},
|
||||||
|
{"khz": 3280.5, "description": "Night Alt"},
|
||||||
|
{"khz": 4610, "description": "Primary"},
|
||||||
|
{"khz": 6834, "description": "Day Alt"},
|
||||||
|
{"khz": 8040, "description": "Day"},
|
||||||
|
{"khz": 11086.5, "description": "Extended"},
|
||||||
|
{"khz": 12390, "description": "Persian Gulf"},
|
||||||
|
{"khz": 18261, "description": "DX"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "03:30", "duration_min": 20, "content": "24-Hour Surface Prog"},
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "08:00", "duration_min": 20, "content": "Sea State Forecast"},
|
||||||
|
{"utc": "09:30", "duration_min": 20, "content": "Extended Forecast"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "15:30", "duration_min": 20, "content": "48-Hour Surface Prog"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "19:00", "duration_min": 20, "content": "Wave Period Forecast"},
|
||||||
|
{"utc": "21:30", "duration_min": 20, "content": "Extended Forecast"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DWD Hamburg/Pinneberg",
|
||||||
|
"callsign": "DDH",
|
||||||
|
"country": "DE",
|
||||||
|
"city": "Pinneberg",
|
||||||
|
"coordinates": [53.66, 9.80],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 3855, "description": "Night (DDH3, 10kW)"},
|
||||||
|
{"khz": 7880, "description": "Primary (DDK3, 20kW)"},
|
||||||
|
{"khz": 13882.5, "description": "Day (DDK6, 20kW)"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "04:30", "duration_min": 20, "content": "Surface Analysis N. Atlantic"},
|
||||||
|
{"utc": "07:15", "duration_min": 20, "content": "Surface Prog"},
|
||||||
|
{"utc": "09:30", "duration_min": 20, "content": "Surface Analysis Europe"},
|
||||||
|
{"utc": "10:07", "duration_min": 20, "content": "Sea State North Sea"},
|
||||||
|
{"utc": "13:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "15:20", "duration_min": 20, "content": "Extended Prog"},
|
||||||
|
{"utc": "15:40", "duration_min": 20, "content": "Sea Ice Chart"},
|
||||||
|
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "21:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "21:15", "duration_min": 20, "content": "Surface Prog"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "JMA Tokyo",
|
||||||
|
"callsign": "JMH",
|
||||||
|
"country": "JP",
|
||||||
|
"city": "Tokyo",
|
||||||
|
"coordinates": [35.69, 139.69],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 3622.5, "description": "Night"},
|
||||||
|
{"khz": 7795, "description": "Primary"},
|
||||||
|
{"khz": 13988.5, "description": "Day"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "01:30", "duration_min": 20, "content": "24-Hour Prog"},
|
||||||
|
{"utc": "03:00", "duration_min": 20, "content": "Satellite Image"},
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "07:30", "duration_min": 20, "content": "Wave Analysis"},
|
||||||
|
{"utc": "09:00", "duration_min": 20, "content": "Satellite Image"},
|
||||||
|
{"utc": "10:19", "duration_min": 20, "content": "Tropical Cyclone Info"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "15:00", "duration_min": 20, "content": "Satellite Image"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "21:00", "duration_min": 20, "content": "48-Hour Prog"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kyodo News Tokyo",
|
||||||
|
"callsign": "JJC",
|
||||||
|
"country": "JP",
|
||||||
|
"city": "Tokyo",
|
||||||
|
"coordinates": [35.69, 139.69],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 4316, "description": "Night"},
|
||||||
|
{"khz": 8467.5, "description": "Primary"},
|
||||||
|
{"khz": 12745.5, "description": "Day"},
|
||||||
|
{"khz": 16971, "description": "Extended"},
|
||||||
|
{"khz": 17069.6, "description": "DX"},
|
||||||
|
{"khz": 22542, "description": "DX 2"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
||||||
|
{"utc": "04:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
||||||
|
{"utc": "08:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
||||||
|
{"utc": "16:00", "duration_min": 20, "content": "Press Photo/News Fax"},
|
||||||
|
{"utc": "20:00", "duration_min": 20, "content": "Press Photo/News Fax"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kagoshima Fisheries",
|
||||||
|
"callsign": "JFX",
|
||||||
|
"country": "JP",
|
||||||
|
"city": "Kagoshima",
|
||||||
|
"coordinates": [31.60, 130.56],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 4274, "description": "Night"},
|
||||||
|
{"khz": 8658, "description": "Primary"},
|
||||||
|
{"khz": 13074, "description": "Day"},
|
||||||
|
{"khz": 16907.5, "description": "Extended"},
|
||||||
|
{"khz": 22559.6, "description": "DX"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Sea Surface Temp"},
|
||||||
|
{"utc": "04:00", "duration_min": 20, "content": "Fishing Forecast"},
|
||||||
|
{"utc": "08:00", "duration_min": 20, "content": "Sea Surface Temp"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Current Chart"},
|
||||||
|
{"utc": "16:00", "duration_min": 20, "content": "Fishing Forecast"},
|
||||||
|
{"utc": "20:00", "duration_min": 20, "content": "Sea Surface Temp"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KMA Seoul",
|
||||||
|
"callsign": "HLL2",
|
||||||
|
"country": "KR",
|
||||||
|
"city": "Seoul",
|
||||||
|
"coordinates": [37.57, 126.98],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 3585, "description": "Night"},
|
||||||
|
{"khz": 5857.5, "description": "Primary"},
|
||||||
|
{"khz": 7433.5, "description": "Day"},
|
||||||
|
{"khz": 9165, "description": "Extended"},
|
||||||
|
{"khz": 13570, "description": "DX"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "03:00", "duration_min": 20, "content": "24-Hour Prog"},
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "09:00", "duration_min": 20, "content": "Satellite Image"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "15:00", "duration_min": 20, "content": "Sea State Analysis"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "21:00", "duration_min": 20, "content": "48-Hour Prog"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Taipei Met",
|
||||||
|
"callsign": "BMF",
|
||||||
|
"country": "TW",
|
||||||
|
"city": "Taipei",
|
||||||
|
"coordinates": [25.03, 121.57],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 4616, "description": "Primary"},
|
||||||
|
{"khz": 8140, "description": "Day"},
|
||||||
|
{"khz": 13900, "description": "Extended"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bangkok Met",
|
||||||
|
"callsign": "HSW64",
|
||||||
|
"country": "TH",
|
||||||
|
"city": "Bangkok",
|
||||||
|
"coordinates": [13.76, 100.50],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 7396.8, "description": "Primary"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shanghai Met",
|
||||||
|
"callsign": "XSG",
|
||||||
|
"country": "CN",
|
||||||
|
"city": "Shanghai",
|
||||||
|
"coordinates": [31.23, 121.47],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 4170, "description": "Night"},
|
||||||
|
{"khz": 8302, "description": "Primary"},
|
||||||
|
{"khz": 12382, "description": "Day"},
|
||||||
|
{"khz": 16559, "description": "Extended"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Guangzhou Radio",
|
||||||
|
"callsign": "XSQ",
|
||||||
|
"country": "CN",
|
||||||
|
"city": "Guangzhou",
|
||||||
|
"coordinates": [23.13, 113.26],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 4199.8, "description": "Night"},
|
||||||
|
{"khz": 8412.5, "description": "Primary"},
|
||||||
|
{"khz": 12629.3, "description": "Day"},
|
||||||
|
{"khz": 16826.3, "description": "Extended"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Singapore Met",
|
||||||
|
"callsign": "9VF",
|
||||||
|
"country": "SG",
|
||||||
|
"city": "Singapore",
|
||||||
|
"coordinates": [1.35, 103.82],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 16035, "description": "Primary"},
|
||||||
|
{"khz": 17430, "description": "Alternate"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "New Delhi Met",
|
||||||
|
"callsign": "ATP",
|
||||||
|
"country": "IN",
|
||||||
|
"city": "New Delhi",
|
||||||
|
"coordinates": [28.61, 77.21],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 7405, "description": "Night"},
|
||||||
|
{"khz": 14842, "description": "Day"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Murmansk Met",
|
||||||
|
"callsign": "RBW",
|
||||||
|
"country": "RU",
|
||||||
|
"city": "Murmansk",
|
||||||
|
"coordinates": [68.97, 33.09],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 6445.5, "description": "Night"},
|
||||||
|
{"khz": 7907, "description": "Primary"},
|
||||||
|
{"khz": 8444, "description": "Day"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "07:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "08:00", "duration_min": 20, "content": "Ice Chart"},
|
||||||
|
{"utc": "14:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "14:30", "duration_min": 20, "content": "Surface Prog"},
|
||||||
|
{"utc": "20:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "St. Petersburg Met",
|
||||||
|
"callsign": "RDD78",
|
||||||
|
"country": "RU",
|
||||||
|
"city": "St. Petersburg",
|
||||||
|
"coordinates": [59.93, 30.32],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 2640, "description": "Night"},
|
||||||
|
{"khz": 4212, "description": "Primary"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Athens Met",
|
||||||
|
"callsign": "SVJ4",
|
||||||
|
"country": "GR",
|
||||||
|
"city": "Athens",
|
||||||
|
"coordinates": [37.97, 23.73],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 4482.9, "description": "Night"},
|
||||||
|
{"khz": 8106.9, "description": "Primary"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis Med"},
|
||||||
|
{"utc": "09:00", "duration_min": 20, "content": "Surface Prog"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis Med"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis Med"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Charleville Met",
|
||||||
|
"callsign": "VMC",
|
||||||
|
"country": "AU",
|
||||||
|
"city": "Charleville, QLD",
|
||||||
|
"coordinates": [-26.41, 146.24],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 2628, "description": "Night"},
|
||||||
|
{"khz": 5100, "description": "Primary"},
|
||||||
|
{"khz": 11030, "description": "Day"},
|
||||||
|
{"khz": 13920, "description": "Extended"},
|
||||||
|
{"khz": 20469, "description": "DX"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||||
|
{"utc": "03:00", "duration_min": 20, "content": "Prognosis"},
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||||
|
{"utc": "09:00", "duration_min": 20, "content": "Sea/Swell Chart"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||||
|
{"utc": "19:00", "duration_min": 20, "content": "Prognosis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wiluna Met",
|
||||||
|
"callsign": "VMW",
|
||||||
|
"country": "AU",
|
||||||
|
"city": "Wiluna, WA",
|
||||||
|
"coordinates": [-26.59, 120.23],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 5755, "description": "Night"},
|
||||||
|
{"khz": 7535, "description": "Primary"},
|
||||||
|
{"khz": 10555, "description": "Day"},
|
||||||
|
{"khz": 15615, "description": "Extended"},
|
||||||
|
{"khz": 18060, "description": "DX"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||||
|
{"utc": "11:00", "duration_min": 20, "content": "Prognosis"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "MSLP Analysis"},
|
||||||
|
{"utc": "21:00", "duration_min": 20, "content": "Sea/Swell Chart"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NZ MetService",
|
||||||
|
"callsign": "ZKLF",
|
||||||
|
"country": "NZ",
|
||||||
|
"city": "Auckland",
|
||||||
|
"coordinates": [-36.85, 174.76],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 3247.4, "description": "Night"},
|
||||||
|
{"khz": 5807, "description": "Primary"},
|
||||||
|
{"khz": 9459, "description": "Day"},
|
||||||
|
{"khz": 13550.5, "description": "Extended"},
|
||||||
|
{"khz": 16340.1, "description": "DX"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CFH Halifax",
|
||||||
|
"callsign": "CFH",
|
||||||
|
"country": "CA",
|
||||||
|
"city": "Halifax, NS",
|
||||||
|
"coordinates": [44.65, -63.57],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 4271, "description": "Night"},
|
||||||
|
{"khz": 6496.4, "description": "Primary"},
|
||||||
|
{"khz": 10536, "description": "Day"},
|
||||||
|
{"khz": 13510, "description": "Extended"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "03:00", "duration_min": 20, "content": "Surface Prog"},
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "22:22", "duration_min": 20, "content": "Ice Chart"},
|
||||||
|
{"utc": "23:01", "duration_min": 20, "content": "Surface Analysis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CCG Iqaluit",
|
||||||
|
"callsign": "VFF",
|
||||||
|
"country": "CA",
|
||||||
|
"city": "Iqaluit, NU",
|
||||||
|
"coordinates": [63.75, -68.52],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 3253, "description": "Night"},
|
||||||
|
{"khz": 7710, "description": "Day"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:10", "duration_min": 20, "content": "Ice Chart"},
|
||||||
|
{"utc": "05:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "07:00", "duration_min": 20, "content": "Ice Chart"},
|
||||||
|
{"utc": "10:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "11:00", "duration_min": 20, "content": "Ice Chart"},
|
||||||
|
{"utc": "21:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "23:30", "duration_min": 20, "content": "Ice Chart"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CCG Inuvik",
|
||||||
|
"callsign": "VFA",
|
||||||
|
"country": "CA",
|
||||||
|
"city": "Inuvik, NT",
|
||||||
|
"coordinates": [68.36, -133.72],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 4292, "description": "Night"},
|
||||||
|
{"khz": 8457.8, "description": "Primary"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "02:00", "duration_min": 20, "content": "Ice Chart"},
|
||||||
|
{"utc": "16:30", "duration_min": 20, "content": "Ice Chart"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CCG Sydney",
|
||||||
|
"callsign": "VCO",
|
||||||
|
"country": "CA",
|
||||||
|
"city": "Sydney, NS",
|
||||||
|
"coordinates": [46.14, -60.19],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 4416, "description": "Night"},
|
||||||
|
{"khz": 6915.1, "description": "Primary"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "11:21", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "11:42", "duration_min": 20, "content": "Surface Prog"},
|
||||||
|
{"utc": "17:41", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "22:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "23:31", "duration_min": 20, "content": "Surface Prog"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cape Naval",
|
||||||
|
"callsign": "ZSJ",
|
||||||
|
"country": "ZA",
|
||||||
|
"city": "Cape Town",
|
||||||
|
"coordinates": [-33.92, 18.42],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 4014, "description": "Night"},
|
||||||
|
{"khz": 7508, "description": "Primary"},
|
||||||
|
{"khz": 13538, "description": "Day"},
|
||||||
|
{"khz": 18238, "description": "Extended"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "04:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "05:00", "duration_min": 20, "content": "Sea State"},
|
||||||
|
{"utc": "06:30", "duration_min": 20, "content": "Surface Prog"},
|
||||||
|
{"utc": "07:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "08:00", "duration_min": 20, "content": "Satellite Image"},
|
||||||
|
{"utc": "10:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "11:00", "duration_min": 20, "content": "Sea State"},
|
||||||
|
{"utc": "15:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "15:40", "duration_min": 20, "content": "Surface Prog"},
|
||||||
|
{"utc": "22:30", "duration_min": 20, "content": "Surface Analysis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Valparaiso Naval",
|
||||||
|
"callsign": "CBV",
|
||||||
|
"country": "CL",
|
||||||
|
"city": "Valparaiso",
|
||||||
|
"coordinates": [-33.05, -71.62],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 4228, "description": "Night"},
|
||||||
|
{"khz": 8677, "description": "Primary"},
|
||||||
|
{"khz": 17146.4, "description": "Day"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "11:15", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "11:30", "duration_min": 20, "content": "Surface Prog"},
|
||||||
|
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "16:45", "duration_min": 20, "content": "Sea State"},
|
||||||
|
{"utc": "19:15", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "19:30", "duration_min": 20, "content": "Surface Prog"},
|
||||||
|
{"utc": "22:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "23:10", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "23:25", "duration_min": 20, "content": "Sea State"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Magallanes Naval",
|
||||||
|
"callsign": "CBM",
|
||||||
|
"country": "CL",
|
||||||
|
"city": "Punta Arenas",
|
||||||
|
"coordinates": [-53.16, -70.91],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 4322, "description": "Night"},
|
||||||
|
{"khz": 8696, "description": "Primary"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "01:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "13:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rio de Janeiro Naval",
|
||||||
|
"callsign": "PWZ33",
|
||||||
|
"country": "BR",
|
||||||
|
"city": "Rio de Janeiro",
|
||||||
|
"coordinates": [-22.91, -43.17],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 12665, "description": "Primary"},
|
||||||
|
{"khz": 16978, "description": "Day"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "07:45", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dakar Met",
|
||||||
|
"callsign": "6VU",
|
||||||
|
"country": "SN",
|
||||||
|
"city": "Dakar",
|
||||||
|
"coordinates": [14.69, -17.44],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 13667.5, "description": "Primary"},
|
||||||
|
{"khz": 19750, "description": "Day"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Misaki Fisheries",
|
||||||
|
"callsign": "JFC",
|
||||||
|
"country": "JP",
|
||||||
|
"city": "Miura",
|
||||||
|
"coordinates": [35.14, 139.62],
|
||||||
|
"frequencies": [
|
||||||
|
{"khz": 8616, "description": "Primary"},
|
||||||
|
{"khz": 13074, "description": "Day"},
|
||||||
|
{"khz": 17231, "description": "Extended"}
|
||||||
|
],
|
||||||
|
"ioc": 576,
|
||||||
|
"lpm": 120,
|
||||||
|
"schedule": [
|
||||||
|
{"utc": "00:00", "duration_min": 20, "content": "Sea Surface Temp"},
|
||||||
|
{"utc": "06:00", "duration_min": 20, "content": "Current Chart"},
|
||||||
|
{"utc": "12:00", "duration_min": 20, "content": "Fishing Forecast"},
|
||||||
|
{"utc": "18:00", "duration_min": 20, "content": "Sea Surface Temp"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,27 +1,62 @@
|
|||||||
# INTERCEPT - Signal Intelligence Platform
|
# INTERCEPT - Signal Intelligence Platform
|
||||||
# Docker Compose configuration for easy deployment
|
# Docker Compose configuration for easy deployment
|
||||||
|
#
|
||||||
|
# Uses gunicorn + gevent production server via start.sh (handles concurrent SSE/WebSocket)
|
||||||
|
#
|
||||||
|
# Basic usage (build locally):
|
||||||
|
# docker compose --profile basic up -d --build
|
||||||
|
#
|
||||||
|
# With ADS-B history (Postgres):
|
||||||
|
# docker compose --profile history up -d
|
||||||
|
|
||||||
services:
|
services:
|
||||||
intercept:
|
intercept:
|
||||||
|
# Always build and use the local image
|
||||||
|
image: intercept:latest
|
||||||
build: .
|
build: .
|
||||||
|
pull_policy: never
|
||||||
container_name: intercept
|
container_name: intercept
|
||||||
ports:
|
ports:
|
||||||
- "5050:5050"
|
- "5050:5050"
|
||||||
|
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
|
||||||
|
# - "5443:5443"
|
||||||
# Privileged mode required for USB SDR device access
|
# Privileged mode required for USB SDR device access
|
||||||
# Alternatively, use device mapping (see below)
|
|
||||||
privileged: true
|
privileged: true
|
||||||
# USB device mapping (alternative to privileged mode)
|
# USB device mapping for all USB devices
|
||||||
# devices:
|
devices:
|
||||||
# - /dev/bus/usb:/dev/bus/usb
|
- /dev/bus/usb:/dev/bus/usb
|
||||||
volumes:
|
volumes:
|
||||||
# Persist data directory
|
# Persist runtime output directories across container rebuilds.
|
||||||
- ./data:/app/data
|
# Mount subdirectories individually so Python modules in /app/data are not shadowed.
|
||||||
|
- ./data/weather_sat:/app/data/weather_sat
|
||||||
|
- ./data/radiosonde:/app/data/radiosonde
|
||||||
|
- ./data/subghz:/app/data/subghz
|
||||||
|
- ./data/adsb:/app/data/adsb
|
||||||
# Optional: mount logs directory
|
# Optional: mount logs directory
|
||||||
# - ./logs:/app/logs
|
# - ./logs:/app/logs
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
- INTERCEPT_HOST=0.0.0.0
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
- INTERCEPT_PORT=5050
|
- INTERCEPT_PORT=5050
|
||||||
- INTERCEPT_LOG_LEVEL=INFO
|
- INTERCEPT_LOG_LEVEL=INFO
|
||||||
|
# HTTPS support (auto-generates self-signed cert)
|
||||||
|
# - INTERCEPT_HTTPS=true
|
||||||
|
# - INTERCEPT_PORT=5443
|
||||||
|
# ADS-B history is disabled by default
|
||||||
|
# To enable, use: docker compose --profile history up -d
|
||||||
|
# - INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
|
# - INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||||
|
# - INTERCEPT_ADSB_DB_PORT=5432
|
||||||
|
# - INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||||
|
# - INTERCEPT_ADSB_DB_USER=intercept
|
||||||
|
# - INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||||
|
# ADS-B auto-start on dashboard load (default false)
|
||||||
|
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
||||||
|
# Shared observer location across modules
|
||||||
|
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
||||||
|
# Default observer coordinates (set to your location to skip the GPS prompt)
|
||||||
|
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
|
||||||
|
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
|
||||||
# Network mode for WiFi scanning (requires host network)
|
# Network mode for WiFi scanning (requires host network)
|
||||||
# network_mode: host
|
# network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -32,6 +67,79 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
# Optional: Add volume for persistent SQLite database
|
# ADS-B history with Postgres persistence
|
||||||
# volumes:
|
# Enable with: docker compose --profile history up -d
|
||||||
# intercept-data:
|
intercept-history:
|
||||||
|
# Always build and use the local image
|
||||||
|
image: intercept:latest
|
||||||
|
build: .
|
||||||
|
pull_policy: never
|
||||||
|
container_name: intercept-history
|
||||||
|
profiles:
|
||||||
|
- history
|
||||||
|
depends_on:
|
||||||
|
- adsb_db
|
||||||
|
ports:
|
||||||
|
- "5050:5050"
|
||||||
|
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
|
||||||
|
# - "5443:5443"
|
||||||
|
# Privileged mode required for USB SDR device access
|
||||||
|
privileged: true
|
||||||
|
# USB device mapping for all USB devices
|
||||||
|
devices:
|
||||||
|
- /dev/bus/usb:/dev/bus/usb
|
||||||
|
volumes:
|
||||||
|
- ./data/weather_sat:/app/data/weather_sat
|
||||||
|
- ./data/radiosonde:/app/data/radiosonde
|
||||||
|
- ./data/subghz:/app/data/subghz
|
||||||
|
- ./data/adsb:/app/data/adsb
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
|
- INTERCEPT_PORT=5050
|
||||||
|
- INTERCEPT_LOG_LEVEL=INFO
|
||||||
|
# HTTPS support (auto-generates self-signed cert)
|
||||||
|
# - INTERCEPT_HTTPS=true
|
||||||
|
# - INTERCEPT_PORT=5443
|
||||||
|
- INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
|
- INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||||
|
- INTERCEPT_ADSB_DB_PORT=5432
|
||||||
|
- INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||||
|
- INTERCEPT_ADSB_DB_USER=intercept
|
||||||
|
- INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||||
|
# ADS-B auto-start on dashboard load (default false)
|
||||||
|
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
||||||
|
# Shared observer location across modules
|
||||||
|
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
||||||
|
# Disable login auth (set to true for local/dev use)
|
||||||
|
- INTERCEPT_DISABLE_AUTH=${INTERCEPT_DISABLE_AUTH:-false}
|
||||||
|
# Default observer coordinates (set to your location to skip the GPS prompt)
|
||||||
|
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
|
||||||
|
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
adsb_db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: intercept-adsb-db
|
||||||
|
profiles:
|
||||||
|
- history
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
|
- POSTGRES_DB=intercept_adsb
|
||||||
|
- POSTGRES_USER=intercept
|
||||||
|
- POSTGRES_PASSWORD=intercept
|
||||||
|
volumes:
|
||||||
|
# Default local path (override with PGDATA_PATH for external storage)
|
||||||
|
- ${PGDATA_PATH:-./pgdata}:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
www.intercept-sigint.com
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
# Intercept Distributed Agent System
|
||||||
|
|
||||||
|
This document describes the distributed agent architecture that allows multiple remote sensor nodes to feed data into a central Intercept controller.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The agent system uses a hub-and-spoke architecture where:
|
||||||
|
- **Controller**: The main Intercept instance that aggregates data from multiple agents
|
||||||
|
- **Agents**: Lightweight sensor nodes running on remote devices with SDR hardware
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ INTERCEPT CONTROLLER │
|
||||||
|
│ (port 5050) │
|
||||||
|
│ │
|
||||||
|
│ - Web UI with agent selector │
|
||||||
|
│ - /controller/manage page │
|
||||||
|
│ - Multi-agent SSE stream │
|
||||||
|
│ - Push data storage │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
▲ ▲ ▲
|
||||||
|
│ │ │
|
||||||
|
Push/Pull │ │ │ Push/Pull
|
||||||
|
│ │ │
|
||||||
|
┌────┴───┐ ┌────┴───┐ ┌────┴───┐
|
||||||
|
│ Agent │ │ Agent │ │ Agent │
|
||||||
|
│ :8020 │ │ :8020 │ │ :8020 │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│[RTL-SDR] │[HackRF] │ │[LimeSDR]
|
||||||
|
└────────┘ └────────┘ └────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Start the Controller
|
||||||
|
|
||||||
|
The controller is the main Intercept application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd intercept
|
||||||
|
./setup.sh # First-time setup (choose install profiles)
|
||||||
|
sudo ./start.sh # Production server on http://localhost:5050
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure an Agent
|
||||||
|
|
||||||
|
Create a config file on the remote machine:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# intercept_agent.cfg
|
||||||
|
[agent]
|
||||||
|
name = sensor-node-1
|
||||||
|
port = 8020
|
||||||
|
allowed_ips =
|
||||||
|
allow_cors = false
|
||||||
|
|
||||||
|
[controller]
|
||||||
|
url = http://192.168.1.100:5050
|
||||||
|
api_key = your-secret-key-here
|
||||||
|
push_enabled = true
|
||||||
|
push_interval = 5
|
||||||
|
|
||||||
|
[modes]
|
||||||
|
pager = true
|
||||||
|
sensor = true
|
||||||
|
adsb = true
|
||||||
|
wifi = true
|
||||||
|
bluetooth = true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start the Agent
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python intercept_agent.py --config intercept_agent.cfg
|
||||||
|
# Runs on http://localhost:8020
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Register the Agent
|
||||||
|
|
||||||
|
Go to `http://controller:5050/controller/manage` and add the agent:
|
||||||
|
- **Name**: sensor-node-1 (must match config)
|
||||||
|
- **Base URL**: http://agent-ip:8020
|
||||||
|
- **API Key**: your-secret-key-here (must match config)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
The system supports two data flow patterns:
|
||||||
|
|
||||||
|
#### Push (Agent → Controller)
|
||||||
|
|
||||||
|
Agents automatically push captured data to the controller:
|
||||||
|
|
||||||
|
1. Agent captures data (e.g., rtl_433 sensor readings)
|
||||||
|
2. Data is queued in the `ControllerPushClient`
|
||||||
|
3. Agent POSTs to `http://controller/controller/api/ingest`
|
||||||
|
4. Controller validates API key and stores in `push_payloads` table
|
||||||
|
5. Data is available via SSE stream at `/controller/stream/all`
|
||||||
|
|
||||||
|
```
|
||||||
|
Agent Controller
|
||||||
|
│ │
|
||||||
|
│ POST /controller/api/ingest │
|
||||||
|
│ Header: X-API-Key: secret │
|
||||||
|
│ Body: {agent_name, scan_type, │
|
||||||
|
│ payload, timestamp} │
|
||||||
|
│ ──────────────────────────────► │
|
||||||
|
│ │
|
||||||
|
│ 200 OK │
|
||||||
|
│ ◄────────────────────────────── │
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Pull (Controller → Agent)
|
||||||
|
|
||||||
|
The controller can also pull data on-demand:
|
||||||
|
|
||||||
|
1. User selects agent in UI dropdown
|
||||||
|
2. User clicks "Start Listening"
|
||||||
|
3. Controller proxies request to agent
|
||||||
|
4. Agent starts the mode and returns status
|
||||||
|
5. Controller polls agent for data
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser Controller Agent
|
||||||
|
│ │ │
|
||||||
|
│ POST /controller/ │ │
|
||||||
|
│ agents/1/sensor/start│ │
|
||||||
|
│ ─────────────────────► │ │
|
||||||
|
│ │ POST /sensor/start │
|
||||||
|
│ │ ────────────────────────► │
|
||||||
|
│ │ │
|
||||||
|
│ │ {status: started} │
|
||||||
|
│ │ ◄──────────────────────── │
|
||||||
|
│ {status: success} │ │
|
||||||
|
│ ◄───────────────────── │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
API key authentication secures the push mechanism:
|
||||||
|
|
||||||
|
1. Agent config specifies `api_key` in `[controller]` section
|
||||||
|
2. Agent sends `X-API-Key` header with each push request
|
||||||
|
3. Controller looks up agent by name in database
|
||||||
|
4. Controller compares provided key with stored key
|
||||||
|
5. Mismatched keys return 401 Unauthorized
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
Two tables support the agent system:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Registered agents
|
||||||
|
CREATE TABLE agents (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
api_key TEXT,
|
||||||
|
capabilities TEXT, -- JSON: {pager: true, sensor: true, ...}
|
||||||
|
interfaces TEXT, -- JSON: {devices: [...]}
|
||||||
|
gps_coords TEXT, -- JSON: {lat, lon}
|
||||||
|
last_seen TIMESTAMP,
|
||||||
|
is_active BOOLEAN
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pushed data from agents
|
||||||
|
CREATE TABLE push_payloads (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
agent_id INTEGER,
|
||||||
|
scan_type TEXT, -- pager, sensor, adsb, wifi, etc.
|
||||||
|
payload TEXT, -- JSON data
|
||||||
|
received_at TIMESTAMP,
|
||||||
|
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent REST API
|
||||||
|
|
||||||
|
The agent exposes these endpoints:
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/health` | GET | Health check (returns `{status: "healthy"}`) |
|
||||||
|
| `/capabilities` | GET | Available modes, devices, GPS status |
|
||||||
|
| `/status` | GET | Running modes, uptime, push status |
|
||||||
|
| `/{mode}/start` | POST | Start a mode (pager, sensor, adsb, etc.) |
|
||||||
|
| `/{mode}/stop` | POST | Stop a mode |
|
||||||
|
| `/{mode}/status` | GET | Mode-specific status |
|
||||||
|
| `/{mode}/data` | GET | Current data snapshot |
|
||||||
|
|
||||||
|
### Example: Start Sensor Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://agent:8020/sensor/start \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"frequency": 433.92, "device_index": 0}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "started",
|
||||||
|
"mode": "sensor",
|
||||||
|
"command": "/usr/local/bin/rtl_433 -d 0 -f 433.92M -F json",
|
||||||
|
"gps_enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Get Capabilities
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://agent:8020/capabilities
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"modes": {
|
||||||
|
"pager": true,
|
||||||
|
"sensor": true,
|
||||||
|
"adsb": true,
|
||||||
|
"wifi": true,
|
||||||
|
"bluetooth": true
|
||||||
|
},
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"name": "RTLSDRBlog, Blog V4",
|
||||||
|
"sdr_type": "rtlsdr",
|
||||||
|
"capabilities": {
|
||||||
|
"freq_min_mhz": 24.0,
|
||||||
|
"freq_max_mhz": 1766.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gps": true,
|
||||||
|
"gps_position": {
|
||||||
|
"lat": 33.543,
|
||||||
|
"lon": -82.194,
|
||||||
|
"altitude": 70.0
|
||||||
|
},
|
||||||
|
"tool_details": {
|
||||||
|
"sensor": {
|
||||||
|
"name": "433MHz Sensors",
|
||||||
|
"ready": true,
|
||||||
|
"tools": {
|
||||||
|
"rtl_433": {"installed": true, "required": true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Modes
|
||||||
|
|
||||||
|
All modes are fully implemented in the agent with the following tools and data formats:
|
||||||
|
|
||||||
|
| Mode | Tool(s) | Data Format | Notes |
|
||||||
|
|------|---------|-------------|-------|
|
||||||
|
| `sensor` | rtl_433 | JSON readings | ISM band devices (433/868/915 MHz) |
|
||||||
|
| `pager` | rtl_fm + multimon-ng | POCSAG/FLEX messages | Address, function, message content |
|
||||||
|
| `adsb` | dump1090 | SBS-format aircraft | ICAO, callsign, position, altitude |
|
||||||
|
| `ais` | AIS-catcher | JSON vessels | MMSI, position, speed, vessel info |
|
||||||
|
| `acars` | acarsdec | JSON messages | Aircraft tail, label, message text |
|
||||||
|
| `aprs` | rtl_fm + direwolf | APRS packets | Callsign, position, path |
|
||||||
|
| `wifi` | airodump-ng | Networks + clients | BSSID, ESSID, signal, clients |
|
||||||
|
| `bluetooth` | bluetoothctl | Device list | MAC, name, RSSI |
|
||||||
|
| `rtlamr` | rtl_tcp + rtlamr | Meter readings | Meter ID, consumption data |
|
||||||
|
| `dsc` | rtl_fm (+ dsc-decoder) | DSC messages | MMSI, distress category, position |
|
||||||
|
| `tscm` | WiFi/BT analysis | Anomaly reports | New/rogue devices detected |
|
||||||
|
| `satellite` | skyfield (TLE) | Pass predictions | No SDR required |
|
||||||
|
| `listening_post` | rtl_fm scanner | Signal detections | Frequency, modulation |
|
||||||
|
|
||||||
|
### Mode-Specific Notes
|
||||||
|
|
||||||
|
**Listening Post**: Full FFT streaming isn't practical over HTTP. Instead, the agent provides:
|
||||||
|
- Signal detection events when activity is found
|
||||||
|
- Current scanning frequency
|
||||||
|
- Activity log of detected signals
|
||||||
|
|
||||||
|
**TSCM**: Analyzes WiFi and Bluetooth data for anomalies:
|
||||||
|
- Builds baseline of known devices
|
||||||
|
- Reports new/unknown devices as anomalies
|
||||||
|
- No SDR required (uses WiFi/BT data)
|
||||||
|
|
||||||
|
**Satellite**: Pure computational mode:
|
||||||
|
- Calculates pass predictions from TLE data
|
||||||
|
- Requires observer location (lat/lon)
|
||||||
|
- No SDR required
|
||||||
|
|
||||||
|
**Audio Modes**: Modes requiring real-time audio (airband, listening_post audio) are limited via agents. Use rtl_tcp for remote audio streaming instead.
|
||||||
|
|
||||||
|
## Controller API
|
||||||
|
|
||||||
|
### Agent Management
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/controller/agents` | GET | List all agents |
|
||||||
|
| `/controller/agents` | POST | Register new agent |
|
||||||
|
| `/controller/agents/{id}` | GET | Get agent details |
|
||||||
|
| `/controller/agents/{id}` | DELETE | Remove agent |
|
||||||
|
| `/controller/agents/{id}?refresh=true` | GET | Refresh agent capabilities |
|
||||||
|
|
||||||
|
### Proxy Operations
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/controller/agents/{id}/{mode}/start` | POST | Start mode on agent |
|
||||||
|
| `/controller/agents/{id}/{mode}/stop` | POST | Stop mode on agent |
|
||||||
|
| `/controller/agents/{id}/{mode}/data` | GET | Get data from agent |
|
||||||
|
|
||||||
|
### Push Ingestion
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/controller/api/ingest` | POST | Receive pushed data from agents |
|
||||||
|
|
||||||
|
### SSE Streams
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `/controller/stream/all` | Combined stream from all agents |
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
### Agent Selector
|
||||||
|
|
||||||
|
The main UI includes an agent dropdown in supported modes:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<select id="agentSelect">
|
||||||
|
<option value="local">Local (This Device)</option>
|
||||||
|
<option value="1">● sensor-node-1</option>
|
||||||
|
</select>
|
||||||
|
```
|
||||||
|
|
||||||
|
When an agent is selected:
|
||||||
|
1. Device list updates to show agent's SDR devices
|
||||||
|
2. Start/Stop commands route through controller proxy
|
||||||
|
3. Data displays with agent name badge
|
||||||
|
|
||||||
|
### Multi-Agent Mode
|
||||||
|
|
||||||
|
Enable "Show All Agents" checkbox to:
|
||||||
|
- Connect to `/controller/stream/all` SSE
|
||||||
|
- Display combined data from all agents
|
||||||
|
- Show agent name badge on each data item
|
||||||
|
|
||||||
|
## GPS Integration
|
||||||
|
|
||||||
|
Agents can include GPS coordinates with captured data:
|
||||||
|
|
||||||
|
1. Agent connects to local `gpsd` daemon
|
||||||
|
2. GPS position included in `/capabilities` and `/status`
|
||||||
|
3. Each data snapshot includes `agent_gps` field
|
||||||
|
4. Controller can use GPS for trilateration (multiple agents)
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
### Agent Config (`intercept_agent.cfg`)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[agent]
|
||||||
|
# Agent identity (must be unique across all agents)
|
||||||
|
name = sensor-node-1
|
||||||
|
|
||||||
|
# Port to listen on
|
||||||
|
port = 8020
|
||||||
|
|
||||||
|
# Restrict connections to specific IPs (comma-separated, empty = all)
|
||||||
|
allowed_ips =
|
||||||
|
|
||||||
|
# Enable CORS headers
|
||||||
|
allow_cors = false
|
||||||
|
|
||||||
|
[controller]
|
||||||
|
# Controller URL (required for push)
|
||||||
|
url = http://192.168.1.100:5050
|
||||||
|
|
||||||
|
# API key for authentication
|
||||||
|
api_key = your-secret-key
|
||||||
|
|
||||||
|
# Enable automatic data push
|
||||||
|
push_enabled = true
|
||||||
|
|
||||||
|
# Push interval in seconds
|
||||||
|
push_interval = 5
|
||||||
|
|
||||||
|
[modes]
|
||||||
|
# Enable/disable specific modes
|
||||||
|
pager = true
|
||||||
|
sensor = true
|
||||||
|
adsb = true
|
||||||
|
ais = true
|
||||||
|
wifi = true
|
||||||
|
bluetooth = true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Agent not appearing in controller
|
||||||
|
|
||||||
|
1. Check agent is running: `curl http://agent:8020/health`
|
||||||
|
2. Verify agent is registered in `/controller/manage`
|
||||||
|
3. Check API key matches between agent config and controller registration
|
||||||
|
4. Check network connectivity between agent and controller
|
||||||
|
|
||||||
|
### Push data not arriving
|
||||||
|
|
||||||
|
1. Check agent status: `curl http://agent:8020/status`
|
||||||
|
- Verify `push_enabled: true` and `push_connected: true`
|
||||||
|
2. Check controller logs for authentication errors
|
||||||
|
3. Verify API key matches
|
||||||
|
4. Check if mode is running and producing data
|
||||||
|
|
||||||
|
### Mode won't start on agent
|
||||||
|
|
||||||
|
1. Check capabilities: `curl http://agent:8020/capabilities`
|
||||||
|
2. Verify required tools are installed (check `tool_details`)
|
||||||
|
3. Check if SDR device is available (not in use by another process)
|
||||||
|
|
||||||
|
### No data from sensor mode
|
||||||
|
|
||||||
|
1. Verify rtl_433 is running: `ps aux | grep rtl_433`
|
||||||
|
2. Check sensor status: `curl http://agent:8020/sensor/status`
|
||||||
|
3. Note: Empty data is normal if no 433MHz devices are transmitting nearby
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **API Keys**: Always use strong, unique API keys for each agent
|
||||||
|
2. **Network**: Consider running agents on a private network or VPN
|
||||||
|
3. **HTTPS**: For production, use HTTPS between agents and controller
|
||||||
|
4. **Firewall**: Restrict agent ports to controller IP only
|
||||||
|
5. **allowed_ips**: Use this config option to restrict agent connections
|
||||||
|
|
||||||
|
## Dashboard Integration
|
||||||
|
|
||||||
|
Agent support has been integrated into the following specialized dashboards:
|
||||||
|
|
||||||
|
### ADS-B Dashboard (`/adsb/dashboard`)
|
||||||
|
- Agent selector in header bar
|
||||||
|
- Routes tracking start/stop through agent proxy when remote agent selected
|
||||||
|
- Connects to multi-agent stream for data from remote agents
|
||||||
|
- Displays agent badge on aircraft from remote sources
|
||||||
|
- Updates observer location from agent's GPS coordinates
|
||||||
|
|
||||||
|
### AIS Dashboard (`/ais/dashboard`)
|
||||||
|
- Agent selector in header bar
|
||||||
|
- Routes AIS and DSC mode operations through agent proxy
|
||||||
|
- Connects to multi-agent stream for vessel data
|
||||||
|
- Displays agent badge on vessels from remote sources
|
||||||
|
- Updates observer location from agent's GPS coordinates
|
||||||
|
|
||||||
|
### Main Dashboard (`/`)
|
||||||
|
- Agent selector in sidebar
|
||||||
|
- Supports sensor, pager, WiFi, Bluetooth modes via agents
|
||||||
|
- SDR conflict detection with device-aware warnings
|
||||||
|
- Real-time sync with agent's running mode state
|
||||||
|
|
||||||
|
### Multi-SDR Agent Support
|
||||||
|
|
||||||
|
For agents with multiple SDR devices, the system now tracks which device each mode is using:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"running_modes": ["sensor", "adsb"],
|
||||||
|
"running_modes_detail": {
|
||||||
|
"sensor": {"device": 0, "started_at": "2024-01-15T10:30:00Z"},
|
||||||
|
"adsb": {"device": 1, "started_at": "2024-01-15T10:35:00Z"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows:
|
||||||
|
- Smart conflict detection (only warns if same device is in use)
|
||||||
|
- Display of which device each mode is using
|
||||||
|
- Parallel operation of multiple SDR modes on multi-SDR agents
|
||||||
|
|
||||||
|
### Agent Mode Warnings
|
||||||
|
|
||||||
|
When an agent has SDR modes running, the UI displays:
|
||||||
|
- Warning banner showing active modes with device numbers
|
||||||
|
- Stop buttons for each running mode
|
||||||
|
- Refresh button to re-sync with agent state
|
||||||
|
|
||||||
|
### Pages Without Agent Support
|
||||||
|
|
||||||
|
The following pages don't require SDR-based agent support:
|
||||||
|
- **Satellite Dashboard** (`/satellite/dashboard`) - Uses TLE orbital calculations, no SDR
|
||||||
|
- **History pages** - Display stored data, not live SDR streams
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `intercept_agent.py` | Standalone agent server |
|
||||||
|
| `intercept_agent.cfg` | Agent configuration template |
|
||||||
|
| `routes/controller.py` | Controller API blueprint |
|
||||||
|
| `utils/agent_client.py` | HTTP client for agents |
|
||||||
|
| `utils/database.py` | Agent CRUD operations |
|
||||||
|
| `static/js/core/agents.js` | Frontend agent management |
|
||||||
|
| `templates/agents.html` | Agent management page |
|
||||||
|
| `templates/adsb_dashboard.html` | ADS-B page with agent integration |
|
||||||
|
| `templates/ais_dashboard.html` | AIS page with agent integration |
|
||||||
@@ -16,6 +16,25 @@ Complete feature list for all modules.
|
|||||||
- **Doorbells, remotes, and IoT devices**
|
- **Doorbells, remotes, and IoT devices**
|
||||||
- **Smart meters** and utility monitors
|
- **Smart meters** and utility monitors
|
||||||
|
|
||||||
|
## Sub-GHz Analyzer
|
||||||
|
|
||||||
|
- **HackRF-based** signal capture and analysis for 300-928 MHz ISM bands
|
||||||
|
- **Protocol decoding** - identify and decode common Sub-GHz protocols
|
||||||
|
- **Signal replay/transmit** capabilities for authorized testing
|
||||||
|
- **Wideband spectrum analysis** with real-time visualization
|
||||||
|
- **I/Q capture** - record raw samples for offline analysis
|
||||||
|
|
||||||
|
## Spy Stations (Number Stations)
|
||||||
|
|
||||||
|
- **Comprehensive database** of active number stations and diplomatic networks
|
||||||
|
- **Station profiles** - frequencies, schedules, operators, descriptions
|
||||||
|
- **Filter by type** - number stations vs diplomatic networks
|
||||||
|
- **Filter by country** - Russia, Cuba, Israel, Poland, North Korea, etc.
|
||||||
|
- **Filter by mode** - USB, AM, CW, OFDM
|
||||||
|
- **Tune integration** - click to tune Listening Post to station frequency
|
||||||
|
- **Source links** - references to priyom.org for detailed information
|
||||||
|
- **Famous stations** - UVB-76 "The Buzzer", Cuban HM01, Israeli E17z
|
||||||
|
|
||||||
## ADS-B Aircraft Tracking
|
## ADS-B Aircraft Tracking
|
||||||
|
|
||||||
- **Real-time aircraft tracking** via dump1090 or rtl_adsb
|
- **Real-time aircraft tracking** via dump1090 or rtl_adsb
|
||||||
@@ -26,6 +45,8 @@ Complete feature list for all modules.
|
|||||||
- **Aircraft filtering** - show all, military only, civil only, or emergency only
|
- **Aircraft filtering** - show all, military only, civil only, or emergency only
|
||||||
- **Marker clustering** - group nearby aircraft at lower zoom levels
|
- **Marker clustering** - group nearby aircraft at lower zoom levels
|
||||||
- **Reception statistics** - max range, message rate, busiest hour, total seen
|
- **Reception statistics** - max range, message rate, busiest hour, total seen
|
||||||
|
- **Persistent ADS-B history** - optional Postgres-backed message and snapshot storage
|
||||||
|
- **History reporting dashboard** - session controls, aircraft timelines, and detail modal
|
||||||
- **Observer location** - manual input or GPS geolocation
|
- **Observer location** - manual input or GPS geolocation
|
||||||
- **Audio alerts** - notifications for military and emergency aircraft
|
- **Audio alerts** - notifications for military and emergency aircraft
|
||||||
- **Emergency squawk highlighting** - visual alerts for 7500/7600/7700
|
- **Emergency squawk highlighting** - visual alerts for 7500/7600/7700
|
||||||
@@ -35,6 +56,149 @@ Complete feature list for all modules.
|
|||||||
<img src="/static/images/screenshots/screenshot_radar.png" alt="Screenshot">
|
<img src="/static/images/screenshots/screenshot_radar.png" alt="Screenshot">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
## AIS Vessel Tracking
|
||||||
|
|
||||||
|
- **Real-time vessel tracking** via AIS-catcher or rtl_ais
|
||||||
|
- **Full-screen dashboard** - dedicated popout with maritime map
|
||||||
|
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
||||||
|
- **Vessel trails** - optional track history visualization
|
||||||
|
- **Vessel details popup** - name, MMSI, callsign, destination, ship type, speed, heading
|
||||||
|
- **Country identification** - flag lookup via Maritime Identification Digits (MID)
|
||||||
|
|
||||||
|
### VHF DSC Channel 70 Monitoring
|
||||||
|
|
||||||
|
Digital Selective Calling (DSC) monitoring on the international maritime distress frequency.
|
||||||
|
|
||||||
|
- **Real-time DSC decoding** - Distress, Urgency, Safety, and Routine messages
|
||||||
|
- **MMSI country lookup** - 180+ Maritime Identification Digit codes
|
||||||
|
- **Distress nature identification** - Fire, Flooding, Collision, Sinking, Piracy, MOB, etc.
|
||||||
|
- **Position extraction** - Automatic lat/lon parsing from distress messages
|
||||||
|
- **Map markers** - Distress positions plotted with pulsing alert markers
|
||||||
|
- **Visual alert overlay** - Prominent popup for DISTRESS and URGENCY messages
|
||||||
|
- **Audio alerts** - Notification sound for critical messages
|
||||||
|
- **Alert persistence** - Critical alerts stored permanently in database
|
||||||
|
- **Acknowledgement workflow** - Track response status with notes
|
||||||
|
- **SDR conflict detection** - Prevents device collisions with AIS tracking
|
||||||
|
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
|
||||||
|
|
||||||
|
## ACARS Messaging
|
||||||
|
|
||||||
|
- **Real-time ACARS decoding** via acarsdec
|
||||||
|
- **Aircraft datalink messages** - operational, weather, and position reports
|
||||||
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
|
- **Message filtering** - filter by message type, flight, or registration
|
||||||
|
|
||||||
|
## VDL2 (VHF Data Link Mode 2)
|
||||||
|
|
||||||
|
- **Real-time VDL2 decoding** via dumpvdl2 on standard VDL2 frequencies
|
||||||
|
- **ACARS-over-AVLC** message capture with full frame parsing
|
||||||
|
- **Signal analysis** - frequency, signal level, noise level, SNR, burst length
|
||||||
|
- **AVLC frame details** - source/destination addresses, frame type, command/response
|
||||||
|
- **Raw JSON inspection** - expandable raw message data for each frame
|
||||||
|
- **Multi-frequency monitoring** - simultaneous reception on multiple VDL2 channels
|
||||||
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
|
- **CSV/JSON export** - export captured messages for offline analysis
|
||||||
|
- **Integrated with ADS-B dashboard** - VDL2 messages linked to aircraft tracking
|
||||||
|
|
||||||
|
## CW/Morse Code Decoder
|
||||||
|
|
||||||
|
- **Custom Goertzel tone detection** for CW (continuous wave) Morse decoding
|
||||||
|
- **OOK/AM envelope detection** mode for on-off keying signals in ISM bands
|
||||||
|
- **HF frequency presets** for amateur CW bands (160m-10m)
|
||||||
|
- **ISM band presets** for OOK envelope mode (315 MHz, 433 MHz, 868 MHz, 915 MHz)
|
||||||
|
- **Real-time character and word output** with WPM estimation
|
||||||
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
|
|
||||||
|
## WeFax (Weather Fax)
|
||||||
|
|
||||||
|
- **HF weather fax reception** from marine and meteorological broadcast stations
|
||||||
|
- **Broadcast timeline** with scheduled transmission times by station
|
||||||
|
- **Auto-scheduler** for unattended capture of scheduled broadcasts
|
||||||
|
- **Image gallery** with timestamped decoded weather charts
|
||||||
|
- **Station presets** for major WeFax broadcasters worldwide
|
||||||
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
|
|
||||||
|
## Listening Post
|
||||||
|
|
||||||
|
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
|
||||||
|
- **Real-time audio monitoring** with FM and SSB demodulation
|
||||||
|
- **Cross-module frequency routing** from scanner to decoders
|
||||||
|
- **Waterfall spectrum display** for visual signal identification
|
||||||
|
- **Customizable frequency presets** and band bookmarks
|
||||||
|
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
|
||||||
|
|
||||||
|
## Weather Satellites
|
||||||
|
|
||||||
|
- **NOAA APT** and **Meteor LRPT** image decoding via SatDump
|
||||||
|
- **Auto-scheduler** with pass prediction and automatic capture
|
||||||
|
- **Polar plot** - real-time satellite position on azimuth/elevation display
|
||||||
|
- **Ground track map** - orbit path with past/future trajectory
|
||||||
|
- **Image gallery** with timestamped decoded imagery
|
||||||
|
|
||||||
|
## WebSDR
|
||||||
|
|
||||||
|
- **KiwiSDR network integration** for remote HF/shortwave listening
|
||||||
|
- **WebSocket audio streaming** from remote receivers
|
||||||
|
- **Receiver discovery** with automatic caching
|
||||||
|
- **Frequency tuning** with band presets
|
||||||
|
|
||||||
|
## ISS SSTV
|
||||||
|
|
||||||
|
- **ISS SSTV image reception** on 145.800 MHz FM during special event transmissions
|
||||||
|
- **Real-time ISS tracking** with world map and pass predictions
|
||||||
|
- **Doppler correction** - optional lat/lon input for real-time frequency shift compensation
|
||||||
|
- **Next pass countdown** - time remaining until ISS is overhead
|
||||||
|
- **Image gallery** with timestamped decoded imagery
|
||||||
|
- **TLE updates** - fetch latest ISS orbital elements
|
||||||
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
|
|
||||||
|
## HF SSTV
|
||||||
|
|
||||||
|
- **Terrestrial SSTV decoding** across HF (80m-10m), VHF (6m, 2m), and UHF (70cm) bands
|
||||||
|
- **Predefined frequency lookup** for 13 active SSTV calling frequencies
|
||||||
|
- **Auto-modulation selection** - frequency table maps to correct mode (USB, LSB, FM)
|
||||||
|
- **Image gallery** with decoded transmissions
|
||||||
|
- **Common modes supported** - PD120, PD180, Martin1, Scottie1, Robot36
|
||||||
|
|
||||||
|
## APRS
|
||||||
|
|
||||||
|
- **Amateur packet radio** position reports and telemetry via direwolf
|
||||||
|
- **Region-specific frequencies** - 144.390 MHz (North America), 144.800 MHz (Europe), and more
|
||||||
|
- **Real-time position tracking** on interactive map
|
||||||
|
- **Message and telemetry display** from APRS network
|
||||||
|
|
||||||
|
## Utility Meter Reading
|
||||||
|
|
||||||
|
- **Smart meter monitoring** via rtl_amr for electric, gas, and water meters
|
||||||
|
- **Real-time JSON output** with meter ID, consumption, and signal data
|
||||||
|
- **Multiple meter protocol support** via rtl_tcp integration
|
||||||
|
|
||||||
|
## Space Weather
|
||||||
|
|
||||||
|
- **Real-time solar indices** - Solar Flux Index (SFI), Kp index, A-index, sunspot number
|
||||||
|
- **NOAA Space Weather Scales** - Geomagnetic storms (G), solar radiation (S), radio blackouts (R)
|
||||||
|
- **HF band conditions** - Day/night propagation from HamQSL for 80m through 10m bands
|
||||||
|
- **Solar wind monitoring** - Speed, density, and IMF Bz from DSCOVR satellite
|
||||||
|
- **X-ray flux chart** - GOES X-ray data with flare class scale (A/B/C/M/X)
|
||||||
|
- **Flare probability** - 1-day and 3-day C/M/X-class flare forecasts
|
||||||
|
- **Solar imagery** - NASA SDO 193A, 304A, and magnetogram images
|
||||||
|
- **D-RAP absorption maps** - HF radio absorption at 5-30 MHz frequency bands
|
||||||
|
- **Aurora forecast** - OVATION aurora oval visualization
|
||||||
|
- **SWPC alerts** - Real-time space weather alerts and warnings
|
||||||
|
- **Active solar regions** - Current sunspot region data with location and area
|
||||||
|
- **Auto-refresh** - 5-minute polling with manual refresh option
|
||||||
|
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
|
||||||
|
|
||||||
|
## Radiosonde Weather Balloon Tracking
|
||||||
|
|
||||||
|
- **400-406 MHz reception** via radiosonde_auto_rx for weather balloon telemetry
|
||||||
|
- **Frequency presets** for common radiosonde bands
|
||||||
|
- **Real-time telemetry** - altitude, temperature, humidity, pressure, GPS position
|
||||||
|
- **Interactive map** with balloon trajectory and burst point prediction
|
||||||
|
- **Station location** with configurable observer position
|
||||||
|
- **Distance tracking** - real-time distance-to-balloon calculation
|
||||||
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
|
|
||||||
## Satellite Tracking
|
## Satellite Tracking
|
||||||
|
|
||||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||||
@@ -75,17 +239,243 @@ Complete feature list for all modules.
|
|||||||
## Bluetooth Scanning
|
## Bluetooth Scanning
|
||||||
|
|
||||||
- **BLE and Classic** Bluetooth device scanning
|
- **BLE and Classic** Bluetooth device scanning
|
||||||
- **Multiple scan modes** - hcitool, bluetoothctl
|
- **Multiple scan modes** - hcitool, bluetoothctl, bleak
|
||||||
- **Tracker detection** - AirTag, Tile, Samsung SmartTag, Chipolo
|
- **Tracker detection** - AirTag, Tile, Samsung SmartTag, Chipolo
|
||||||
- **Device classification** - phones, audio, wearables, computers
|
- **Device classification** - phones, audio, wearables, computers
|
||||||
- **Manufacturer lookup** via OUI database
|
- **Manufacturer lookup** via OUI database and Bluetooth Company IDs
|
||||||
- **Proximity radar** visualization
|
- **Proximity radar** visualization
|
||||||
- **Device type breakdown** chart
|
- **Device type breakdown** chart
|
||||||
|
|
||||||
|
## BT Locate (SAR Bluetooth Device Location)
|
||||||
|
|
||||||
|
Search and rescue Bluetooth device location with GPS-tagged signal trail mapping.
|
||||||
|
|
||||||
|
### Core Features
|
||||||
|
- **Target tracking** - Locate devices by MAC address, name pattern, or IRK (Identity Resolving Key)
|
||||||
|
- **RPA resolution** - Resolve BLE Resolvable Private Addresses using IRK for tracking devices with randomized addresses
|
||||||
|
- **IRK auto-detection** - Extract IRKs from paired devices on macOS and Linux
|
||||||
|
- **GPS-tagged signal trail** - Every detection is tagged with GPS coordinates for trail mapping
|
||||||
|
- **Proximity bands** - IMMEDIATE (<1m), NEAR (1-5m), FAR (>5m) with color-coded HUD
|
||||||
|
- **RSSI history chart** - Real-time signal strength sparkline for trend analysis
|
||||||
|
- **Distance estimation** - Log-distance path loss model with environment presets
|
||||||
|
- **Audio proximity alerts** - Web Audio API tones that increase in pitch as signal strengthens
|
||||||
|
- **Hand-off from Bluetooth mode** - One-click transfer of a device from BT scanner to BT Locate
|
||||||
|
|
||||||
|
### Environment Presets
|
||||||
|
- **Open Field** (n=2.0) - Free space path loss
|
||||||
|
- **Outdoor** (n=2.2) - Typical outdoor environment
|
||||||
|
- **Indoor** (n=3.0) - Indoor with walls and obstacles
|
||||||
|
|
||||||
|
### Map & Trail
|
||||||
|
- Interactive Leaflet map with GPS trail visualization
|
||||||
|
- Trail points color-coded by proximity band
|
||||||
|
- Polyline connecting detection points for path visualization
|
||||||
|
- Supports user-configured tile providers
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- Bluetooth adapter (built-in or USB)
|
||||||
|
- GPS receiver (optional, falls back to manual coordinates)
|
||||||
|
|
||||||
|
## WiFi Locate
|
||||||
|
|
||||||
|
Locate a WiFi access point by BSSID using real-time signal strength tracking.
|
||||||
|
|
||||||
|
### Core Features
|
||||||
|
- **Target by BSSID** - Enter any MAC address or hand off from the WiFi scanner
|
||||||
|
- **Real-time signal meter** - Large dBm display with color-coded strength (good/medium/weak)
|
||||||
|
- **20-segment signal bar** - Visual proximity indicator with red/yellow/green segments
|
||||||
|
- **RSSI history chart** - Canvas sparkline showing signal trend over time
|
||||||
|
- **Distance estimation** - Log-distance path loss model with configurable environment presets
|
||||||
|
- **Audio proximity alerts** - Web Audio API tones that increase in pitch and frequency as signal strengthens
|
||||||
|
- **Signal lost detection** - 30-second timeout with visual overlay when target disappears
|
||||||
|
- **Hand-off from WiFi mode** - One-click transfer from WiFi detail drawer to WiFi Locate
|
||||||
|
- **Stats tracking** - Current, min, max, and average RSSI across session
|
||||||
|
|
||||||
|
### Environment Presets
|
||||||
|
- **Open Field** (n=2.0) - Free space path loss
|
||||||
|
- **Outdoor** (n=2.8) - Typical outdoor environment (default)
|
||||||
|
- **Indoor** (n=3.5) - Indoor with walls and obstacles
|
||||||
|
|
||||||
|
### Mode Transition
|
||||||
|
- WiFi scan is preserved when switching between WiFi and WiFi Locate modes
|
||||||
|
- Deep scan auto-starts if not already running
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- WiFi adapter capable of monitor mode
|
||||||
|
- aircrack-ng suite for deep scanning
|
||||||
|
|
||||||
|
## GPS Mode
|
||||||
|
|
||||||
|
Real-time GPS position tracking with live map visualization.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Live position tracking** - Real-time latitude, longitude, altitude display
|
||||||
|
- **Interactive map** - Current position on Leaflet map with track history
|
||||||
|
- **Speed and heading** - Real-time speed (km/h) and compass heading
|
||||||
|
- **Satellite info** - Number of satellites in view and fix quality
|
||||||
|
- **Track recording** - Record GPS tracks with export capability
|
||||||
|
- **Accuracy display** - Horizontal and vertical position accuracy (EPX/EPY)
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- USB GPS receiver connected via gpsd
|
||||||
|
- gpsd daemon running (`sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`)
|
||||||
|
|
||||||
|
## TSCM Counter-Surveillance Mode
|
||||||
|
|
||||||
|
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
|
||||||
|
|
||||||
|
### Wireless Sweep Features
|
||||||
|
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
|
||||||
|
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
|
||||||
|
- **RF spectrum analysis** (RTL-SDR or HackRF) - FM bugs, ISM bands, video transmitters
|
||||||
|
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF
|
||||||
|
- **Baseline comparison** - detect new/unknown devices vs known environment
|
||||||
|
|
||||||
|
### MAC-Randomization Resistant Detection
|
||||||
|
- **Device fingerprinting** based on advertisement payloads, not MAC addresses
|
||||||
|
- **Behavioral clustering** - groups observations into probable physical devices
|
||||||
|
- **Session tracking** - monitors device presence windows
|
||||||
|
- **Timing pattern analysis** - detects characteristic advertising intervals
|
||||||
|
- **RSSI trajectory correlation** - identifies co-located devices
|
||||||
|
|
||||||
|
### Risk Assessment
|
||||||
|
- **Three-tier scoring model**:
|
||||||
|
- Informational (0-2): Known or expected devices
|
||||||
|
- Needs Review (3-5): Unusual devices requiring assessment
|
||||||
|
- High Interest (6+): Multiple indicators warrant investigation
|
||||||
|
- **Risk indicators**: Stable RSSI, audio-capable, ESP32 chipsets, hidden identity, MAC rotation
|
||||||
|
- **Audit trail** - full evidence chain for each link/flag
|
||||||
|
- **Client-safe disclaimers** - findings are indicators, not confirmed surveillance
|
||||||
|
|
||||||
|
### Limitations (Documented)
|
||||||
|
- Cannot detect non-transmitting devices
|
||||||
|
- False positives/negatives expected
|
||||||
|
- Results require professional verification
|
||||||
|
- No cryptographic de-randomization
|
||||||
|
- Passive screening only (no active probing by default)
|
||||||
|
|
||||||
|
## Drone Intelligence
|
||||||
|
|
||||||
|
Multi-vector UAV detection and identification system combining three complementary detection methods into unified contact tracking.
|
||||||
|
|
||||||
|
### Detection Vectors
|
||||||
|
|
||||||
|
- **Remote ID (WiFi/BLE)** — Parses ASTM F3411-22a broadcast frames from WiFi Beacon and BLE Advertisement packets. Extracts drone ID, operator ID, drone type, GPS position, altitude, speed, and emergency status. Mandatory for all drones >250g in the US/EU since 2023.
|
||||||
|
- **RTL-SDR RF (433/868 MHz)** — Monitors ISM bands for control link and telemetry signals characteristic of consumer and FPV drones. Detects DJI OcuSync, FrSky, FlySky, and generic FSK/GFSK drone control protocols.
|
||||||
|
- **HackRF (2.4/5.8 GHz)** — Wide-scan of video downlink and telemetry bands used by most consumer drones. Detects power above noise floor across 2.400–2.483 GHz and 5.725–5.875 GHz ISM bands.
|
||||||
|
|
||||||
|
### Contact Correlation
|
||||||
|
|
||||||
|
The `DroneCorrelator` merges raw observations from all three vectors into unified `DroneContact` objects:
|
||||||
|
- **TTL-based store** — contacts expire after 120 seconds of no activity
|
||||||
|
- **Multi-vector fusion** — a single contact can be seen on 1–3 vectors simultaneously
|
||||||
|
- **Deduplication** — observations from the same vector within 5 seconds are collapsed
|
||||||
|
|
||||||
|
### Risk Scoring
|
||||||
|
|
||||||
|
| Level | Criteria |
|
||||||
|
|-------|----------|
|
||||||
|
| High | No Remote ID broadcast (non-compliant) or ASTM non-conformant frame |
|
||||||
|
| Medium | Multiple detection vectors active, or RSSI delta >15 dB between vectors |
|
||||||
|
| Low | Compliant Remote ID present, single detection vector |
|
||||||
|
|
||||||
|
### Live Map
|
||||||
|
|
||||||
|
Remote ID contacts with GPS position data are plotted on a Leaflet map. Markers show drone ID and last known coordinates. Map updates in real time via SSE.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- WiFi adapter capable of monitor mode (for BLE/WiFi Remote ID)
|
||||||
|
- RTL-SDR dongle (for 433/868 MHz RF detection)
|
||||||
|
- HackRF One (optional, for 2.4/5.8 GHz detection)
|
||||||
|
- Python package: `opendroneid>=1.0`
|
||||||
|
|
||||||
|
## Meshtastic Mesh Networks
|
||||||
|
|
||||||
|
Integration with Meshtastic LoRa mesh networking devices for decentralized communication.
|
||||||
|
|
||||||
|
### Device Support
|
||||||
|
- **Heltec** - LoRa32 series
|
||||||
|
- **T-Beam** - TTGO T-Beam with GPS
|
||||||
|
- **RAK** - WisBlock series
|
||||||
|
- Any Meshtastic-compatible device via USB/Serial
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Real-time messaging** - Stream messages as they arrive
|
||||||
|
- **Channel configuration** - Set encryption keys and channel names
|
||||||
|
- **Node information** - View connected nodes with signal metrics
|
||||||
|
- **Message history** - Up to 500 messages retained
|
||||||
|
- **Signal quality** - RSSI and SNR for each message
|
||||||
|
- **Hop tracking** - See message hop count
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- Physical Meshtastic device connected via USB
|
||||||
|
- Meshtastic Python SDK (`pip install meshtastic`)
|
||||||
|
|
||||||
|
## Ubertooth One BLE Scanning
|
||||||
|
|
||||||
|
Advanced Bluetooth Low Energy scanning using Ubertooth One hardware.
|
||||||
|
|
||||||
|
### Capabilities
|
||||||
|
- **40-channel scanning** - Capture BLE advertisements across all channels
|
||||||
|
- **Raw payload access** - Full advertising data for analysis
|
||||||
|
- **Passive sniffing** - No active scanning required
|
||||||
|
- **MAC address extraction** - Public and random address types
|
||||||
|
- **RSSI measurement** - Signal strength for proximity estimation
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
- Works alongside standard BlueZ/DBus Bluetooth scanning
|
||||||
|
- Automatically detected when ubertooth-btle is available
|
||||||
|
- Falls back to standard adapter if Ubertooth not present
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- Ubertooth One hardware
|
||||||
|
- ubertooth-btle command-line tool installed
|
||||||
|
- libubertooth library
|
||||||
|
|
||||||
|
## Remote Agents (Distributed SIGINT)
|
||||||
|
|
||||||
|
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- **Hub-and-spoke model** - Central controller with multiple remote agents
|
||||||
|
- **Push and Pull modes** - Agents can push data automatically or respond to on-demand requests
|
||||||
|
- **API key authentication** - Secure communication between agents and controller
|
||||||
|
|
||||||
|
### Agent Features
|
||||||
|
- **Standalone deployment** - Run on Raspberry Pi, mini PCs, or any Linux device with SDR
|
||||||
|
- **All modes supported** - Pager, sensor, ADS-B, AIS, WiFi, Bluetooth, and more
|
||||||
|
- **GPS integration** - Automatic location tagging from USB GPS receivers
|
||||||
|
- **Multi-SDR support** - Run multiple modes simultaneously on agents with multiple SDRs
|
||||||
|
- **Capability discovery** - Controller auto-detects available modes and devices
|
||||||
|
|
||||||
|
### Controller Features
|
||||||
|
- **Agent management UI** - Register, test, and remove agents from `/controller/manage`
|
||||||
|
- **Real-time status** - Health monitoring with online/offline indicators
|
||||||
|
- **Unified data stream** - Aggregate data from all agents via SSE
|
||||||
|
- **Dashboard integration** - Agent selector in ADS-B, AIS, and main dashboards
|
||||||
|
- **Device conflict detection** - Smart warnings when SDR is in use
|
||||||
|
|
||||||
|
### Use Cases
|
||||||
|
- **Wide-area monitoring** - Cover larger geographic areas with distributed sensors
|
||||||
|
- **Remote installations** - Deploy sensors in locations without direct access
|
||||||
|
- **Redundancy** - Multiple nodes for reliable coverage
|
||||||
|
- **Triangulation** - Use multiple GPS-enabled agents for signal location
|
||||||
|
|
||||||
|
## System Health
|
||||||
|
|
||||||
|
- **Telemetry dashboard** with real-time system metrics
|
||||||
|
- **Process monitoring** for all running SDR tools and decoders
|
||||||
|
- **CPU, memory, and disk usage** tracking
|
||||||
|
- **SDR device status** overview
|
||||||
|
- **No SDR required** - monitors system health independently
|
||||||
|
|
||||||
## User Interface
|
## User Interface
|
||||||
|
|
||||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||||
- **UTC clock** - always visible in header for time-critical operations
|
- **UTC clock** - always visible in header for time-critical operations
|
||||||
|
- **SSE connection status indicator** - real-time connection state with SSEManager and exponential backoff reconnection
|
||||||
|
- **Accessibility** - aria-labels, form label associations, keyboard list navigation, and destructive action confirmation modals
|
||||||
- **Active mode indicator** - shows current mode with pulse animation
|
- **Active mode indicator** - shows current mode with pulse animation
|
||||||
- **Collapsible sections** - click any header to collapse/expand
|
- **Collapsible sections** - click any header to collapse/expand
|
||||||
- **Panel styling** - gradient backgrounds with indicator dots
|
- **Panel styling** - gradient backgrounds with indicator dots
|
||||||
@@ -103,17 +493,58 @@ Complete feature list for all modules.
|
|||||||
| ? | Open help (when not typing) |
|
| ? | Open help (when not typing) |
|
||||||
| Escape | Close help/modals |
|
| Escape | Close help/modals |
|
||||||
|
|
||||||
|
## Offline Mode
|
||||||
|
|
||||||
|
Run iNTERCEPT without internet connectivity by using bundled local assets.
|
||||||
|
|
||||||
|
### Bundled Assets
|
||||||
|
- **Leaflet 1.9.4** - Map library with marker images
|
||||||
|
- **Chart.js 4.4.1** - Signal strength graphs
|
||||||
|
- **Inter font** - Primary UI font (400, 500, 600, 700 weights)
|
||||||
|
- **JetBrains Mono font** - Monospace/code font (400, 500, 600, 700 weights)
|
||||||
|
|
||||||
|
### Settings Modal
|
||||||
|
Access via the gear icon in the navigation bar:
|
||||||
|
- **Offline Tab** - Toggle offline mode, configure asset sources (CDN vs local)
|
||||||
|
- **Display Tab** - Theme and animation preferences
|
||||||
|
- **About Tab** - Version info and links
|
||||||
|
|
||||||
|
### Map Tile Providers
|
||||||
|
Choose from multiple tile sources for maps:
|
||||||
|
- **OpenStreetMap** - Default, general purpose
|
||||||
|
- **CartoDB Dark** - Dark themed, matches UI
|
||||||
|
- **CartoDB Positron** - Light themed
|
||||||
|
- **ESRI World Imagery** - Satellite imagery
|
||||||
|
- **Custom URL** - Connect to your own tile server (e.g., local OpenStreetMap tile cache)
|
||||||
|
|
||||||
|
### Local Asset Status
|
||||||
|
The settings modal shows availability status for each bundled asset:
|
||||||
|
- Green "Available" badge when asset is present
|
||||||
|
- Red "Missing" badge when asset is not found
|
||||||
|
- Click "Check Assets" to refresh status
|
||||||
|
|
||||||
|
### Use Cases
|
||||||
|
- **Air-gapped environments** - Run on isolated networks
|
||||||
|
- **Field deployments** - Operate without reliable internet
|
||||||
|
- **Local tile servers** - Use pre-cached map tiles for specific regions
|
||||||
|
- **Reduced latency** - Faster loading with local assets
|
||||||
|
|
||||||
## General
|
## General
|
||||||
|
|
||||||
- **Web-based interface** - no desktop app needed
|
- **Web-based interface** - no desktop app needed
|
||||||
|
- **Production server** - gunicorn + gevent via `start.sh` for concurrent SSE/WebSocket handling (falls back to Flask dev server)
|
||||||
- **Live message streaming** via Server-Sent Events (SSE)
|
- **Live message streaming** via Server-Sent Events (SSE)
|
||||||
- **Audio alerts** with mute toggle
|
- **Audio alerts** with mute toggle
|
||||||
- **Message export** to CSV/JSON
|
- **Message export** to CSV/JSON
|
||||||
- **Signal activity meter** and waterfall display
|
- **Signal activity meter** and waterfall display
|
||||||
- **Message logging** to file with timestamps
|
- **Message logging** to file with timestamps
|
||||||
- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF
|
- **HTTPS support** via `INTERCEPT_HTTPS` configuration for secure deployments
|
||||||
|
- **Voice alerts** for configurable event notifications across modes
|
||||||
|
- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
|
||||||
- **Automatic device detection** across all supported hardware
|
- **Automatic device detection** across all supported hardware
|
||||||
- **Hardware-specific validation** - frequency/gain ranges per device type
|
- **Hardware-specific validation** - frequency/gain ranges per device type
|
||||||
|
- **Tool path overrides** via `INTERCEPT_*_PATH` environment variables
|
||||||
|
- **Native Homebrew detection** for Apple Silicon tool paths
|
||||||
- **Configurable gain and PPM correction**
|
- **Configurable gain and PPM correction**
|
||||||
- **Device intelligence** dashboard with tracking
|
- **Device intelligence** dashboard with tracking
|
||||||
- **GPS dongle support** - USB GPS receivers for precise observer location
|
- **GPS dongle support** - USB GPS receivers for precise observer location
|
||||||
|
|||||||
@@ -14,7 +14,39 @@ INTERCEPT automatically detects connected devices.
|
|||||||
|
|
||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
### macOS (Homebrew)
|
### Recommended: Use the Setup Script
|
||||||
|
|
||||||
|
The setup script provides an interactive menu with install profiles for selective installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/smittix/intercept.git
|
||||||
|
cd intercept
|
||||||
|
./setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
On first run, a guided wizard walks you through profile selection:
|
||||||
|
|
||||||
|
| Profile | What it installs |
|
||||||
|
|---------|-----------------|
|
||||||
|
| Core SIGINT | rtl_sdr, multimon-ng, rtl_433, dump1090, acarsdec, dumpvdl2, ffmpeg, gpsd |
|
||||||
|
| Maritime & Radio | AIS-catcher, direwolf |
|
||||||
|
| Weather & Space | SatDump, radiosonde_auto_rx |
|
||||||
|
| RF Security | aircrack-ng, HackRF, BlueZ, hcxtools, Ubertooth, SoapySDR |
|
||||||
|
| Full SIGINT | All of the above |
|
||||||
|
|
||||||
|
For headless/CI installs:
|
||||||
|
```bash
|
||||||
|
./setup.sh --non-interactive # Install everything
|
||||||
|
./setup.sh --profile=core,maritime # Install specific profiles
|
||||||
|
```
|
||||||
|
|
||||||
|
After installation, use the menu to manage your setup:
|
||||||
|
```bash
|
||||||
|
./setup.sh # Opens interactive menu
|
||||||
|
./setup.sh --health-check # Verify installation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Install: macOS (Homebrew)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install Homebrew if needed
|
# Install Homebrew if needed
|
||||||
@@ -36,7 +68,7 @@ brew install soapysdr limesuite soapylms7
|
|||||||
brew install hackrf soapyhackrf
|
brew install hackrf soapyhackrf
|
||||||
```
|
```
|
||||||
|
|
||||||
### Debian / Ubuntu / Raspberry Pi OS
|
### Manual Install: Debian / Ubuntu / Raspberry Pi OS
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Update package lists
|
# Update package lists
|
||||||
@@ -94,6 +126,126 @@ sudo modprobe -r dvb_usb_rtl28xxu
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Multiple RTL-SDR Dongles
|
||||||
|
|
||||||
|
If you're running two (or more) RTL-SDR dongles on the same machine, they ship with the same default serial number so Linux can't tell them apart reliably. Follow these steps to give each a unique identity.
|
||||||
|
|
||||||
|
### Step 1: Blacklist the DVB-T driver
|
||||||
|
|
||||||
|
Already covered above, but make sure this is done first — the kernel's DVB driver will grab the dongles before librtlsdr can:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf
|
||||||
|
sudo modprobe -r dvb_usb_rtl28xxu
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Burn unique serial numbers
|
||||||
|
|
||||||
|
Each dongle has an EEPROM that stores a serial number. By default they're all `00000001`. You need to give each one a unique serial.
|
||||||
|
|
||||||
|
**Plug in only the first dongle**, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtl_eeprom -d 0 -s 00000001
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unplug it, plug in the second dongle**, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtl_eeprom -d 0 -s 00000002
|
||||||
|
```
|
||||||
|
|
||||||
|
> Pick any 8-digit hex serials you like. The `-d 0` means "device index 0" (the only one plugged in).
|
||||||
|
|
||||||
|
Unplug and replug both dongles after writing.
|
||||||
|
|
||||||
|
### Step 3: Verify
|
||||||
|
|
||||||
|
With both plugged in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rtl_test -t
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see:
|
||||||
|
|
||||||
|
```
|
||||||
|
0: Realtek, RTL2838UHIDIR, SN: 00000001
|
||||||
|
1: Realtek, RTL2838UHIDIR, SN: 00000002
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tip:** If you don't know which physical dongle has which serial, unplug one and run `rtl_test -t` — the one still detected is the one still plugged in.
|
||||||
|
|
||||||
|
### Step 4: Udev rules with stable symlinks
|
||||||
|
|
||||||
|
Create rules that give each dongle a persistent name based on its serial:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
|
||||||
|
# RTL-SDR dongles - permissions and stable symlinks by serial
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="2838", MODE="0666"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="2832", MODE="0666"
|
||||||
|
|
||||||
|
# Symlinks by serial — change names/serials to match your hardware
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTRS{serial}=="00000001", SYMLINK+="sdr-dongle1"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTRS{serial}=="00000002", SYMLINK+="sdr-dongle2"
|
||||||
|
EOF'
|
||||||
|
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
sudo udevadm trigger
|
||||||
|
```
|
||||||
|
|
||||||
|
After replugging, you'll have `/dev/sdr-dongle1` and `/dev/sdr-dongle2`.
|
||||||
|
|
||||||
|
### Step 5: USB power (Raspberry Pi)
|
||||||
|
|
||||||
|
Two dongles can draw more current than the Pi allows by default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In /boot/firmware/config.txt, add:
|
||||||
|
usb_max_current_enable=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Disable USB autosuspend so dongles don't get powered off:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In /etc/default/grub or kernel cmdline, add:
|
||||||
|
usbcore.autosuspend=-1
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via udev:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'ACTION=="add", SUBSYSTEM=="usb", ATTR{power/autosuspend}="-1"' | \
|
||||||
|
sudo tee /etc/udev/rules.d/50-usb-autosuspend.rules
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Docker access
|
||||||
|
|
||||||
|
Your `docker-compose.yml` needs privileged mode and USB passthrough:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
intercept:
|
||||||
|
privileged: true
|
||||||
|
volumes:
|
||||||
|
- /dev/bus/usb:/dev/bus/usb
|
||||||
|
```
|
||||||
|
|
||||||
|
INTERCEPT auto-detects both dongles inside the container via `rtl_test -t` and addresses them by device index (`-d 0`, `-d 1`).
|
||||||
|
|
||||||
|
### Quick reference
|
||||||
|
|
||||||
|
| Step | What | Why |
|
||||||
|
|------|------|-----|
|
||||||
|
| Blacklist DVB | `/etc/modprobe.d/blacklist-rtl.conf` | Kernel won't steal the dongles |
|
||||||
|
| Burn serials | `rtl_eeprom -d 0 -s <serial>` | Unique identity per dongle |
|
||||||
|
| Udev rules | `/etc/udev/rules.d/20-rtlsdr.rules` | Permissions + stable `/dev/sdr-*` names |
|
||||||
|
| USB power | `config.txt` + autosuspend off | Enough current for two dongles on a Pi |
|
||||||
|
| Docker | `privileged: true` + USB volume | Container sees both dongles |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Verify Installation
|
## Verify Installation
|
||||||
|
|
||||||
### Check dependencies
|
### Check dependencies
|
||||||
@@ -119,11 +271,19 @@ SoapySDRUtil --find
|
|||||||
./setup.sh
|
./setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This automatically:
|
The setup wizard automatically:
|
||||||
- Detects your OS
|
- Detects your OS (macOS, Debian/Ubuntu, DragonOS)
|
||||||
- Creates a virtual environment if needed (for PEP 668 systems)
|
- Lets you choose install profiles (Core, Maritime, Weather, Security, Full, Custom)
|
||||||
- Installs Python dependencies
|
- Creates a virtual environment with system site-packages
|
||||||
- Checks for required tools
|
- Installs Python dependencies (core + optional)
|
||||||
|
- Runs a health check to verify everything works
|
||||||
|
|
||||||
|
After initial setup, use the menu to manage your environment:
|
||||||
|
- **Install / Add Modules** — add tools you didn't install initially
|
||||||
|
- **System Health Check** — verify all tools and dependencies
|
||||||
|
- **Environment Configurator** — set `INTERCEPT_*` variables interactively
|
||||||
|
- **Update Tools** — rebuild source-built tools (dump1090, SatDump, etc.)
|
||||||
|
- **View Status** — see what's installed at a glance
|
||||||
|
|
||||||
### Manual setup
|
### Manual setup
|
||||||
```bash
|
```bash
|
||||||
@@ -139,14 +299,13 @@ pip install -r requirements.txt
|
|||||||
After installation:
|
After installation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Standard
|
sudo ./start.sh
|
||||||
sudo python3 intercept.py
|
|
||||||
|
|
||||||
# With virtual environment
|
|
||||||
sudo venv/bin/python intercept.py
|
|
||||||
|
|
||||||
# Custom port
|
# Custom port
|
||||||
INTERCEPT_PORT=8080 sudo python3 intercept.py
|
sudo ./start.sh -p 8080
|
||||||
|
|
||||||
|
# HTTPS
|
||||||
|
sudo ./start.sh --https
|
||||||
```
|
```
|
||||||
|
|
||||||
Open **http://localhost:5050** in your browser.
|
Open **http://localhost:5050** in your browser.
|
||||||
@@ -183,6 +342,7 @@ Open **http://localhost:5050** in your browser.
|
|||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `flask` | Web server |
|
| `flask` | Web server |
|
||||||
| `skyfield` | Satellite tracking |
|
| `skyfield` | Satellite tracking |
|
||||||
|
| `bleak` | BLE scanning with manufacturer data (TSCM) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -203,9 +363,57 @@ https://github.com/flightaware/dump1090
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## TSCM Mode Requirements
|
||||||
|
|
||||||
|
TSCM (Technical Surveillance Countermeasures) mode requires specific hardware for full functionality:
|
||||||
|
|
||||||
|
### BLE Scanning (Tracker Detection)
|
||||||
|
- Any Bluetooth adapter supported by your OS
|
||||||
|
- `bleak` Python library for manufacturer data detection
|
||||||
|
- Detects: AirTags, Tile, SmartTags, ESP32/ESP8266 devices
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install bleak
|
||||||
|
pip install bleak>=0.21.0
|
||||||
|
|
||||||
|
# Or via apt (Debian/Ubuntu)
|
||||||
|
sudo apt install python3-bleak
|
||||||
|
```
|
||||||
|
|
||||||
|
### RF Spectrum Analysis
|
||||||
|
- **RTL-SDR dongle** (required for RF sweeps)
|
||||||
|
- `rtl_power` command from `rtl-sdr` package
|
||||||
|
|
||||||
|
Frequency bands scanned:
|
||||||
|
| Band | Frequency | Purpose |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| FM Broadcast | 88-108 MHz | FM bugs |
|
||||||
|
| 315 MHz ISM | 315 MHz | US wireless devices |
|
||||||
|
| 433 MHz ISM | 433-434 MHz | EU wireless devices |
|
||||||
|
| 868 MHz ISM | 868-869 MHz | EU IoT devices |
|
||||||
|
| 915 MHz ISM | 902-928 MHz | US IoT devices |
|
||||||
|
| 1.2 GHz | 1200-1300 MHz | Video transmitters |
|
||||||
|
| 2.4 GHz ISM | 2400-2500 MHz | WiFi/BT/Video |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux
|
||||||
|
sudo apt install rtl-sdr
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
brew install librtlsdr
|
||||||
|
```
|
||||||
|
|
||||||
|
### WiFi Scanning
|
||||||
|
- Standard WiFi adapter (managed mode for basic scanning)
|
||||||
|
- Monitor mode capable adapter for advanced features
|
||||||
|
- `aircrack-ng` suite for monitor mode management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Bluetooth on macOS**: Uses native CoreBluetooth, bluez tools not needed
|
- **Bluetooth on macOS**: Uses bleak library (CoreBluetooth backend), bluez tools not needed
|
||||||
- **WiFi on macOS**: Monitor mode has limited support, full functionality on Linux
|
- **WiFi on macOS**: Monitor mode has limited support, full functionality on Linux
|
||||||
- **System tools**: `iw`, `iwconfig`, `rfkill`, `ip` are pre-installed on most Linux systems
|
- **System tools**: `iw`, `iwconfig`, `rfkill`, `ip` are pre-installed on most Linux systems
|
||||||
|
- **TSCM on macOS**: BLE and WiFi scanning work; RF spectrum requires RTL-SDR
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any net
|
|||||||
echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef -
|
echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef -
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Bind to Localhost**: For local-only access, set the host environment variable:
|
2. **Bind to Localhost**: For local-only access, set the host or use the CLI flag:
|
||||||
```bash
|
```bash
|
||||||
export INTERCEPT_HOST=127.0.0.1
|
sudo ./start.sh -H 127.0.0.1
|
||||||
python intercept.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
|
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ sudo apt install python3-flask python3-requests python3-serial python3-skyfield
|
|||||||
# Then create venv with system packages
|
# Then create venv with system packages
|
||||||
python3 -m venv --system-site-packages venv
|
python3 -m venv --system-site-packages venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
sudo venv/bin/python intercept.py
|
sudo ./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### "error: externally-managed-environment" (pip blocked)
|
### "error: externally-managed-environment" (pip blocked)
|
||||||
@@ -61,18 +61,21 @@ sudo apt install python3.11 python3.11-venv python3-pip
|
|||||||
python3.11 -m venv venv
|
python3.11 -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
sudo venv/bin/python intercept.py
|
sudo ./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Alternative: Use the setup script
|
### Alternative: Use the setup script
|
||||||
|
|
||||||
The setup script handles all installation automatically, including apt packages:
|
The setup script handles all installation automatically, including apt packages and source builds:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x setup.sh
|
./setup.sh # Interactive wizard (first run) or menu
|
||||||
./setup.sh
|
./setup.sh --non-interactive # Headless full install
|
||||||
|
./setup.sh --health-check # Diagnose installation issues
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The setup menu also includes a **System Health Check** (option 2) that verifies all tools, SDR devices, ports, permissions, and Python packages — useful for diagnosing installation problems.
|
||||||
|
|
||||||
### "pip: command not found"
|
### "pip: command not found"
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -336,9 +339,7 @@ rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
|
|||||||
|
|
||||||
Run INTERCEPT with sudo:
|
Run INTERCEPT with sudo:
|
||||||
```bash
|
```bash
|
||||||
sudo python3 intercept.py
|
sudo ./start.sh
|
||||||
# Or with venv:
|
|
||||||
sudo venv/bin/python intercept.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Interface not found after enabling monitor mode
|
### Interface not found after enabling monitor mode
|
||||||
@@ -375,7 +376,14 @@ sudo usermod -a -G bluetooth $USER
|
|||||||
|
|
||||||
### Cannot install dump1090 in Debian (ADS-B mode)
|
### Cannot install dump1090 in Debian (ADS-B mode)
|
||||||
|
|
||||||
On newer Debian versions, dump1090 may not be in repositories. The recommended action is to build from source or use the setup.sh script which will do it for you.
|
On newer Debian versions, dump1090 may not be in repositories. Use the setup script which builds it from source automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./setup.sh # Select Core SIGINT profile, or
|
||||||
|
./setup.sh --profile=core # Install core tools including dump1090
|
||||||
|
```
|
||||||
|
|
||||||
|
The setup menu's **Install / Add Modules** option also lets you install dump1090 individually via the Custom tool checklist.
|
||||||
|
|
||||||
### No aircraft appearing (ADS-B mode)
|
### No aircraft appearing (ADS-B mode)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,617 @@
|
|||||||
|
# iNTERCEPT UI Guide
|
||||||
|
|
||||||
|
This guide documents the UI design system, components, and patterns used in iNTERCEPT.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Design Tokens](#design-tokens)
|
||||||
|
2. [Base Templates](#base-templates)
|
||||||
|
3. [Navigation](#navigation)
|
||||||
|
4. [Components](#components)
|
||||||
|
5. [Adding a New Module Page](#adding-a-new-module-page)
|
||||||
|
6. [Adding a New Dashboard](#adding-a-new-dashboard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Tokens
|
||||||
|
|
||||||
|
All design tokens are defined in `static/css/core/variables.css`. Import this file first in any stylesheet.
|
||||||
|
|
||||||
|
### Colors
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Backgrounds (layered depth) */
|
||||||
|
--bg-primary: #0a0c10; /* Darkest - page background */
|
||||||
|
--bg-secondary: #0f1218; /* Panels, sidebars */
|
||||||
|
--bg-tertiary: #151a23; /* Cards, elevated elements */
|
||||||
|
--bg-card: #121620; /* Card backgrounds */
|
||||||
|
--bg-elevated: #1a202c; /* Hover states, modals */
|
||||||
|
|
||||||
|
/* Accent Colors */
|
||||||
|
--accent-cyan: #4a9eff; /* Primary action color */
|
||||||
|
--accent-green: #22c55e; /* Success, online status */
|
||||||
|
--accent-red: #ef4444; /* Error, danger, stop */
|
||||||
|
--accent-orange: #f59e0b; /* Warning */
|
||||||
|
--accent-amber: #d4a853; /* Secondary highlight */
|
||||||
|
|
||||||
|
/* Text Hierarchy */
|
||||||
|
--text-primary: #e8eaed; /* Main content */
|
||||||
|
--text-secondary: #9ca3af; /* Secondary content */
|
||||||
|
--text-dim: #4b5563; /* Disabled, placeholder */
|
||||||
|
--text-muted: #374151; /* Barely visible */
|
||||||
|
|
||||||
|
/* Status Colors */
|
||||||
|
--status-online: #22c55e;
|
||||||
|
--status-warning: #f59e0b;
|
||||||
|
--status-error: #ef4444;
|
||||||
|
--status-offline: #6b7280;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spacing Scale
|
||||||
|
|
||||||
|
```css
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 32px;
|
||||||
|
--space-10: 40px;
|
||||||
|
--space-12: 48px;
|
||||||
|
--space-16: 64px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Font Families */
|
||||||
|
--font-sans: 'Inter', -apple-system, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
/* Font Sizes */
|
||||||
|
--text-xs: 10px;
|
||||||
|
--text-sm: 12px;
|
||||||
|
--text-base: 14px;
|
||||||
|
--text-lg: 16px;
|
||||||
|
--text-xl: 18px;
|
||||||
|
--text-2xl: 20px;
|
||||||
|
--text-3xl: 24px;
|
||||||
|
--text-4xl: 30px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Border Radius
|
||||||
|
|
||||||
|
```css
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 6px;
|
||||||
|
--radius-lg: 8px;
|
||||||
|
--radius-xl: 12px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Light Theme
|
||||||
|
|
||||||
|
The design system supports light/dark themes via `data-theme` attribute:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<html data-theme="dark"> <!-- or "light" -->
|
||||||
|
```
|
||||||
|
|
||||||
|
Toggle with JavaScript:
|
||||||
|
```javascript
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Base Templates
|
||||||
|
|
||||||
|
### `templates/layout/base.html`
|
||||||
|
|
||||||
|
The main base template for standard pages. Use for pages with sidebar + content layout.
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% extends 'layout/base.html' %}
|
||||||
|
|
||||||
|
{% block title %}My Page Title{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/my-page.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block navigation %}
|
||||||
|
{% set active_mode = 'mymode' %}
|
||||||
|
{% include 'partials/nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block sidebar %}
|
||||||
|
<div class="app-sidebar">
|
||||||
|
<!-- Sidebar content -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-container">
|
||||||
|
<h1>Page Title</h1>
|
||||||
|
<!-- Page content -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Page-specific JavaScript
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `templates/layout/base_dashboard.html`
|
||||||
|
|
||||||
|
Extended base for full-screen dashboards (maps, visualizations).
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% extends 'layout/base_dashboard.html' %}
|
||||||
|
|
||||||
|
{% set active_mode = 'mydashboard' %}
|
||||||
|
|
||||||
|
{% block dashboard_title %}MY DASHBOARD{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
{{ super() }}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/my_dashboard.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block stats_strip %}
|
||||||
|
<div class="stats-strip">
|
||||||
|
<!-- Stats bar content -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block dashboard_content %}
|
||||||
|
<div class="dashboard-map-container">
|
||||||
|
<!-- Main visualization -->
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-sidebar">
|
||||||
|
<!-- Sidebar panels -->
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
### Including Navigation
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% set active_mode = 'pager' %}
|
||||||
|
{% include 'partials/nav.html' %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Valid `active_mode` Values
|
||||||
|
|
||||||
|
| Mode | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `pager` | Pager decoding |
|
||||||
|
| `sensor` | 433MHz sensors |
|
||||||
|
| `rtlamr` | Utility meters |
|
||||||
|
| `adsb` | Aircraft tracking |
|
||||||
|
| `ais` | Vessel tracking |
|
||||||
|
| `aprs` | Amateur radio |
|
||||||
|
| `wifi` | WiFi scanning |
|
||||||
|
| `bluetooth` | Bluetooth scanning |
|
||||||
|
| `tscm` | Counter-surveillance |
|
||||||
|
| `satellite` | Satellite tracking |
|
||||||
|
| `sstv` | ISS SSTV |
|
||||||
|
| `listening` | Listening post |
|
||||||
|
| `spystations` | Spy stations |
|
||||||
|
| `meshtastic` | Mesh networking |
|
||||||
|
| `weathersat` | Weather satellites |
|
||||||
|
| `sstv_general` | HF SSTV |
|
||||||
|
| `gps` | GPS tracking |
|
||||||
|
| `websdr` | WebSDR |
|
||||||
|
| `subghz` | Sub-GHz analyzer |
|
||||||
|
| `bt_locate` | BT Locate |
|
||||||
|
| `wifi_locate` | WiFi Locate |
|
||||||
|
| `analytics` | Analytics dashboard |
|
||||||
|
| `spaceweather` | Space weather |
|
||||||
|
### Navigation Groups
|
||||||
|
|
||||||
|
The navigation is organized into groups:
|
||||||
|
- **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz
|
||||||
|
- **Tracking**: Aircraft, Vessels, APRS, GPS
|
||||||
|
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, Space Weather
|
||||||
|
- **Wireless**: WiFi, Bluetooth, BT Locate, WiFi Locate, Meshtastic
|
||||||
|
- **Intel**: TSCM, Analytics, Spy Stations, WebSDR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Card / Panel
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% call card(title='PANEL TITLE', indicator=true, indicator_active=false) %}
|
||||||
|
<p>Panel content here</p>
|
||||||
|
{% endcall %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually:
|
||||||
|
```html
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>PANEL TITLE</span>
|
||||||
|
<div class="panel-indicator active"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-content">
|
||||||
|
<p>Content here</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Empty State
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% include 'components/empty_state.html' with context %}
|
||||||
|
{# Or with variables: #}
|
||||||
|
{% with title='No data yet', description='Start scanning to see results', action_text='Start Scan', action_onclick='startScan()' %}
|
||||||
|
{% include 'components/empty_state.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading State
|
||||||
|
|
||||||
|
```html
|
||||||
|
{# Inline spinner #}
|
||||||
|
{% include 'components/loading.html' %}
|
||||||
|
|
||||||
|
{# With text #}
|
||||||
|
{% with text='Loading data...', size='lg' %}
|
||||||
|
{% include 'components/loading.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{# Full overlay #}
|
||||||
|
{% with overlay=true, text='Please wait...' %}
|
||||||
|
{% include 'components/loading.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status Badge
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% with status='online', text='Connected', id='connectionStatus' %}
|
||||||
|
{% include 'components/status_badge.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Status values: `online`, `offline`, `warning`, `error`, `inactive`
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Primary action -->
|
||||||
|
<button class="btn btn-primary">Start Tracking</button>
|
||||||
|
|
||||||
|
<!-- Secondary action -->
|
||||||
|
<button class="btn btn-secondary">Cancel</button>
|
||||||
|
|
||||||
|
<!-- Danger action -->
|
||||||
|
<button class="btn btn-danger">Stop</button>
|
||||||
|
|
||||||
|
<!-- Ghost/subtle -->
|
||||||
|
<button class="btn btn-ghost">Settings</button>
|
||||||
|
|
||||||
|
<!-- Sizes -->
|
||||||
|
<button class="btn btn-primary btn-sm">Small</button>
|
||||||
|
<button class="btn btn-primary btn-lg">Large</button>
|
||||||
|
|
||||||
|
<!-- Icon button -->
|
||||||
|
<button class="btn btn-icon btn-secondary">
|
||||||
|
<span class="icon">...</span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badges
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span class="badge">Default</span>
|
||||||
|
<span class="badge badge-primary">Primary</span>
|
||||||
|
<span class="badge badge-success">Online</span>
|
||||||
|
<span class="badge badge-warning">Warning</span>
|
||||||
|
<span class="badge badge-danger">Error</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Groups
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="frequency">Frequency (MHz)</label>
|
||||||
|
<input type="text" id="frequency" value="153.350">
|
||||||
|
<span class="form-help">Enter frequency in MHz</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="gain">Gain</label>
|
||||||
|
<select id="gain">
|
||||||
|
<option value="auto">Auto</option>
|
||||||
|
<option value="30">30 dB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-check">
|
||||||
|
<input type="checkbox" id="alerts">
|
||||||
|
<span>Enable alerts</span>
|
||||||
|
</label>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stats Strip
|
||||||
|
|
||||||
|
Used in dashboards for horizontal statistics display:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="stats-strip">
|
||||||
|
<div class="stats-strip-inner">
|
||||||
|
<div class="strip-stat">
|
||||||
|
<span class="strip-value" id="count">0</span>
|
||||||
|
<span class="strip-label">COUNT</span>
|
||||||
|
</div>
|
||||||
|
<div class="strip-divider"></div>
|
||||||
|
<div class="strip-status">
|
||||||
|
<div class="status-dot active" id="statusDot"></div>
|
||||||
|
<span id="statusText">TRACKING</span>
|
||||||
|
</div>
|
||||||
|
<div class="strip-time" id="utcTime">--:--:-- UTC</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Module Page
|
||||||
|
|
||||||
|
### 1. Create the Route
|
||||||
|
|
||||||
|
In `routes/mymodule.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
|
mymodule_bp = Blueprint('mymodule', __name__, url_prefix='/mymodule')
|
||||||
|
|
||||||
|
@mymodule_bp.route('/dashboard')
|
||||||
|
def dashboard():
|
||||||
|
return render_template('mymodule_dashboard.html',
|
||||||
|
offline_settings=get_offline_settings())
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Register the Blueprint
|
||||||
|
|
||||||
|
In `routes/__init__.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from routes.mymodule import mymodule_bp
|
||||||
|
app.register_blueprint(mymodule_bp)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create the Template
|
||||||
|
|
||||||
|
Option A: Simple page extending base.html
|
||||||
|
```html
|
||||||
|
{% extends 'layout/base.html' %}
|
||||||
|
{% set active_mode = 'mymodule' %}
|
||||||
|
|
||||||
|
{% block title %}My Module{% endblock %}
|
||||||
|
|
||||||
|
{% block navigation %}
|
||||||
|
{% include 'partials/nav.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Your content -->
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Option B: Full-screen dashboard
|
||||||
|
```html
|
||||||
|
{% extends 'layout/base_dashboard.html' %}
|
||||||
|
{% set active_mode = 'mymodule' %}
|
||||||
|
|
||||||
|
{% block dashboard_title %}MY MODULE{% endblock %}
|
||||||
|
|
||||||
|
{% block dashboard_content %}
|
||||||
|
<!-- Your dashboard content -->
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Add to Navigation
|
||||||
|
|
||||||
|
In `templates/partials/nav.html`, add your module to the appropriate group:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
|
||||||
|
onclick="switchMode('mymodule')">
|
||||||
|
<span class="nav-icon icon"><!-- SVG icon --></span>
|
||||||
|
<span class="nav-label">My Module</span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if it's a dashboard link:
|
||||||
|
```html
|
||||||
|
<a href="/mymodule/dashboard"
|
||||||
|
class="mode-nav-btn {% if active_mode == 'mymodule' %}active{% endif %}"
|
||||||
|
style="text-decoration: none;">
|
||||||
|
<span class="nav-icon icon"><!-- SVG icon --></span>
|
||||||
|
<span class="nav-label">My Module</span>
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Create Stylesheet
|
||||||
|
|
||||||
|
In `static/css/mymodule.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/**
|
||||||
|
* My Module Styles
|
||||||
|
*/
|
||||||
|
@import url('./core/variables.css');
|
||||||
|
|
||||||
|
/* Your styles using design tokens */
|
||||||
|
.mymodule-container {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Dashboard
|
||||||
|
|
||||||
|
For full-screen dashboards like ADSB, AIS, or Satellite:
|
||||||
|
|
||||||
|
### 1. Create the Template
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MY DASHBOARD // iNTERCEPT</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||||
|
|
||||||
|
<!-- Design tokens (required) -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
|
{% else %}
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- External libraries if needed -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
|
||||||
|
<!-- Dashboard styles -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/mydashboard.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Background effects -->
|
||||||
|
<div class="radar-bg"></div>
|
||||||
|
<div class="scanline"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<a href="/" style="color: inherit; text-decoration: none;">
|
||||||
|
MY DASHBOARD
|
||||||
|
<span>// iNTERCEPT</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="status-bar">
|
||||||
|
<a href="#" onclick="history.back(); return false;" class="back-link">Back</a>
|
||||||
|
<a href="/" class="back-link">Main Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Unified Navigation -->
|
||||||
|
{% set active_mode = 'mydashboard' %}
|
||||||
|
{% include 'partials/nav.html' %}
|
||||||
|
|
||||||
|
<!-- Stats Strip -->
|
||||||
|
<div class="stats-strip">
|
||||||
|
<!-- Stats content -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Dashboard Content -->
|
||||||
|
<main class="dashboard">
|
||||||
|
<!-- Your dashboard layout -->
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Dashboard JavaScript
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create the Stylesheet
|
||||||
|
|
||||||
|
```css
|
||||||
|
/**
|
||||||
|
* My Dashboard Styles
|
||||||
|
*/
|
||||||
|
@import url('./core/variables.css');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Dashboard-specific aliases */
|
||||||
|
--bg-dark: var(--bg-primary);
|
||||||
|
--bg-panel: var(--bg-secondary);
|
||||||
|
--bg-card: var(--bg-tertiary);
|
||||||
|
--grid-line: rgba(74, 158, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Your dashboard styles */
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### DO
|
||||||
|
|
||||||
|
- Use design tokens for all colors, spacing, and typography
|
||||||
|
- Include the nav partial on all pages for consistent navigation
|
||||||
|
- Set `active_mode` before including the nav partial
|
||||||
|
- Use semantic component classes (`btn`, `panel`, `badge`, etc.)
|
||||||
|
- Support both light and dark themes
|
||||||
|
- Test on mobile viewports
|
||||||
|
|
||||||
|
### DON'T
|
||||||
|
|
||||||
|
- Hardcode color values - use CSS variables
|
||||||
|
- Create new color variations without adding to tokens
|
||||||
|
- Duplicate navigation markup - use the partial
|
||||||
|
- Skip the favicon and design tokens imports
|
||||||
|
- Use inline styles for layout (use utility classes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
templates/
|
||||||
|
├── layout/
|
||||||
|
│ ├── base.html # Standard page base
|
||||||
|
│ └── base_dashboard.html # Dashboard page base
|
||||||
|
├── partials/
|
||||||
|
│ ├── nav.html # Unified navigation
|
||||||
|
│ ├── page_header.html # Page title component
|
||||||
|
│ └── settings-modal.html # Settings modal
|
||||||
|
├── components/
|
||||||
|
│ ├── card.html # Panel/card component
|
||||||
|
│ ├── empty_state.html # Empty state placeholder
|
||||||
|
│ ├── loading.html # Loading spinner
|
||||||
|
│ ├── stats_strip.html # Stats bar component
|
||||||
|
│ └── status_badge.html # Status indicator
|
||||||
|
├── index.html # Main dashboard
|
||||||
|
├── adsb_dashboard.html # Aircraft tracking
|
||||||
|
├── ais_dashboard.html # Vessel tracking
|
||||||
|
└── satellite_dashboard.html # Satellite tracking
|
||||||
|
|
||||||
|
static/css/
|
||||||
|
├── core/
|
||||||
|
│ ├── variables.css # Design tokens
|
||||||
|
│ ├── base.css # Reset & typography
|
||||||
|
│ ├── components.css # Component styles
|
||||||
|
│ └── layout.css # Layout styles
|
||||||
|
├── index.css # Main dashboard styles
|
||||||
|
├── adsb_dashboard.css # Aircraft dashboard
|
||||||
|
├── ais_dashboard.css # Vessel dashboard
|
||||||
|
├── satellite_dashboard.css # Satellite dashboard
|
||||||
|
└── responsive.css # Responsive breakpoints
|
||||||
|
```
|
||||||
@@ -57,6 +57,48 @@ INTERCEPT automatically detects known trackers:
|
|||||||
- Samsung SmartTag
|
- Samsung SmartTag
|
||||||
- Chipolo
|
- Chipolo
|
||||||
|
|
||||||
|
## Sub-GHz Analyzer
|
||||||
|
|
||||||
|
1. **Connect HackRF** - Plug in your HackRF One device
|
||||||
|
2. **Set Frequency** - Enter a frequency in the 300-928 MHz ISM range or use a preset
|
||||||
|
3. **Start Capture** - Click "Start Capture" to begin signal analysis
|
||||||
|
4. **View Spectrum** - Real-time spectrum visualization of the selected band
|
||||||
|
5. **Protocol Decoding** - Identified protocols are displayed with decoded data
|
||||||
|
|
||||||
|
### Supported Protocols
|
||||||
|
|
||||||
|
Common ISM band protocols including garage doors, key fobs, weather stations, and IoT devices in the 300-928 MHz range.
|
||||||
|
|
||||||
|
## VDL2 (Aircraft Datalink)
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Select Device** - Choose your SDR device
|
||||||
|
3. **Set Frequencies** - Default VDL2 frequencies are pre-configured (136.975, 136.725, 136.775 MHz etc.)
|
||||||
|
4. **Start Decoding** - Click "Start" to begin VDL2 reception via dumpvdl2
|
||||||
|
5. **View Messages** - AVLC frames appear with source/destination, signal levels, and decoded content
|
||||||
|
6. **Inspect Details** - Click a message to view full AVLC frame details and raw JSON
|
||||||
|
7. **Export** - Use CSV or JSON export buttons to save captured messages
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- VDL2 is most active near airports and along flight corridors
|
||||||
|
- Multiple frequencies can be monitored simultaneously for better coverage
|
||||||
|
- VDL2 data is also accessible from the ADS-B dashboard
|
||||||
|
|
||||||
|
## Listening Post
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Set Frequency Range** - Define start and end frequencies for scanning
|
||||||
|
3. **Start Scanning** - Click "Start Scan" for wideband sweep
|
||||||
|
4. **View Signals** - Discovered signals are listed with frequency and SNR
|
||||||
|
5. **Tune In** - Click a signal to tune the audio demodulator
|
||||||
|
6. **Listen** - Real-time audio plays in your browser
|
||||||
|
|
||||||
|
### Demodulation Modes
|
||||||
|
|
||||||
|
- **FM** - Narrowband and wideband FM
|
||||||
|
- **SSB** - Upper and lower sideband for amateur radio and shortwave
|
||||||
|
|
||||||
## Aircraft Mode (ADS-B)
|
## Aircraft Mode (ADS-B)
|
||||||
|
|
||||||
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
|
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
|
||||||
@@ -65,6 +107,8 @@ INTERCEPT automatically detects known trackers:
|
|||||||
- **Manual Entry** - Type coordinates directly
|
- **Manual Entry** - Type coordinates directly
|
||||||
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
|
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
|
||||||
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
|
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
|
||||||
|
- **Shared Location** - By default, the observer location is shared across modules
|
||||||
|
(disable with `INTERCEPT_SHARED_OBSERVER_LOCATION=false`)
|
||||||
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
|
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
|
||||||
5. **View Map** - Aircraft appear on the interactive Leaflet map
|
5. **View Map** - Aircraft appear on the interactive Leaflet map
|
||||||
6. **Click Aircraft** - Click markers for detailed information
|
6. **Click Aircraft** - Click markers for detailed information
|
||||||
@@ -72,6 +116,9 @@ INTERCEPT automatically detects known trackers:
|
|||||||
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
|
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
|
||||||
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
|
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
|
||||||
|
|
||||||
|
> Note: ADS-B auto-start is disabled by default. To enable auto-start on dashboard load,
|
||||||
|
> set `INTERCEPT_ADSB_AUTO_START=true`.
|
||||||
|
|
||||||
### Emergency Squawks
|
### Emergency Squawks
|
||||||
|
|
||||||
The system highlights aircraft transmitting emergency squawks:
|
The system highlights aircraft transmitting emergency squawks:
|
||||||
@@ -79,6 +126,85 @@ The system highlights aircraft transmitting emergency squawks:
|
|||||||
- **7600** - Radio failure
|
- **7600** - Radio failure
|
||||||
- **7700** - General emergency
|
- **7700** - General emergency
|
||||||
|
|
||||||
|
## ACARS Messaging
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Select Device** - Choose your SDR device
|
||||||
|
3. **Select Region** - Choose North America, Europe, or Asia-Pacific to auto-populate frequencies
|
||||||
|
4. **Select Frequencies** - Check one or more ACARS frequencies (131.550 MHz primary worldwide, 130.025 MHz secondary USA/Canada, etc.)
|
||||||
|
5. **Adjust Gain** - Set gain (0 for auto, or 0-50 dB)
|
||||||
|
6. **Start Decoding** - Click "Start" to begin ACARS reception via acarsdec
|
||||||
|
7. **View Messages** - Aircraft messages appear in real-time with flight ID, registration, and content
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- A vertical polarization antenna works best for ACARS
|
||||||
|
- Quarter-wave dipole: 57 cm per element at 130 MHz
|
||||||
|
- Stock SDR antenna may work at close range near airports
|
||||||
|
- Outdoor placement with clear sky view significantly improves reception
|
||||||
|
|
||||||
|
## ADS-B History (Optional)
|
||||||
|
|
||||||
|
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
||||||
|
|
||||||
|
### Enable History
|
||||||
|
|
||||||
|
Set the following environment variables (Docker recommended):
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
|
||||||
|
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
|
||||||
|
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
|
||||||
|
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
|
||||||
|
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
|
||||||
|
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
|
||||||
|
|
||||||
|
### Other ADS-B Settings
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
|
||||||
|
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
|
||||||
|
|
||||||
|
**Local install example**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INTERCEPT_ADSB_AUTO_START=true \
|
||||||
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||||
|
sudo ./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker example (.env)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
INTERCEPT_ADSB_AUTO_START=true
|
||||||
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Setup
|
||||||
|
|
||||||
|
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile history up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using the History Dashboard
|
||||||
|
|
||||||
|
1. Open **/adsb/history**
|
||||||
|
2. Use **Start Tracking** to run ADS-B in headless mode
|
||||||
|
3. View aircraft history and timelines
|
||||||
|
4. Stop tracking when desired (session history is recorded)
|
||||||
|
|
||||||
|
If the History dashboard shows **HISTORY DISABLED**, enable `INTERCEPT_ADSB_HISTORY_ENABLED=true` and ensure Postgres is running.
|
||||||
|
|
||||||
## Satellite Mode
|
## Satellite Mode
|
||||||
|
|
||||||
1. **Set Location** - Choose location source:
|
1. **Set Location** - Choose location source:
|
||||||
@@ -98,6 +224,494 @@ The system highlights aircraft transmitting emergency squawks:
|
|||||||
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
|
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
|
||||||
4. Select satellites to add
|
4. Select satellites to add
|
||||||
|
|
||||||
|
## Weather Satellites
|
||||||
|
|
||||||
|
1. **Set Location** - Enter observer coordinates or use GPS
|
||||||
|
2. **Select Satellite** - Choose NOAA (APT) or Meteor (LRPT)
|
||||||
|
3. **View Passes** - Upcoming passes shown with polar plot and ground track
|
||||||
|
4. **Start Capture** - Click "Start Capture" when a satellite is overhead, or enable auto-scheduler
|
||||||
|
5. **View Images** - Decoded imagery appears in the gallery
|
||||||
|
|
||||||
|
### Auto-Scheduler
|
||||||
|
|
||||||
|
Enable the auto-scheduler to automatically capture passes:
|
||||||
|
- Calculates upcoming NOAA and Meteor passes for your location
|
||||||
|
- Starts SatDump at the correct time and frequency
|
||||||
|
- Decoded images are saved with timestamps
|
||||||
|
|
||||||
|
## Space Weather
|
||||||
|
|
||||||
|
1. **Switch to Space Weather mode** - Select "Space Weather" from the Space navigation group
|
||||||
|
2. **View Dashboard** - Solar indices, NOAA scales, band conditions, and charts load automatically
|
||||||
|
3. **Solar Imagery** - Toggle between SDO 193A, 304A, and Magnetogram views
|
||||||
|
4. **D-RAP Maps** - Select frequency (5-30 MHz) to view HF radio absorption maps
|
||||||
|
5. **Aurora Forecast** - View the OVATION aurora oval for the northern hemisphere
|
||||||
|
6. **Alerts** - Review current SWPC space weather alerts and warnings
|
||||||
|
7. **Active Regions** - View solar active region data (number, location, area)
|
||||||
|
8. **Refresh** - Data auto-refreshes every 5 minutes, or click "Refresh Now"
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- No SDR hardware required — all data comes from public APIs (NOAA SWPC, NASA SDO, HamQSL)
|
||||||
|
- Check HF band conditions before operating on shortwave frequencies
|
||||||
|
- Kp >= 5 indicates geomagnetic storm conditions that may affect HF propagation
|
||||||
|
- D-RAP maps show where HF absorption is highest — useful for path planning
|
||||||
|
- Solar imagery updates approximately every 15 minutes from NASA SDO
|
||||||
|
|
||||||
|
## AIS Vessel Tracking
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Start Tracking** - Click "Start Tracking" to monitor AIS frequencies (161.975/162.025 MHz)
|
||||||
|
3. **View Map** - Vessels appear on the interactive maritime map
|
||||||
|
4. **Click Vessels** - View name, MMSI, callsign, destination, speed, heading
|
||||||
|
5. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated maritime view
|
||||||
|
|
||||||
|
### VHF DSC Channel 70
|
||||||
|
|
||||||
|
Digital Selective Calling monitoring runs alongside AIS:
|
||||||
|
- Distress, Urgency, Safety, and Routine messages
|
||||||
|
- Distress positions plotted with pulsing alert markers
|
||||||
|
- Audio alerts for critical messages
|
||||||
|
|
||||||
|
## WebSDR
|
||||||
|
|
||||||
|
1. **Set Frequency** - Enter a frequency in kHz (e.g., 6500 for 6.5 MHz)
|
||||||
|
2. **Select Mode** - Choose demodulation mode (USB, LSB, AM, CW)
|
||||||
|
3. **Find Receivers** - Click "Find Receivers" to discover available KiwiSDR nodes worldwide
|
||||||
|
4. **Select Receiver** - Click a receiver from the list to connect
|
||||||
|
5. **Listen** - Audio streams in real-time via WebSocket
|
||||||
|
6. **Adjust Volume** - Use the volume slider and monitor the S-meter
|
||||||
|
7. **Spy Station Presets** - Use the quick-tune buttons to jump to known number station frequencies
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Requires an internet connection to access the KiwiSDR network
|
||||||
|
- Receiver list is cached for 1 hour to reduce API load
|
||||||
|
- Receivers are sorted by distance from your location
|
||||||
|
- Integrated spy station presets allow quick tuning to SIGINT targets
|
||||||
|
|
||||||
|
## ISS SSTV
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Select Device** - Choose your SDR device
|
||||||
|
3. **Set Frequency** - Default is 145.800 MHz (ISS downlink)
|
||||||
|
4. **Set Location** - Enter lat/lon for Doppler correction and pass prediction
|
||||||
|
5. **Update TLE** - Click "Update TLE" to fetch latest ISS orbital elements
|
||||||
|
6. **Wait for Pass** - The next pass countdown shows when ISS will be overhead
|
||||||
|
7. **Start Decoding** - Click "Start" to begin SSTV reception
|
||||||
|
8. **View Images** - Decoded SSTV images appear in the gallery with timestamps
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- A V-dipole or better antenna is required (stock antenna will not work)
|
||||||
|
- V-dipole construction: 51 cm per element at 145.8 MHz, 120-degree angle between elements
|
||||||
|
- ISS SSTV events occur during special anniversaries and missions — check ARISS for schedules
|
||||||
|
- Best passes have elevation > 30 degrees above horizon
|
||||||
|
- Doppler shift tracking dramatically improves reception quality
|
||||||
|
- Common SSTV modes: PD120, PD180, Martin1, Scottie1
|
||||||
|
- Outdoor antenna placement with clear sky view is essential
|
||||||
|
|
||||||
|
## HF SSTV
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Select Device** - Choose your SDR device
|
||||||
|
3. **Select Frequency** - Choose from 13 preset frequencies or enter a custom one
|
||||||
|
4. **Modulation** - Auto-selected based on frequency (USB for HF, FM for VHF/UHF)
|
||||||
|
5. **Start Decoding** - Click "Start" to begin SSTV reception
|
||||||
|
6. **View Images** - Decoded amateur radio images appear in the gallery
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- HF frequencies (3-30 MHz) require an upconverter with RTL-SDR
|
||||||
|
- VHF/UHF frequencies (145 MHz, 433 MHz) work directly with RTL-SDR
|
||||||
|
- Most popular frequency: 14.230 MHz USB (20m band) with regular activity
|
||||||
|
- Weekend activity peaks on most HF bands
|
||||||
|
- Amateur license is not required to receive (listen-only)
|
||||||
|
|
||||||
|
## APRS
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Set Frequency** - Defaults to regional APRS frequency (144.390 MHz NA, 144.800 MHz EU)
|
||||||
|
3. **Start Decoding** - Click "Start Decoding" to begin packet radio reception via direwolf
|
||||||
|
4. **View Map** - Station positions appear on the interactive map
|
||||||
|
5. **View Messages** - Position reports, telemetry, and messages displayed in real time
|
||||||
|
|
||||||
|
## Utility Meters
|
||||||
|
|
||||||
|
1. **Start Monitoring** - Click "Start" to begin meter broadcast reception via rtl_amr
|
||||||
|
2. **View Meters** - Decoded meter data appears with meter ID, type, and consumption
|
||||||
|
3. **Filter** - Filter by meter type (electric, gas, water) or meter ID
|
||||||
|
|
||||||
|
## BT Locate (SAR Device Location)
|
||||||
|
|
||||||
|
1. **Set Target** - Enter one or more target identifiers:
|
||||||
|
- **MAC Address** - Exact Bluetooth address (AA:BB:CC:DD:EE:FF)
|
||||||
|
- **Name Pattern** - Substring match (e.g., "iPhone", "Galaxy")
|
||||||
|
- **IRK** - 32-character hex Identity Resolving Key for RPA resolution
|
||||||
|
- **Detect IRKs** - Click "Detect" to auto-extract IRKs from paired devices
|
||||||
|
2. **Choose Environment** - Select the RF environment preset:
|
||||||
|
- **Open Field** (n=2.0) - Best for open areas with line-of-sight
|
||||||
|
- **Outdoor** (n=2.2) - Default, works well in most outdoor settings
|
||||||
|
- **Indoor** (n=3.0) - For buildings with walls and obstacles
|
||||||
|
3. **Start Locate** - Click "Start Locate" to begin tracking
|
||||||
|
4. **Monitor HUD** - The proximity display shows:
|
||||||
|
- Proximity band (IMMEDIATE / NEAR / FAR)
|
||||||
|
- Estimated distance in meters
|
||||||
|
- Raw RSSI and smoothed RSSI average
|
||||||
|
- Detection count and GPS-tagged points
|
||||||
|
5. **Follow the Signal** - Move towards stronger signal (higher RSSI / closer distance)
|
||||||
|
6. **Audio Alerts** - Enable audio for proximity tones that increase in pitch as you get closer
|
||||||
|
7. **Review Trail** - Check the map for GPS-tagged detection trail
|
||||||
|
|
||||||
|
### Hand-off from Bluetooth Mode
|
||||||
|
|
||||||
|
1. Open Bluetooth scanning mode and find the target device
|
||||||
|
2. Click the "Locate" button on the device card
|
||||||
|
3. BT Locate opens with the device pre-filled
|
||||||
|
4. Click "Start Locate" to begin tracking
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- For devices with address randomization (iPhones, modern Android), use the IRK method
|
||||||
|
- Click "Detect" next to the IRK field to auto-extract IRKs from paired devices
|
||||||
|
- The RSSI chart shows signal trend over time — use it to determine if you're getting closer
|
||||||
|
- Clear the trail when starting a new search area
|
||||||
|
|
||||||
|
## WiFi Locate Mode
|
||||||
|
|
||||||
|
1. **Set Target** - Enter a BSSID (MAC address) in AA:BB:CC:DD:EE:FF format, or hand off from WiFi mode
|
||||||
|
2. **Choose Environment** - Select the RF environment preset:
|
||||||
|
- **Open Field** (n=2.0) - Best for open areas with line-of-sight
|
||||||
|
- **Outdoor** (n=2.8) - Default, works well in most outdoor settings
|
||||||
|
- **Indoor** (n=3.5) - For buildings with walls and obstacles
|
||||||
|
3. **Start Locate** - Click "Start Locate" to begin tracking
|
||||||
|
4. **Monitor Signal** - The HUD shows:
|
||||||
|
- Large dBm reading with color coding (green/yellow/red)
|
||||||
|
- 20-segment signal bar for quick visual reference
|
||||||
|
- Estimated distance based on path loss model
|
||||||
|
- RSSI history chart for trend analysis
|
||||||
|
- Current/min/max/average statistics
|
||||||
|
5. **Follow the Signal** - Move towards stronger signal (higher RSSI / closer distance)
|
||||||
|
6. **Audio Alerts** - Enable audio for proximity tones that speed up as signal strengthens
|
||||||
|
|
||||||
|
### Hand-off from WiFi Mode
|
||||||
|
|
||||||
|
1. Open WiFi scanning mode and start a deep scan
|
||||||
|
2. Click any network to open the detail drawer
|
||||||
|
3. Click the "Locate" button in the drawer header
|
||||||
|
4. WiFi Locate opens with the BSSID and SSID pre-filled
|
||||||
|
5. Click "Start Locate" to begin tracking
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Deep scan is required for continuous RSSI updates — WiFi Locate auto-starts it if needed
|
||||||
|
- The WiFi scan is preserved when switching between WiFi and WiFi Locate modes
|
||||||
|
- Signal lost overlay appears after 30 seconds without an update from the target
|
||||||
|
- The distance estimate is approximate — environment preset significantly affects accuracy
|
||||||
|
- Indoor environments with walls attenuate signal more than open field
|
||||||
|
|
||||||
|
## GPS Mode
|
||||||
|
|
||||||
|
1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking
|
||||||
|
2. **View Map** - Your position appears on the interactive map with a track trail
|
||||||
|
3. **Monitor Stats** - Speed, heading, altitude, and satellite count displayed in real-time
|
||||||
|
4. **Record Track** - Enable track recording to save your path
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Ensure gpsd is running: `sudo gpsd /dev/ttyUSB0 -F /var/run/gpsd.sock`
|
||||||
|
- GPS fix may take 30-60 seconds after cold start
|
||||||
|
- Accuracy improves with more satellites in view
|
||||||
|
|
||||||
|
## TSCM (Counter-Surveillance)
|
||||||
|
|
||||||
|
1. **Select Sweep Type** - Choose from Quick Scan (2 min), Standard (5 min), Full Sweep (15 min), or presets for Wireless Cameras, Body-Worn Devices, or GPS Trackers
|
||||||
|
2. **Select Scan Sources** - Toggle WiFi, Bluetooth, and/or RF/SDR scanning and select the appropriate interfaces
|
||||||
|
3. **Select Baseline** - Optionally choose a previously recorded baseline to compare against
|
||||||
|
4. **Start Sweep** - Click "Start Sweep" to begin scanning
|
||||||
|
5. **Review Results** - Detected devices are classified and scored by threat level
|
||||||
|
6. **Record Baseline** - In a known clean environment, record a baseline for future comparison
|
||||||
|
7. **Export Report** - Generate PDF report, JSON annex, or CSV data
|
||||||
|
|
||||||
|
### Threat Levels
|
||||||
|
|
||||||
|
- **Informational (0-2)** - Known or expected devices
|
||||||
|
- **Needs Review (3-5)** - Unusual devices requiring assessment
|
||||||
|
- **High Interest (6+)** - Multiple indicators warrant investigation
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Record a baseline in a known clean environment before conducting sweeps
|
||||||
|
- Use the meeting window feature to flag new RF signatures during sensitive periods
|
||||||
|
- Full functionality requires WiFi adapter, Bluetooth adapter, and SDR hardware
|
||||||
|
- Threat detection uses a database of 47K+ known tracker fingerprints
|
||||||
|
|
||||||
|
## Drone Intelligence
|
||||||
|
|
||||||
|
1. **Open Mode** - Select "Drone Intel" from the Intel group in the navigation bar
|
||||||
|
2. **Configure Interfaces** - Enter your WiFi interface name (must support monitor mode) for Remote ID detection
|
||||||
|
3. **Set RTL-SDR Index** - If you have multiple RTL-SDR devices, enter the device index (default: 0)
|
||||||
|
4. **Start** - Click "Start Scan" to activate all available detection vectors simultaneously
|
||||||
|
5. **Monitor Contacts** - Detected drone contacts appear in the contact list with ID, vectors, risk level, and last seen time
|
||||||
|
6. **View Map** - Contacts with GPS data from Remote ID are plotted on the live map
|
||||||
|
|
||||||
|
### Detection Vectors
|
||||||
|
|
||||||
|
- **Remote ID (WiFi/BLE)** — Passive sniff of 802.11 beacon frames and BLE advertisements. Decodes ASTM F3411 payloads: drone GPS, operator ID, drone type, speed, altitude, and emergency status
|
||||||
|
- **433/868 MHz RF** — RTL-SDR scans ISM bands for drone control link and telemetry RF signatures
|
||||||
|
- **2.4/5.8 GHz** — HackRF (if present) sweeps video downlink bands for active drone transmissions
|
||||||
|
|
||||||
|
### Risk Levels
|
||||||
|
|
||||||
|
- **High** — Drone operating without Remote ID (non-compliant) or malformed ASTM frame. Warrants immediate attention.
|
||||||
|
- **Medium** — Contact detected on multiple RF vectors, or significant RSSI difference between vectors (>15 dB). May indicate evasion or multi-radio platform.
|
||||||
|
- **Low** — Compliant Remote ID broadcast, single detection vector. Standard consumer drone.
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Remote ID is mandatory for drones >250g in the US (FAA) and EU (EU 2019/945) — absence of Remote ID is itself a significant indicator
|
||||||
|
- WiFi adapter must support monitor mode; run `airmon-ng check kill` if other processes interfere
|
||||||
|
- The contact map only shows drones that broadcast GPS coordinates via Remote ID
|
||||||
|
- Contacts expire after 120 seconds of inactivity — the list shows only currently active drones
|
||||||
|
- HackRF detection is passive (receive-only); no transmission occurs
|
||||||
|
|
||||||
|
## Spy Stations
|
||||||
|
|
||||||
|
1. **Browse Database** - View the full list of documented number stations and diplomatic networks
|
||||||
|
2. **Filter by Type** - Toggle between Number Stations and Diplomatic Networks
|
||||||
|
3. **Filter by Country** - Select specific countries (Russia, Cuba, Israel, Poland, etc.)
|
||||||
|
4. **Filter by Mode** - Filter by demodulation mode (USB, AM, CW, OFDM)
|
||||||
|
5. **View Details** - Click "Details" on a station card for full information
|
||||||
|
6. **Tune In** - Click "Tune In" to route the station frequency to the Listening Post or WebSDR
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Data sourced from priyom.org (non-profit monitoring community)
|
||||||
|
- Most activity is on HF bands (3-30 MHz) — propagation varies by time of day
|
||||||
|
- Notable stations: UVB-76 "The Buzzer" (4625 kHz), E06 English Man, HM01 Cuban Numbers
|
||||||
|
- Legal to monitor in most countries (check local regulations)
|
||||||
|
- No decryption or content decoding is included — this is a reference database
|
||||||
|
|
||||||
|
## Meshtastic
|
||||||
|
|
||||||
|
1. **Connect Device** - Plug in a Meshtastic device via USB or connect via TCP
|
||||||
|
2. **Start** - Click "Start" to connect to the mesh network
|
||||||
|
3. **View Messages** - Real-time message stream from the mesh
|
||||||
|
4. **View Nodes** - Connected nodes displayed with signal metrics (RSSI, SNR)
|
||||||
|
5. **Send Messages** - Type messages to broadcast on the mesh
|
||||||
|
|
||||||
|
## Offline Mode
|
||||||
|
|
||||||
|
1. **Open Settings** - Click the gear icon in the navigation bar
|
||||||
|
2. **Offline Tab** - Toggle "Offline Mode" to enable local assets
|
||||||
|
3. **Configure Sources** - Switch assets and fonts from CDN to local
|
||||||
|
4. **Set Tile Provider** - Choose a map tile provider or enter a custom tile server URL
|
||||||
|
5. **Check Assets** - Click "Check Assets" to verify all local files are present
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Download required assets: Leaflet JS/CSS, Chart.js, Inter and JetBrains Mono fonts
|
||||||
|
- Assets are stored in the `static/vendor/` directory
|
||||||
|
- For maps, you need a local tile server (e.g., self-hosted OpenStreetMap tiles)
|
||||||
|
- Missing assets fail gracefully with console warnings
|
||||||
|
- Useful for air-gapped environments, field deployments, or reducing latency
|
||||||
|
|
||||||
|
## Remote Agents (Distributed SIGINT)
|
||||||
|
|
||||||
|
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||||
|
|
||||||
|
### Setting Up an Agent
|
||||||
|
|
||||||
|
1. **Install INTERCEPT** on the remote machine
|
||||||
|
2. **Create config file** (`intercept_agent.cfg`):
|
||||||
|
```ini
|
||||||
|
[agent]
|
||||||
|
name = sensor-node-1
|
||||||
|
port = 8020
|
||||||
|
|
||||||
|
[controller]
|
||||||
|
url = http://192.168.1.100:5050
|
||||||
|
api_key = your-secret-key
|
||||||
|
push_enabled = true
|
||||||
|
|
||||||
|
[modes]
|
||||||
|
pager = true
|
||||||
|
sensor = true
|
||||||
|
adsb = true
|
||||||
|
```
|
||||||
|
3. **Start the agent**:
|
||||||
|
```bash
|
||||||
|
python intercept_agent.py --config intercept_agent.cfg
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering Agents in the Controller
|
||||||
|
|
||||||
|
1. Navigate to `/controller/manage` in the main INTERCEPT instance
|
||||||
|
2. Enter agent details:
|
||||||
|
- **Name**: Must match config file (e.g., `sensor-node-1`)
|
||||||
|
- **Base URL**: Agent address (e.g., `http://192.168.1.50:8020`)
|
||||||
|
- **API Key**: Must match config file
|
||||||
|
3. Click "Register Agent"
|
||||||
|
4. Use "Test" to verify connectivity
|
||||||
|
|
||||||
|
### Using Remote Agents
|
||||||
|
|
||||||
|
Once registered, agents appear in mode dropdowns:
|
||||||
|
|
||||||
|
1. **Select agent** from the dropdown in supported modes
|
||||||
|
2. **Start mode** - Commands are proxied to the remote agent
|
||||||
|
3. **View data** - Data streams back to your browser via SSE
|
||||||
|
|
||||||
|
### Multi-Agent Streaming
|
||||||
|
|
||||||
|
Enable "Show All Agents" to aggregate data from all registered agents simultaneously.
|
||||||
|
|
||||||
|
For complete documentation, see [Distributed Agents Guide](DISTRIBUTED_AGENTS.md).
|
||||||
|
|
||||||
|
## Webhooks & Notifications
|
||||||
|
|
||||||
|
INTERCEPT has a built-in alert engine that fires webhooks when decoded events match configurable rules. This lets you forward pager messages (or events from any other mode) to Discord, Slack, n8n, Home Assistant, or any HTTP endpoint.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
1. You configure **alert rules** via the Alerts UI — each rule defines which mode and event type to watch, optional match criteria, and a severity level.
|
||||||
|
2. When an incoming event matches a rule, INTERCEPT stores it in the alert log and POSTs a JSON payload to your configured webhook URL.
|
||||||
|
3. All modes are supported: pager, sensor, ADS-B, AIS, ACARS, WiFi, Bluetooth, and more.
|
||||||
|
|
||||||
|
### Enable the webhook
|
||||||
|
|
||||||
|
Set these environment variables in your `.env` file or `docker-compose.yml`:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `ALERT_WEBHOOK_URL` | _(empty)_ | URL to POST alert payloads to |
|
||||||
|
| `ALERT_WEBHOOK_SECRET` | _(empty)_ | Optional token sent as `X-Alert-Token` header |
|
||||||
|
| `ALERT_WEBHOOK_TIMEOUT` | `5` | HTTP timeout in seconds |
|
||||||
|
|
||||||
|
**Local install (`.env`):**
|
||||||
|
```env
|
||||||
|
ALERT_WEBHOOK_URL=https://your-endpoint.example.com/intercept-alerts
|
||||||
|
ALERT_WEBHOOK_SECRET=mysecrettoken
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker (`.env` or `docker-compose.yml` environment block):**
|
||||||
|
```env
|
||||||
|
ALERT_WEBHOOK_URL=https://your-endpoint.example.com/intercept-alerts
|
||||||
|
ALERT_WEBHOOK_SECRET=mysecrettoken
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create an alert rule
|
||||||
|
|
||||||
|
1. Open the **Alerts** panel in INTERCEPT
|
||||||
|
2. Click **New Rule**
|
||||||
|
3. Configure:
|
||||||
|
- **Mode**: `pager` (or any other mode, or leave blank to match all)
|
||||||
|
- **Event type**: `message` for pager decodes (or blank to match all event types)
|
||||||
|
- **Match criteria**: leave empty to forward everything, or add filters (e.g. capcode equals `1234567`, or message contains `FIRE`)
|
||||||
|
- **Severity**: `low`, `medium`, or `high`
|
||||||
|
4. Save and enable the rule
|
||||||
|
|
||||||
|
### Webhook payload format
|
||||||
|
|
||||||
|
INTERCEPT sends a POST request with `Content-Type: application/json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"rule_id": 1,
|
||||||
|
"mode": "pager",
|
||||||
|
"event_type": "message",
|
||||||
|
"severity": "medium",
|
||||||
|
"title": "My Pager Rule",
|
||||||
|
"message": "message | 1234567",
|
||||||
|
"created_at": "2026-04-13T10:00:00+00:00",
|
||||||
|
"payload": {
|
||||||
|
"mode": "pager",
|
||||||
|
"event_type": "message",
|
||||||
|
"event": {
|
||||||
|
"capcode": "1234567",
|
||||||
|
"message": "UNIT 4 RESPOND TO 123 MAIN ST",
|
||||||
|
"type": "POCSAG1200"
|
||||||
|
},
|
||||||
|
"rule": { "id": 1, "name": "My Pager Rule" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sending to Discord
|
||||||
|
|
||||||
|
Discord webhooks expect a specific JSON format (`content`, `embeds`), so you need a small relay between INTERCEPT and Discord. Two options:
|
||||||
|
|
||||||
|
**Option A — No-code relay (recommended)**
|
||||||
|
|
||||||
|
Use [n8n](https://n8n.io), [Make](https://make.com), or [Pipedream](https://pipedream.com) to receive INTERCEPT's webhook and forward it to Discord with a custom message template. Point `ALERT_WEBHOOK_URL` at your workflow's ingest URL.
|
||||||
|
|
||||||
|
**Option B — Self-hosted Python relay**
|
||||||
|
|
||||||
|
Save this as `discord_relay.py` and run it alongside INTERCEPT:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Flask, request
|
||||||
|
import urllib.request, json
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN"
|
||||||
|
|
||||||
|
@app.post("/relay")
|
||||||
|
def relay():
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
mode = data.get("mode", "unknown").upper()
|
||||||
|
title = data.get("title", "Alert")
|
||||||
|
message = data.get("message", "")
|
||||||
|
event = data.get("payload", {}).get("event", {})
|
||||||
|
|
||||||
|
# Build a readable Discord message
|
||||||
|
lines = [f"**[{mode}]** {title}", message]
|
||||||
|
if event.get("capcode"):
|
||||||
|
lines.append(f"Capcode: `{event['capcode']}`")
|
||||||
|
if event.get("type"):
|
||||||
|
lines.append(f"Protocol: {event['type']}")
|
||||||
|
|
||||||
|
payload = json.dumps({"content": "\n".join(lines)}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
DISCORD_WEBHOOK_URL,
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
urllib.request.urlopen(req, timeout=5)
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
app.run(host="0.0.0.0", port=5051)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set:
|
||||||
|
```env
|
||||||
|
ALERT_WEBHOOK_URL=http://localhost:5051/relay
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the relay: `python3 discord_relay.py`
|
||||||
|
|
||||||
|
The relay formats pager decodes as Discord messages like:
|
||||||
|
|
||||||
|
```
|
||||||
|
[PAGER] My Pager Rule
|
||||||
|
message | 1234567
|
||||||
|
Capcode: `1234567`
|
||||||
|
Protocol: POCSAG1200
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filtering specific capcodes
|
||||||
|
|
||||||
|
To only forward decodes from a specific capcode, set the rule's **Match criteria**:
|
||||||
|
|
||||||
|
| Field | Operator | Value |
|
||||||
|
|-------|----------|-------|
|
||||||
|
| `capcode` | equals | `1234567` |
|
||||||
|
|
||||||
|
Multiple rules can coexist — e.g. one rule for all pager traffic to a general Discord channel, and a second rule for emergency capcodes with `high` severity to a separate channel (using a second relay instance on a different port).
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
INTERCEPT can be configured via environment variables:
|
INTERCEPT can be configured via environment variables:
|
||||||
@@ -110,10 +724,28 @@ INTERCEPT can be configured via environment variables:
|
|||||||
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
||||||
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
|
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
|
||||||
|
|
||||||
Example: `INTERCEPT_PORT=8080 sudo python3 intercept.py`
|
Example: `INTERCEPT_PORT=8080 sudo ./start.sh`
|
||||||
|
|
||||||
## Command-line Options
|
## Command-line Options
|
||||||
|
|
||||||
|
### Production server (recommended)
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo ./start.sh --help
|
||||||
|
|
||||||
|
-p, --port PORT Port to listen on (default: 5050)
|
||||||
|
-H, --host HOST Host to bind to (default: 0.0.0.0)
|
||||||
|
-d, --debug Run in debug mode (Flask dev server)
|
||||||
|
--https Enable HTTPS with self-signed certificate
|
||||||
|
--check-deps Check dependencies and exit
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** `sudo` is required for SDR hardware access, WiFi monitor mode, and Bluetooth low-level operations.
|
||||||
|
|
||||||
|
`start.sh` auto-detects gunicorn + gevent and runs a production WSGI server with cooperative greenlets — this handles multiple SSE streams and WebSocket connections concurrently without blocking. Falls back to the Flask dev server if gunicorn is not installed.
|
||||||
|
|
||||||
|
### Development server
|
||||||
|
|
||||||
```
|
```
|
||||||
python3 intercept.py --help
|
python3 intercept.py --help
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
title: iNTERCEPT
|
||||||
|
description: Signal Intelligence Platform - A web-based interface for software-defined radio tools
|
||||||
|
url: https://smittix.github.io
|
||||||
|
baseurl: /intercept
|
||||||
|
|
||||||
|
# Build settings
|
||||||
|
include:
|
||||||
|
- _headers
|
||||||
|
|
||||||
|
# Exclude files from build
|
||||||
|
exclude:
|
||||||
|
- README.md
|
||||||
|
- SECURITY.md
|
||||||
|
- TROUBLESHOOTING.md
|
||||||
|
- USAGE.md
|
||||||
|
- FEATURES.md
|
||||||
|
- HARDWARE.md
|
||||||
|
- DISTRIBUTED_AGENTS.md
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Pager & 433 Sensor Display Revamp
|
||||||
|
|
||||||
|
**Date:** 2026-05-21
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Replace the plain chronological card feed for the Pager and 433 Sensor modes with purpose-built views that better surface the structure of each signal type. Both new views are opt-out (toggle to classic feed available).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The two modes use slightly different DOM strategies suited to each layout.
|
||||||
|
|
||||||
|
**Pager:** `#pagerDirectoryView` is the left directory panel only. The output panel parent switches to `display: flex` in directory mode, placing the directory panel and `#output` side by side. `#output` becomes the right feed panel — no duplication, no hidden copy.
|
||||||
|
|
||||||
|
**Sensor:** `#sensorDashboardView` is a full-replacement grid that sits alongside `#output`. In dashboard mode `#output` is hidden but continues to receive classic `signal-card` insertions so export and filtering remain intact.
|
||||||
|
|
||||||
|
```
|
||||||
|
[output-panel] (flex in pager directory mode)
|
||||||
|
[#pagerDirectoryView] ← left dir panel only; shown in pager directory mode
|
||||||
|
[#sensorDashboardView] ← full replacement grid; shown in sensor dashboard mode
|
||||||
|
[#output] ← right feed panel (pager) or hidden (sensor); always updated
|
||||||
|
```
|
||||||
|
|
||||||
|
`addMessage()` gets a hook to `PagerDirectory.addMessage()` for directory panel updates only (the feed is `#output` itself). `addSensorReading()` gets a hook to `SensorDashboard.addReading()` for station card updates. No other existing logic changes.
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `static/js/components/pager-directory.js` | PagerDirectory component |
|
||||||
|
| `static/js/components/sensor-dashboard.js` | SensorDashboard component |
|
||||||
|
| `static/css/components/pager-directory.css` | Directory view styles |
|
||||||
|
| `static/css/components/sensor-dashboard.css` | Dashboard view styles |
|
||||||
|
|
||||||
|
`templates/index.html` gets:
|
||||||
|
- Two new sibling containers (`#pagerDirectoryView`, `#sensorDashboardView`)
|
||||||
|
- Toggle buttons in the output panel header (one per mode, shown when that mode is active)
|
||||||
|
- Script/link tags for the four new files
|
||||||
|
- One-line hook calls inside `addMessage()` and `addSensorReading()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pager — Source Directory View
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
Split panel, full height of the output area:
|
||||||
|
|
||||||
|
- **Left (200 px fixed):** address directory panel
|
||||||
|
- **Right (flex):** full message feed
|
||||||
|
|
||||||
|
### Directory panel (left)
|
||||||
|
|
||||||
|
- One row per unique pager address seen this session
|
||||||
|
- Sorted by message count descending (most active at top)
|
||||||
|
- Each row shows:
|
||||||
|
- Protocol badge (`P` = POCSAG, `F` = FLEX), coloured accordingly
|
||||||
|
- Address string
|
||||||
|
- Message count (`×24`)
|
||||||
|
- Relative-width activity bar (count relative to the highest-count address)
|
||||||
|
- Last-seen relative timestamp (`just now`, `2m ago`)
|
||||||
|
- Green dot when a new message arrives from that address (fades after 3 s)
|
||||||
|
- Blue left-border accent on the currently highlighted address
|
||||||
|
- Directory state is in-memory for the session only (not persisted)
|
||||||
|
|
||||||
|
### Feed panel (right)
|
||||||
|
|
||||||
|
- Shows **all messages** at all times (no filtering)
|
||||||
|
- When an address is highlighted via the directory:
|
||||||
|
- Feed scrolls to that address's most recent card
|
||||||
|
- All cards from that address get a blue left-border + subtle background tint
|
||||||
|
- Sub-header shows `"<address> highlighted"` with a "clear highlight" link
|
||||||
|
- Clicking "clear highlight" (or clicking the same address again) removes all highlighting and returns to the plain feed
|
||||||
|
- Cards are otherwise identical to the existing `signal-card` format
|
||||||
|
|
||||||
|
### Toggle
|
||||||
|
|
||||||
|
- Button group top-right of the output panel header: **Directory** | **Feed**
|
||||||
|
- Default: **Directory**
|
||||||
|
- Preference saved to `localStorage` key `pagerView` (`'directory'` | `'feed'`)
|
||||||
|
- Restored on mode switch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 433 Sensor — Station Dashboard View
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
Responsive CSS grid of station cards (3 columns on typical desktop width, wrapping as needed).
|
||||||
|
|
||||||
|
### Station card
|
||||||
|
|
||||||
|
One persistent card per unique device, keyed by `model + id`. Cards are created on first reading and updated in place on subsequent readings from the same device.
|
||||||
|
|
||||||
|
Each card contains:
|
||||||
|
|
||||||
|
- **Header:** device model name (e.g. `Acurite-Tower`), device ID + channel, last-seen relative timestamp (green when < 10 s)
|
||||||
|
- **Readings:** the primary numeric values for that device (temperature, humidity, pressure, wind speed, rain, etc.) — label + value + unit, displayed as a small inline grid
|
||||||
|
- **Sparkline:** SVG polyline tracking the primary numeric value across the last 30 readings. Colour matches the reading type (amber for temperature, blue for humidity/wind, purple for pressure). A filled circle marks the latest data point.
|
||||||
|
- **Footer:** battery status (green `BAT OK` / red `BAT LOW`), SNR value, frequency badge
|
||||||
|
|
||||||
|
### State-only devices
|
||||||
|
|
||||||
|
Devices that emit only a state (doorbells, PIR sensors, etc.) get a card with a state indicator (coloured dot + label e.g. `MOTION DETECTED`) in place of numeric readings. The sparkline area is replaced with an "event-only device" label. Card still flashes on each event.
|
||||||
|
|
||||||
|
### Flash on update
|
||||||
|
|
||||||
|
When a new reading arrives for a known device:
|
||||||
|
- Card receives a CSS animation class that briefly tints the background (blue for temp sensors, purple for other types) and fades back to normal over ~0.8 s
|
||||||
|
- Values update in place; the sparkline dot advances right
|
||||||
|
|
||||||
|
### New device appearance
|
||||||
|
|
||||||
|
First time a device is seen: card slides in with a subtle green border accent. The border fades to normal after the first update.
|
||||||
|
|
||||||
|
### Toggle
|
||||||
|
|
||||||
|
- Button group top-right of output panel header: **Dashboard** | **Feed**
|
||||||
|
- Default: **Dashboard**
|
||||||
|
- Preference saved to `localStorage` key `sensorView` (`'dashboard'` | `'feed'`)
|
||||||
|
- Restored on mode switch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared behaviour
|
||||||
|
|
||||||
|
- Both toggles are shown only when the relevant mode is active
|
||||||
|
- Classic `#output` feed always receives cards in the background (export, CSV/JSON, existing filter bar all continue to work)
|
||||||
|
- No changes to SSE handling, process management, or backend routes
|
||||||
|
- No new backend endpoints required
|
||||||
|
After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 837 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 790 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 694 KiB |
|
After Width: | Height: | Size: 853 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 929 KiB |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 698 KiB |
|
After Width: | Height: | Size: 570 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 876 KiB |
|
After Width: | Height: | Size: 692 KiB |
|
After Width: | Height: | Size: 791 KiB |
|
After Width: | Height: | Size: 455 KiB |
|
After Width: | Height: | Size: 886 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 811 KiB |
@@ -0,0 +1,829 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>iNTERCEPT - Signal Intelligence Platform</title>
|
||||||
|
<meta name="description" content="A web-based interface for software-defined radio tools. Pager decoding, ADS-B tracking, WiFi scanning, and more.">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="bg-canvas"></canvas>
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="nav-container">
|
||||||
|
<a href="#" class="nav-logo"><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="#features">Features</a>
|
||||||
|
<a href="#screenshots">Screenshots</a>
|
||||||
|
<a href="#installation">Install</a>
|
||||||
|
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
|
||||||
|
<a href="https://github.com/smittix/intercept" class="nav-btn" target="_blank">GitHub</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="hero-badge">Open Source SIGINT Platform</div>
|
||||||
|
<h1><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</h1>
|
||||||
|
<p class="hero-subtitle">A unified web interface for software-defined radio tools. Monitor pagers, track aircraft, scan WiFi networks, and more — all from your browser.</p>
|
||||||
|
<div class="hero-buttons">
|
||||||
|
<a href="#installation" class="btn btn-primary">Get Started</a>
|
||||||
|
<a href="https://github.com/smittix/intercept" class="btn btn-secondary" target="_blank">View on GitHub</a>
|
||||||
|
</div>
|
||||||
|
<div class="hero-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">35</span>
|
||||||
|
<span class="stat-label">Modes</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">200+</span>
|
||||||
|
<span class="stat-label">Protocols</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">$25</span>
|
||||||
|
<span class="stat-label">Min Hardware</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-image">
|
||||||
|
<img src="images/dashboard.png" alt="iNTERCEPT Dashboard">
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="features" class="features">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Capabilities</h2>
|
||||||
|
<p class="section-subtitle">Everything you need for signal intelligence in one interface</p>
|
||||||
|
|
||||||
|
<div class="carousel-filters">
|
||||||
|
<button class="filter-btn active" data-filter="all">All</button>
|
||||||
|
<button class="filter-btn" data-filter="signals">Signals</button>
|
||||||
|
<button class="filter-btn" data-filter="tracking">Tracking</button>
|
||||||
|
<button class="filter-btn" data-filter="space">Space</button>
|
||||||
|
<button class="filter-btn" data-filter="wireless">Wireless</button>
|
||||||
|
<button class="filter-btn" data-filter="intel">Intel</button>
|
||||||
|
<button class="filter-btn" data-filter="platform">Platform</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="carousel-wrapper">
|
||||||
|
<button class="carousel-arrow carousel-arrow-left" aria-label="Scroll left">‹</button>
|
||||||
|
<div class="carousel-track">
|
||||||
|
<div class="feature-card" data-category="signals">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="6" y1="9" x2="6" y2="15"/><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="11" x2="18" y2="11"/><line x1="14" y1="13" x2="18" y2="13"/></svg></div>
|
||||||
|
<h3>Pager Decoding</h3>
|
||||||
|
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="signals">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="M12 18v4"/><circle cx="12" cy="12" r="4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg></div>
|
||||||
|
<h3>433MHz Sensors</h3>
|
||||||
|
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="signals">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-9 6 18 3-9h4"/></svg></div>
|
||||||
|
<h3>Sub-GHz Analyzer</h3>
|
||||||
|
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="signals">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 18.5a6.5 6.5 0 1 1 0-13"/><path d="M17 12h5"/><path d="M12 7V2"/><circle cx="12" cy="12" r="2"/><path d="M8.5 8.5L5 5"/></svg></div>
|
||||||
|
<h3>Listening Post</h3>
|
||||||
|
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="signals">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h2"/><path d="M8 12h1"/><path d="M11 12h2"/><path d="M15 12h1"/><path d="M18 12h2"/><circle cx="6" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="18" cy="12" r="1"/><path d="M4 8h16"/><path d="M4 16h16"/></svg></div>
|
||||||
|
<h3>CW/Morse Decoder</h3>
|
||||||
|
<p>Morse code decoding with custom Goertzel tone detection for CW and OOK/AM envelope detection for ISM band signals.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="intel">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
|
||||||
|
<h3>WebSDR</h3>
|
||||||
|
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="intel">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M8 8h2v2H8z"/><path d="M14 8h2v2h-2z"/><path d="M8 14h2v2H8z"/><path d="M14 14h2v2h-2z"/><path d="M11 8h2v2h-2z"/><path d="M11 11h2v2h-2z"/></svg></div>
|
||||||
|
<h3>Spy Stations</h3>
|
||||||
|
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="tracking">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></div>
|
||||||
|
<h3>APRS</h3>
|
||||||
|
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="signals">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
|
||||||
|
<h3>Utility Meters</h3>
|
||||||
|
<p>Smart meter monitoring via rtlamr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="tracking">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></div>
|
||||||
|
<h3>Aircraft Tracking</h3>
|
||||||
|
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="tracking">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/><path d="M7 16h8"/></svg></div>
|
||||||
|
<h3>ACARS</h3>
|
||||||
|
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="tracking">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17"/><path d="M3.5 15h17"/></svg></div>
|
||||||
|
<h3>VDL2</h3>
|
||||||
|
<p>VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2. Real-time ACARS-over-AVLC message capture with signal analysis.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="tracking">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20l4-4h3l4-7 2 4h2l5-9"/><path d="M22 20H2"/><path d="M6 16v4"/></svg></div>
|
||||||
|
<h3>Vessel Tracking</h3>
|
||||||
|
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v4"/><path d="M12 19v4"/><path d="M5 5l2 2"/><path d="M17 17l2 2"/><path d="M1 12h4"/><path d="M19 12h4"/><path d="M5 19l2-2"/><path d="M17 7l2-2"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(45 12 12)"/></svg></div>
|
||||||
|
<h3>Satellite Tracking</h3>
|
||||||
|
<p>Track satellites with TLE data, sky plots, ground track visualization, and pass predictions for your location.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/><circle cx="12" cy="12" r="4"/><path d="M16 12a4 4 0 0 0-4-4"/></svg></div>
|
||||||
|
<h3>Weather Satellites</h3>
|
||||||
|
<p>NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler, polar plot, and ground track map.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg></div>
|
||||||
|
<h3>ISS SSTV</h3>
|
||||||
|
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 3v18"/></svg></div>
|
||||||
|
<h3>HF SSTV</h3>
|
||||||
|
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15h18"/><path d="M3 9h18"/><path d="M6 3v18"/><path d="M18 3v18"/><path d="M9 6h6"/></svg></div>
|
||||||
|
<h3>WeFax</h3>
|
||||||
|
<p>HF weather fax decoder with broadcast timeline, auto-scheduler, and image gallery for marine weather charts.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="tracking">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/><path d="M12 8l4 4-4 4"/></svg></div>
|
||||||
|
<h3>GPS Tracking</h3>
|
||||||
|
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="tracking">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><path d="M12 22v-6"/><circle cx="12" cy="12" r="4"/><path d="M8 12H2"/><path d="M22 12h-6"/><path d="M12 8a20 20 0 0 1 0 8"/><path d="M7 4l2 3"/><path d="M17 20l-2-3"/></svg></div>
|
||||||
|
<h3>Radiosonde</h3>
|
||||||
|
<p>Weather balloon tracking on 400-406 MHz via radiosonde_auto_rx. Real-time telemetry, trajectory map, and station distance.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></div>
|
||||||
|
<h3>Space Weather</h3>
|
||||||
|
<p>Real-time solar and geomagnetic monitoring. Kp index, HF band conditions, solar imagery, D-RAP maps, and aurora forecasts from NOAA SWPC.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg></div>
|
||||||
|
<h3>WiFi Scanning</h3>
|
||||||
|
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="6"/><path d="M12 16v5"/><path d="M8 21h8"/><path d="M9.5 7.5L12 10l2.5-2.5"/></svg></div>
|
||||||
|
<h3>Bluetooth Scanning</h3>
|
||||||
|
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="7" stroke-dasharray="4 2"/><circle cx="12" cy="12" r="11" stroke-dasharray="2 3"/><line x1="12" y1="1" x2="12" y2="3"/></svg></div>
|
||||||
|
<h3>BT Locate</h3>
|
||||||
|
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="10" r="2"/><path d="M12 14v-2"/></svg></div>
|
||||||
|
<h3>WiFi Locate</h3>
|
||||||
|
<p>Locate WiFi access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="intel">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div>
|
||||||
|
<h3>TSCM</h3>
|
||||||
|
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="intel">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="18" r="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><line x1="8" y1="8" x2="9" y2="9"/><line x1="16" y1="8" x2="15" y2="9"/><line x1="8" y1="16" x2="9" y2="15"/><line x1="16" y1="16" x2="15" y2="15"/></svg></div>
|
||||||
|
<h3>Drone Intelligence</h3>
|
||||||
|
<p>Multi-vector UAV detection via ASTM F3411 Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF fingerprinting, and HackRF 2.4/5.8 GHz scanning with live contact map and risk scoring.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
|
||||||
|
<h3>Meshtastic</h3>
|
||||||
|
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="platform">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><circle cx="9" cy="10" r="1.5"/><circle cx="15" cy="10" r="1.5"/><path d="M5 10h2"/><path d="M17 10h2"/></svg></div>
|
||||||
|
<h3>Remote Agents</h3>
|
||||||
|
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="platform">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64A9 9 0 0 1 20.77 15"/><path d="M6.16 6.16a9 9 0 0 0-2.57 8.84"/><path d="M12 2v4"/><path d="M2 12h4"/><line x1="2" y1="2" x2="22" y2="22"/><circle cx="12" cy="12" r="3"/></svg></div>
|
||||||
|
<h3>Offline Mode</h3>
|
||||||
|
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="platform">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></div>
|
||||||
|
<h3>System Health</h3>
|
||||||
|
<p>Real-time telemetry dashboard with process monitoring, system metrics, and SDR device status overview.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">›</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="carousel-indicators" id="carousel-indicators"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screenshots" class="screenshots">
|
||||||
|
<div class="container">
|
||||||
|
<h2>See It In Action</h2>
|
||||||
|
<p class="section-subtitle">A clean, modern interface for complex RF operations</p>
|
||||||
|
|
||||||
|
<div class="screenshot-gallery">
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/dashboard.png" alt="Main Dashboard">
|
||||||
|
<span class="screenshot-label">Dashboard</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/tscm.png" alt="TSCM Counter-Surveillance">
|
||||||
|
<span class="screenshot-label">TSCM Counter-Surveillance</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/bluetooth.png" alt="Bluetooth Scanner">
|
||||||
|
<span class="screenshot-label">Bluetooth Scanner</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/wifi.png" alt="WiFi Scanner">
|
||||||
|
<span class="screenshot-label">WiFi Scanner</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/scanner.png" alt="Listening Post">
|
||||||
|
<span class="screenshot-label">Listening Post</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/sensors.png" alt="433MHz Sensor Monitor">
|
||||||
|
<span class="screenshot-label">433MHz Sensors</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/tscm-detail.png" alt="Device Detail Dialog">
|
||||||
|
<span class="screenshot-label">Device Analysis</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/remote-agents.png" alt="Remote Agents Management">
|
||||||
|
<span class="screenshot-label">Remote Agents</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/ais.png" alt="AIS Vessel Tracking">
|
||||||
|
<span class="screenshot-label">AIS Vessel Tracking</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/bt-locate.png" alt="BT Locate SAR Tracker">
|
||||||
|
<span class="screenshot-label">BT Locate — SAR Tracker</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/spy-stations.png" alt="Spy Stations Database">
|
||||||
|
<span class="screenshot-label">Spy Stations</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/gps.png" alt="GPS Receiver">
|
||||||
|
<span class="screenshot-label">GPS Receiver</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/websdr.png" alt="WebSDR Remote Listening">
|
||||||
|
<span class="screenshot-label">WebSDR</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/aprs.png" alt="APRS Tracker">
|
||||||
|
<span class="screenshot-label">APRS Tracker</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/vdl2.png" alt="VDL2 Aircraft Datalink">
|
||||||
|
<span class="screenshot-label">VDL2 Aircraft Datalink</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/weather-satellite.png" alt="Weather Satellite Decoder">
|
||||||
|
<span class="screenshot-label">Weather Satellite</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/space-weather-1.png" alt="Space Weather Dashboard">
|
||||||
|
<span class="screenshot-label">Space Weather</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/space-weather-2.png" alt="Space Weather Solar Imagery">
|
||||||
|
<span class="screenshot-label">Space Weather — Solar & Aurora</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/satellite-tracker.png" alt="Satellite Tracker">
|
||||||
|
<span class="screenshot-label">Satellite Tracker</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/iss-sstv.png" alt="ISS SSTV Decoder">
|
||||||
|
<span class="screenshot-label">ISS SSTV</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="installation" class="installation">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Quick Start</h2>
|
||||||
|
<p class="section-subtitle">Get up and running in minutes</p>
|
||||||
|
|
||||||
|
<div class="platform-note">
|
||||||
|
<p><strong>Supported Platforms:</strong> Officially tested on Debian and Ubuntu. Partial support for macOS. Other distributions have not been fully tested.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="install-options">
|
||||||
|
<div class="install-card">
|
||||||
|
<h3>Standard Installation</h3>
|
||||||
|
<div class="code-block">
|
||||||
|
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||||
|
cd intercept
|
||||||
|
./setup.sh # Interactive wizard with install profiles
|
||||||
|
sudo ./start.sh</code></pre>
|
||||||
|
</div>
|
||||||
|
<p class="install-note">Menu-driven setup: choose Core, Maritime, Weather, Security, or Full SIGINT profiles. Headless mode: <code>./setup.sh --non-interactive</code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="install-card">
|
||||||
|
<h3>Docker</h3>
|
||||||
|
<div class="code-block">
|
||||||
|
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||||
|
cd intercept
|
||||||
|
docker compose --profile basic up -d --build</code></pre>
|
||||||
|
</div>
|
||||||
|
<p class="install-note">Requires privileged mode for USB SDR access</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post-install">
|
||||||
|
<p>After starting, open <code>http://localhost:5050</code> in your browser.</p>
|
||||||
|
<p>Default credentials: <code>admin</code> / <code>admin</code></p>
|
||||||
|
<p>Run <code>./setup.sh --health-check</code> to verify your installation, or use menu option 2 for a full system health check.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="hardware">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Hardware</h2>
|
||||||
|
<p class="section-subtitle">Minimal hardware, maximum capability</p>
|
||||||
|
|
||||||
|
<div class="hardware-grid">
|
||||||
|
<div class="hardware-card required">
|
||||||
|
<div class="hardware-tag">Required</div>
|
||||||
|
<h3>RTL-SDR</h3>
|
||||||
|
<p>Core SDR functionality for all radio features</p>
|
||||||
|
<span class="price">~$25-35</span>
|
||||||
|
</div>
|
||||||
|
<div class="hardware-card optional">
|
||||||
|
<div class="hardware-tag">Optional</div>
|
||||||
|
<h3>WiFi Adapter</h3>
|
||||||
|
<p>Monitor mode support for WiFi scanning</p>
|
||||||
|
<span class="price">~$20-40</span>
|
||||||
|
</div>
|
||||||
|
<div class="hardware-card optional">
|
||||||
|
<div class="hardware-tag">Optional</div>
|
||||||
|
<h3>GPS Receiver</h3>
|
||||||
|
<p>Real-time location for mapping features</p>
|
||||||
|
<span class="price">~$10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hardware-note">iNTERCEPT also supports HackRF, LimeSDR, Airspy, and SDRplay via SoapySDR</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cta">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Ready to start intercepting?</h2>
|
||||||
|
<p>Join the community and start exploring the RF spectrum</p>
|
||||||
|
<div class="cta-buttons">
|
||||||
|
<a href="https://github.com/smittix/intercept" class="btn btn-primary" target="_blank">Get iNTERCEPT</a>
|
||||||
|
<a href="https://discord.gg/EyeksEJmWE" class="btn btn-secondary" target="_blank">Join Discord</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="support">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Support & Contact</h2>
|
||||||
|
<p class="section-subtitle">Help keep iNTERCEPT alive or get in touch</p>
|
||||||
|
|
||||||
|
<div class="support-grid">
|
||||||
|
<a href="https://www.buymeacoffee.com/smittix" target="_blank" class="support-card support-coffee">
|
||||||
|
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 0 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V8z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg></div>
|
||||||
|
<h3>Buy Me a Coffee</h3>
|
||||||
|
<p>Support development with a one-time donation</p>
|
||||||
|
</a>
|
||||||
|
<a href="#" id="email-card" class="support-card" onclick="return false;">
|
||||||
|
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M22 4L12 13 2 4"/></svg></div>
|
||||||
|
<h3>Email</h3>
|
||||||
|
<p id="email-text">Click to reveal</p>
|
||||||
|
</a>
|
||||||
|
<a href="https://discord.gg/EyeksEJmWE" target="_blank" class="support-card">
|
||||||
|
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><circle cx="12" cy="12" r="10"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
|
||||||
|
<h3>Discord</h3>
|
||||||
|
<p>Join the community for help and discussion</p>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/smittix/intercept/issues" target="_blank" class="support-card">
|
||||||
|
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
|
||||||
|
<h3>Report an Issue</h3>
|
||||||
|
<p>Bug reports and feature requests on GitHub</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-content">
|
||||||
|
<div class="footer-brand">
|
||||||
|
<span class="footer-logo"><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</span>
|
||||||
|
<p>Signal Intelligence Platform</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</a>
|
||||||
|
<a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a>
|
||||||
|
<a href="#" id="footer-email">Email</a>
|
||||||
|
<a href="https://www.buymeacoffee.com/smittix" target="_blank">Donate</a>
|
||||||
|
<a href="https://github.com/smittix/intercept/blob/main/docs/USAGE.md">Documentation</a>
|
||||||
|
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-bottom">
|
||||||
|
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · Apache 2.0 License</p>
|
||||||
|
<p class="disclaimer">For educational and authorized testing purposes only.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Lightbox Modal -->
|
||||||
|
<div id="lightbox" class="lightbox">
|
||||||
|
<span class="lightbox-close">×</span>
|
||||||
|
<img class="lightbox-img" id="lightbox-img" src="" alt="">
|
||||||
|
<div class="lightbox-caption" id="lightbox-caption"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Lightbox functionality
|
||||||
|
const lightbox = document.getElementById('lightbox');
|
||||||
|
const lightboxImg = document.getElementById('lightbox-img');
|
||||||
|
const lightboxCaption = document.getElementById('lightbox-caption');
|
||||||
|
const closeBtn = document.querySelector('.lightbox-close');
|
||||||
|
|
||||||
|
document.querySelectorAll('.screenshot-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const img = item.querySelector('img');
|
||||||
|
const label = item.querySelector('.screenshot-label');
|
||||||
|
lightbox.classList.add('active');
|
||||||
|
lightboxImg.src = img.src;
|
||||||
|
lightboxCaption.textContent = label.textContent;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeLightbox() {
|
||||||
|
lightbox.classList.remove('active');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeBtn.addEventListener('click', closeLightbox);
|
||||||
|
lightbox.addEventListener('click', (e) => {
|
||||||
|
if (e.target === lightbox) closeLightbox();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeLightbox();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Carousel functionality
|
||||||
|
(function() {
|
||||||
|
const track = document.querySelector('.carousel-track');
|
||||||
|
const cards = Array.from(track.querySelectorAll('.feature-card'));
|
||||||
|
const leftArrow = document.querySelector('.carousel-arrow-left');
|
||||||
|
const rightArrow = document.querySelector('.carousel-arrow-right');
|
||||||
|
const filterBtns = document.querySelectorAll('.filter-btn');
|
||||||
|
const indicatorContainer = document.getElementById('carousel-indicators');
|
||||||
|
|
||||||
|
const SCROLL_AMOUNT = 300;
|
||||||
|
|
||||||
|
function updateArrows() {
|
||||||
|
leftArrow.disabled = track.scrollLeft <= 0;
|
||||||
|
rightArrow.disabled = track.scrollLeft + track.clientWidth >= track.scrollWidth - 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIndicators() {
|
||||||
|
const visible = cards.filter(c => !c.classList.contains('hidden'));
|
||||||
|
const totalWidth = visible.length * 300;
|
||||||
|
const pages = Math.max(1, Math.ceil(totalWidth / track.clientWidth));
|
||||||
|
indicatorContainer.innerHTML = '';
|
||||||
|
for (let i = 0; i < pages; i++) {
|
||||||
|
const dot = document.createElement('button');
|
||||||
|
dot.className = 'carousel-dot' + (i === 0 ? ' active' : '');
|
||||||
|
dot.addEventListener('click', () => {
|
||||||
|
track.scrollTo({ left: (track.scrollWidth / pages) * i, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
indicatorContainer.appendChild(dot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIndicators() {
|
||||||
|
const dots = indicatorContainer.querySelectorAll('.carousel-dot');
|
||||||
|
if (!dots.length) return;
|
||||||
|
const ratio = track.scrollLeft / Math.max(1, track.scrollWidth - track.clientWidth);
|
||||||
|
const idx = Math.round(ratio * (dots.length - 1));
|
||||||
|
dots.forEach((d, i) => d.classList.toggle('active', i === idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
leftArrow.addEventListener('click', () => {
|
||||||
|
track.scrollBy({ left: -SCROLL_AMOUNT, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
rightArrow.addEventListener('click', () => {
|
||||||
|
track.scrollBy({ left: SCROLL_AMOUNT, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
track.addEventListener('scroll', () => {
|
||||||
|
updateArrows();
|
||||||
|
updateIndicators();
|
||||||
|
});
|
||||||
|
|
||||||
|
filterBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
filterBtns.forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
const filter = btn.dataset.filter;
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
if (filter === 'all' || card.dataset.category === filter) {
|
||||||
|
card.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
card.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
track.scrollTo({ left: 0 });
|
||||||
|
buildIndicators();
|
||||||
|
updateArrows();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
buildIndicators();
|
||||||
|
updateArrows();
|
||||||
|
window.addEventListener('resize', () => { buildIndicators(); updateArrows(); });
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Obfuscated email - assembled at runtime to defeat scrapers
|
||||||
|
(function() {
|
||||||
|
const p = ['smittix', 'outlook', 'com'];
|
||||||
|
const addr = p[0] + '@' + p[1] + '.' + p[2];
|
||||||
|
const card = document.getElementById('email-card');
|
||||||
|
const text = document.getElementById('email-text');
|
||||||
|
const footerLink = document.getElementById('footer-email');
|
||||||
|
let revealed = false;
|
||||||
|
|
||||||
|
card.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!revealed) {
|
||||||
|
text.textContent = addr;
|
||||||
|
revealed = true;
|
||||||
|
} else {
|
||||||
|
window.location.href = 'mail' + 'to:' + addr;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
footerLink.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.href = 'mail' + 'to:' + addr;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Animated satellite & signal background
|
||||||
|
(function() {
|
||||||
|
const canvas = document.getElementById('bg-canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let w, h, dpr;
|
||||||
|
let orbits = [];
|
||||||
|
let pulses = [];
|
||||||
|
let particles = [];
|
||||||
|
let mouse = { x: -1000, y: -1000 };
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
w = window.innerWidth;
|
||||||
|
h = document.documentElement.scrollHeight;
|
||||||
|
canvas.width = w * dpr;
|
||||||
|
canvas.height = h * dpr;
|
||||||
|
canvas.style.width = w + 'px';
|
||||||
|
canvas.style.height = h + 'px';
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orbital paths with satellites
|
||||||
|
function createOrbits() {
|
||||||
|
orbits = [];
|
||||||
|
const count = Math.max(4, Math.floor(w / 300));
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const cx = Math.random() * w;
|
||||||
|
const cy = Math.random() * h;
|
||||||
|
const rx = 120 + Math.random() * 280;
|
||||||
|
const ry = 40 + Math.random() * 100;
|
||||||
|
const tilt = (Math.random() - 0.5) * 1.2;
|
||||||
|
const speed = (0.0002 + Math.random() * 0.0004) * (Math.random() > 0.5 ? 1 : -1);
|
||||||
|
const sats = [];
|
||||||
|
const satCount = 1 + Math.floor(Math.random() * 2);
|
||||||
|
for (let j = 0; j < satCount; j++) {
|
||||||
|
sats.push({ angle: Math.random() * Math.PI * 2, pulseTimer: 0 });
|
||||||
|
}
|
||||||
|
orbits.push({ cx, cy, rx, ry, tilt, speed, sats });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating signal particles (tiny dots drifting upward)
|
||||||
|
function createParticles() {
|
||||||
|
particles = [];
|
||||||
|
const count = Math.max(30, Math.floor((w * h) / 25000));
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
particles.push({
|
||||||
|
x: Math.random() * w,
|
||||||
|
y: Math.random() * h,
|
||||||
|
vy: -(0.08 + Math.random() * 0.15),
|
||||||
|
vx: (Math.random() - 0.5) * 0.1,
|
||||||
|
size: 0.5 + Math.random() * 1.2,
|
||||||
|
alpha: 0.1 + Math.random() * 0.25,
|
||||||
|
flicker: Math.random() * Math.PI * 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnPulse(x, y) {
|
||||||
|
pulses.push({ x, y, r: 2, maxR: 50 + Math.random() * 40, alpha: 0.35 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOrbitPath(orbit) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(orbit.cx, orbit.cy);
|
||||||
|
ctx.rotate(orbit.tilt);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(0, 0, orbit.rx, orbit.ry, 0, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = 'rgba(0, 212, 170, 0.04)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSatellite(orbit, sat, dt) {
|
||||||
|
sat.angle += orbit.speed * dt;
|
||||||
|
const cos = Math.cos(orbit.tilt);
|
||||||
|
const sin = Math.sin(orbit.tilt);
|
||||||
|
const ex = orbit.rx * Math.cos(sat.angle);
|
||||||
|
const ey = orbit.ry * Math.sin(sat.angle);
|
||||||
|
const sx = orbit.cx + ex * cos - ey * sin;
|
||||||
|
const sy = orbit.cy + ex * sin + ey * cos;
|
||||||
|
|
||||||
|
// Satellite dot
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = 'rgba(0, 212, 170, 0.7)';
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Faint glow
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, 6, 0, Math.PI * 2);
|
||||||
|
const g = ctx.createRadialGradient(sx, sy, 0, sx, sy, 6);
|
||||||
|
g.addColorStop(0, 'rgba(0, 212, 170, 0.15)');
|
||||||
|
g.addColorStop(1, 'rgba(0, 212, 170, 0)');
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Periodic signal pulse
|
||||||
|
sat.pulseTimer += dt;
|
||||||
|
if (sat.pulseTimer > 3000 + Math.random() * 500) {
|
||||||
|
sat.pulseTimer = 0;
|
||||||
|
spawnPulse(sx, sy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPulses(dt) {
|
||||||
|
for (let i = pulses.length - 1; i >= 0; i--) {
|
||||||
|
const p = pulses[i];
|
||||||
|
p.r += dt * 0.025;
|
||||||
|
p.alpha = 0.35 * (1 - p.r / p.maxR);
|
||||||
|
if (p.r >= p.maxR) { pulses.splice(i, 1); continue; }
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = `rgba(0, 212, 170, ${p.alpha})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Second ring
|
||||||
|
if (p.r > 12) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.r * 0.6, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = `rgba(0, 136, 255, ${p.alpha * 0.5})`;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawParticles(dt, time) {
|
||||||
|
for (const p of particles) {
|
||||||
|
p.y += p.vy * dt * 0.06;
|
||||||
|
p.x += p.vx * dt * 0.06;
|
||||||
|
p.flicker += dt * 0.002;
|
||||||
|
|
||||||
|
if (p.y < -10) { p.y = h + 10; p.x = Math.random() * w; }
|
||||||
|
if (p.x < -10) p.x = w + 10;
|
||||||
|
if (p.x > w + 10) p.x = -10;
|
||||||
|
|
||||||
|
const flick = p.alpha * (0.6 + 0.4 * Math.sin(p.flicker));
|
||||||
|
|
||||||
|
// Mouse interaction - subtle brighten
|
||||||
|
const dx = p.x - mouse.x;
|
||||||
|
const dy = p.y - mouse.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const boost = dist < 150 ? 0.3 * (1 - dist / 150) : 0;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `rgba(0, 212, 170, ${Math.min(flick + boost, 0.6)})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Faint grid lines (signal grid)
|
||||||
|
function drawGrid(time) {
|
||||||
|
ctx.strokeStyle = 'rgba(0, 212, 170, 0.015)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
const spacing = 120;
|
||||||
|
const offset = (time * 0.005) % spacing;
|
||||||
|
|
||||||
|
for (let x = -spacing + offset; x < w + spacing; x += spacing) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, h);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = -spacing + offset * 0.7; y < h + spacing; y += spacing) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(w, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let last = 0;
|
||||||
|
function animate(now) {
|
||||||
|
const dt = last ? Math.min(now - last, 50) : 16;
|
||||||
|
last = now;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
drawGrid(now);
|
||||||
|
|
||||||
|
for (const orbit of orbits) {
|
||||||
|
drawOrbitPath(orbit);
|
||||||
|
for (const sat of orbit.sats) {
|
||||||
|
drawSatellite(orbit, sat, dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPulses(dt);
|
||||||
|
drawParticles(dt, now);
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track mouse for particle interaction
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
mouse.x = e.clientX;
|
||||||
|
mouse.y = e.clientY + window.scrollY;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resize handling
|
||||||
|
let resizeTimer;
|
||||||
|
function handleResize() {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => {
|
||||||
|
resize();
|
||||||
|
createOrbits();
|
||||||
|
createParticles();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep canvas height synced with document
|
||||||
|
const ro = new ResizeObserver(() => { handleResize(); });
|
||||||
|
ro.observe(document.documentElement);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
resize();
|
||||||
|
createOrbits();
|
||||||
|
createParticles();
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# Meshcore Support — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-05-10
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a Meshcore mode to Intercept, providing full feature parity with the existing Meshtastic module. Meshcore is a LoRa mesh radio platform using a repeater-based routing model (dedicated infrastructure nodes relay; clients do not). It has an official Python library (`meshcore`, PyPI) and a published companion protocol.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Connection methods | USB serial + TCP + BLE | Maximum hardware flexibility |
|
||||||
|
| Feature scope | Full parity with Meshtastic | Messages, node map, telemetry, traceroute, repeater management |
|
||||||
|
| Async integration | Background asyncio thread | meshcore library is asyncio-based; this isolates it cleanly from Flask/gevent |
|
||||||
|
| UI layout | Messages-first (mirror Meshtastic) | Sidebar: contacts/nodes. Center: message feed. Tabs: map, telemetry, repeaters |
|
||||||
|
| BLE in Docker | Document limitation + proxy workaround | BLE unavailable in containers; meshcore-proxy bridges BLE → TCP |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
|
||||||
|
```
|
||||||
|
utils/meshcore.py # MeshcoreClient singleton + dataclasses
|
||||||
|
utils/meshcore_client.py # Thin async wrapper around meshcore library (lives in asyncio thread)
|
||||||
|
routes/meshcore.py # Flask blueprint (/meshcore)
|
||||||
|
static/js/modes/meshcore.js # Frontend IIFE module
|
||||||
|
static/css/modes/meshcore.css # Scoped styles
|
||||||
|
templates/partials/modes/meshcore.html # Sidebar partial
|
||||||
|
tests/test_meshcore_client.py
|
||||||
|
tests/test_meshcore_routes.py
|
||||||
|
tests/test_meshcore_integration.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
|
||||||
|
- `routes/__init__.py` — import + `register_blueprint(meshcore_bp)`
|
||||||
|
- `templates/index.html` — ~12 insertion points (CSS, partial, JS, validModes, modeGroups, etc.)
|
||||||
|
- `requirements.txt` — add `meshcore>=1.0.0` (optional dep, graceful fallback if absent)
|
||||||
|
- `.gitignore` — already has `.superpowers/` ✓
|
||||||
|
|
||||||
|
### Async Bridge Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
meshcore library (asyncio event loop in daemon OS thread)
|
||||||
|
→ event callbacks (_on_message, _on_node_update, _on_telemetry)
|
||||||
|
→ asyncio.run_coroutine_threadsafe() → queue.Queue (thread-safe, max 500)
|
||||||
|
→ /meshcore/stream SSE generator drains queue (30s keepalive timeout)
|
||||||
|
→ Frontend EventSource routes by event type
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the same conceptual pattern as all other decoder integrations in Intercept (ADS-B socket reader, AIS-catcher output thread, rtl_433 stdout thread), just with an explicit asyncio loop instead of a subprocess thread.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class MeshcoreMessage:
|
||||||
|
id: str
|
||||||
|
sender_id: str
|
||||||
|
recipient_id: str # node ID or broadcast address
|
||||||
|
text: str
|
||||||
|
timestamp: datetime
|
||||||
|
hop_count: int
|
||||||
|
snr: float | None
|
||||||
|
is_direct: bool # DM vs broadcast
|
||||||
|
pending: bool = False # optimistic send state
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MeshcoreNode:
|
||||||
|
node_id: str
|
||||||
|
name: str
|
||||||
|
is_repeater: bool # key Meshcore distinction — rendered differently on map
|
||||||
|
lat: float | None
|
||||||
|
lon: float | None
|
||||||
|
battery_pct: int | None
|
||||||
|
last_seen: datetime
|
||||||
|
snr: float | None
|
||||||
|
hops_away: int | None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MeshcoreContact:
|
||||||
|
node_id: str
|
||||||
|
name: str
|
||||||
|
public_key: str # Meshcore uses key-based addressing
|
||||||
|
last_msg: datetime | None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MeshcoreTelemetry:
|
||||||
|
node_id: str
|
||||||
|
timestamp: datetime
|
||||||
|
battery_pct: int | None
|
||||||
|
voltage: float | None
|
||||||
|
temperature: float | None
|
||||||
|
humidity: float | None
|
||||||
|
uptime_secs: int | None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MeshcoreTraceroute:
|
||||||
|
origin_id: str
|
||||||
|
destination_id: str
|
||||||
|
hops: list[str]
|
||||||
|
snr_per_hop: list[float]
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SerialConfig:
|
||||||
|
port: str | None = None # None = auto-discover
|
||||||
|
baud: int = 115200
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TCPConfig:
|
||||||
|
host: str = "localhost"
|
||||||
|
port: int = 5000 # meshcore-proxy default
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BLEConfig:
|
||||||
|
device_address: str | None = None # None = scan for first Meshcore device
|
||||||
|
|
||||||
|
ConnectionConfig = SerialConfig | TCPConfig | BLEConfig
|
||||||
|
```
|
||||||
|
|
||||||
|
Connection state enum: `DISCONNECTED | CONNECTING | CONNECTED | ERROR`
|
||||||
|
|
||||||
|
## Connection Handling
|
||||||
|
|
||||||
|
### Serial
|
||||||
|
Auto-discover: scan `/dev/ttyUSB*`, `/dev/ttyACM*`, `/dev/cu.usbserial*` and return list to frontend via `GET /meshcore/ports`. User can also specify path directly.
|
||||||
|
|
||||||
|
### TCP
|
||||||
|
Direct connection to `host:port`. Primary use case: meshcore-proxy running on the host, exposing a local USB or BLE device over TCP for Docker deployments.
|
||||||
|
|
||||||
|
### BLE
|
||||||
|
- Linux/RPi: meshcore library uses BlueZ (requires `bluetoothctl` accessible)
|
||||||
|
- macOS: meshcore library uses CoreBluetooth
|
||||||
|
- Docker: detect via presence of `/.dockerenv` or `INTERCEPT_DOCKER=1` env var; connect attempt fails fast with clear error directing user to meshcore-proxy
|
||||||
|
|
||||||
|
`GET /meshcore/ble/scan` returns: `[{"address": "AA:BB:CC:DD:EE:FF", "name": "MeshCore-Node1", "rssi": -72}]`
|
||||||
|
|
||||||
|
### Reconnect
|
||||||
|
Exponential backoff: 3 retries at 5s, 15s, 45s (cap 60s). On final failure, pushes `status` SSE event with `state: "error"`. User can manually retry via `POST /meshcore/connect`.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | /meshcore/status | Connection state + transport info |
|
||||||
|
| POST | /meshcore/connect | Connect with SerialConfig, TCPConfig, or BLEConfig |
|
||||||
|
| POST | /meshcore/disconnect | Disconnect and stop background thread |
|
||||||
|
| GET | /meshcore/ports | List available serial ports |
|
||||||
|
| GET | /meshcore/ble/scan | Scan for nearby Meshcore BLE devices |
|
||||||
|
| GET | /meshcore/stream | SSE stream (messages, nodes, telemetry, status) |
|
||||||
|
| GET | /meshcore/messages | Recent messages (last 500) |
|
||||||
|
| POST | /meshcore/send | Send text message |
|
||||||
|
| GET | /meshcore/nodes | All known nodes |
|
||||||
|
| GET | /meshcore/contacts | Contact list |
|
||||||
|
| POST | /meshcore/contacts | Add contact |
|
||||||
|
| DELETE | /meshcore/contacts/`<id>` | Remove contact |
|
||||||
|
| GET | /meshcore/telemetry/`<node_id>` | Telemetry history for node |
|
||||||
|
| POST | /meshcore/traceroute | Request traceroute to node |
|
||||||
|
| GET | /meshcore/repeaters | List repeater nodes |
|
||||||
|
|
||||||
|
## SSE Event Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "message", "data": { ...MeshcoreMessage }}
|
||||||
|
{"type": "node", "data": { ...MeshcoreNode }}
|
||||||
|
{"type": "telemetry", "data": { ...MeshcoreTelemetry }}
|
||||||
|
{"type": "traceroute", "data": { ...MeshcoreTraceroute }}
|
||||||
|
{"type": "status", "data": {"state": "connected", "transport": "serial", "device": "/dev/ttyUSB0"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keepalive comment (`: keepalive`) sent every 30 seconds on idle.
|
||||||
|
|
||||||
|
## Frontend (meshcore.js)
|
||||||
|
|
||||||
|
IIFE pattern, same as all other Intercept JS modules. Key responsibilities:
|
||||||
|
|
||||||
|
- **SSE consumer** — `EventSource('/meshcore/stream')`, routes events by `type`
|
||||||
|
- **Message feed** — append to scrolling list, optimistic pending state on send
|
||||||
|
- **Sidebar** — contact list + node list; repeaters shown separately with triangle icon (vs circle for client nodes), matching Meshcore UI conventions
|
||||||
|
- **Tabs** — Map (Leaflet, reuse existing map setup pattern), Telemetry (Chart.js, reuse existing chart helpers), Repeaters (dedicated table view)
|
||||||
|
- **Connection panel** — transport selector (Serial / TCP / BLE), port/IP/address input, connect/disconnect button
|
||||||
|
- **Traceroute modal** — hop diagram with SNR annotations, same visual style as Meshtastic traceroute
|
||||||
|
|
||||||
|
## Repeater Management
|
||||||
|
|
||||||
|
Meshcore repeaters are a first-class concept (unlike Meshtastic where all nodes relay). Design:
|
||||||
|
|
||||||
|
- Repeaters identified by `is_repeater: true` on `MeshcoreNode`
|
||||||
|
- Rendered on map as orange triangles (client nodes = blue circles)
|
||||||
|
- Dedicated "Repeaters" tab in the main panel showing: name, location, uptime, last seen, hop count
|
||||||
|
- Repeater stats surfaced in telemetry if available (uptime_secs from `MeshcoreTelemetry`)
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- meshcore library not installed → mode loads but shows "meshcore package required: `pip install meshcore`"
|
||||||
|
- BLE in Docker → clear error: "BLE unavailable in Docker. Run meshcore-proxy on the host and connect via TCP."
|
||||||
|
- Serial port not found → return available ports list in error response
|
||||||
|
- Connection lost mid-session → automatic reconnect with backoff; SSE `status` event updates UI indicator
|
||||||
|
- Send failure → SSE event clears pending state, shows error in message feed
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**`tests/test_meshcore_client.py`**
|
||||||
|
- Connection state machine transitions
|
||||||
|
- Reconnect backoff timing (mock asyncio loop)
|
||||||
|
- Message parsing and queue feeding
|
||||||
|
- Node/contact TTL expiry
|
||||||
|
- BLE unavailability error (Docker scenario)
|
||||||
|
|
||||||
|
**`tests/test_meshcore_routes.py`**
|
||||||
|
- All REST endpoints: correct JSON shape, status codes
|
||||||
|
- `/meshcore/connect` with each connection config type
|
||||||
|
- `/meshcore/send` with missing/invalid params → 400
|
||||||
|
- SSE stream yields keepalive on empty queue
|
||||||
|
- Input validation via `utils/validation.py`
|
||||||
|
|
||||||
|
**`tests/test_meshcore_integration.py`**
|
||||||
|
- Mock meshcore library at boundary (same approach as mocking meshtastic SDK)
|
||||||
|
- Full round-trip: connect → receive message event → appears in SSE stream
|
||||||
|
- Traceroute request → hop structure correctly parsed
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
meshcore>=1.0.0 # optional — graceful degradation if absent
|
||||||
|
```
|
||||||
|
|
||||||
|
No new frontend dependencies — Leaflet and Chart.js already present.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
- Meshcore Python library: https://github.com/meshcore-dev/meshcore_py
|
||||||
|
- Companion protocol: https://docs.meshcore.io/companion_protocol/
|
||||||
|
- meshcore-proxy (BLE/serial → TCP bridge): https://github.com/rgregg/meshcore-proxy
|
||||||
|
- Existing Meshtastic implementation (reference): `utils/meshtastic.py`, `routes/meshtastic.py`
|
||||||
@@ -0,0 +1,958 @@
|
|||||||
|
/* INTERCEPT GitHub Pages - Dark Theme */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0f;
|
||||||
|
--bg-secondary: #12121a;
|
||||||
|
--bg-card: #1a1a24;
|
||||||
|
--bg-card-hover: #22222e;
|
||||||
|
--text-primary: #f0f0f5;
|
||||||
|
--text-secondary: #8888a0;
|
||||||
|
--text-muted: #5c5c70;
|
||||||
|
--accent: #00d4aa;
|
||||||
|
--accent-hover: #00f0c0;
|
||||||
|
--accent-glow: rgba(0, 212, 170, 0.2);
|
||||||
|
--border: #2a2a38;
|
||||||
|
--code-bg: #0d0d14;
|
||||||
|
--gradient-start: #00d4aa;
|
||||||
|
--gradient-end: #0088ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated background canvas */
|
||||||
|
#bg-canvas {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > *:not(#bg-canvas) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.navbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(10, 10, 15, 0.9);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Branded "i" — inline SVG glyph matching the app logo */
|
||||||
|
.brand-i {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.55em;
|
||||||
|
height: 0.9em;
|
||||||
|
vertical-align: baseline;
|
||||||
|
position: relative;
|
||||||
|
top: 0.05em;
|
||||||
|
}
|
||||||
|
.brand-i svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary) !important;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero */
|
||||||
|
.hero {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 60px;
|
||||||
|
padding: 120px 24px 80px;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-glow);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 4.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 40px;
|
||||||
|
max-width: 500px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 14px 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 30px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-image {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-image img {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
section {
|
||||||
|
padding: 100px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
section h2 {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Features */
|
||||||
|
.features {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category filter tabs */
|
||||||
|
.carousel-filters {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Carousel */
|
||||||
|
.carousel-wrapper {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-track {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding: 8px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-track::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
flex: 0 0 280px;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Carousel arrows */
|
||||||
|
.carousel-arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow:disabled:hover {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Carousel indicators */
|
||||||
|
.carousel-indicators {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-dot.active {
|
||||||
|
background: var(--accent);
|
||||||
|
width: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-dot:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screenshots */
|
||||||
|
.screenshot-gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-item {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: all 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-item:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 212, 170, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-label {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lightbox */
|
||||||
|
.lightbox {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.95);
|
||||||
|
z-index: 1000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 30px;
|
||||||
|
font-size: 40px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-img {
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-caption {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Installation */
|
||||||
|
.installation {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 32px;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-card h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: var(--code-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block pre {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block code {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-note {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-note {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-note p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-note strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-install {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-install p {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-install code {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hardware */
|
||||||
|
.hardware-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-card.required .hardware-tag {
|
||||||
|
background: var(--accent-glow);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-card.optional .hardware-tag {
|
||||||
|
background: rgba(136, 136, 160, 0.1);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-card h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-card p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-card .price {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-note {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CTA */
|
||||||
|
.cta {
|
||||||
|
background: linear-gradient(135deg, rgba(0, 212, 170, 0.1), rgba(0, 136, 255, 0.1));
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta h2 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Support & Contact */
|
||||||
|
.support {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.3s;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card.support-coffee {
|
||||||
|
border-color: rgba(255, 193, 59, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card.support-coffee:hover {
|
||||||
|
border-color: #ffc13b;
|
||||||
|
box-shadow: 0 8px 30px rgba(255, 193, 59, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card.support-coffee .support-icon {
|
||||||
|
color: #ffc13b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card p {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 60px 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 32px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-logo {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-brand p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-buttons {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-image {
|
||||||
|
order: -1;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-wrapper {
|
||||||
|
padding: 0 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
flex: 0 0 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-gallery {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-options {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-wrapper {
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
flex: 0 0 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-filters {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 6px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-gallery {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Download sample NOAA APT recordings for testing the weather satellite
|
||||||
|
# test-decode feature. These are FM-demodulated audio WAV files.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./download-weather-sat-samples.sh
|
||||||
|
# docker exec intercept /app/download-weather-sat-samples.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SAMPLE_DIR="$(dirname "$0")/data/weather_sat/samples"
|
||||||
|
mkdir -p "$SAMPLE_DIR"
|
||||||
|
|
||||||
|
echo "Downloading NOAA APT sample files to $SAMPLE_DIR ..."
|
||||||
|
|
||||||
|
# Full satellite pass recorded over Argentina (NOAA, 11025 Hz mono WAV)
|
||||||
|
# Source: https://github.com/martinber/noaa-apt
|
||||||
|
if [ ! -f "$SAMPLE_DIR/noaa_apt_argentina.wav" ]; then
|
||||||
|
echo " -> noaa_apt_argentina.wav (18 MB) ..."
|
||||||
|
curl -fSL -o "$SAMPLE_DIR/noaa_apt_argentina.wav" \
|
||||||
|
"https://noaa-apt.mbernardi.com.ar/examples/argentina.wav"
|
||||||
|
else
|
||||||
|
echo " -> noaa_apt_argentina.wav (already exists)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done. Test decode with:"
|
||||||
|
echo " Satellite: NOAA-18"
|
||||||
|
echo " File path: data/weather_sat/samples/noaa_apt_argentina.wav"
|
||||||
|
echo " Sample rate: 11025 Hz"
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Minimal Flask-SocketIO compatibility shim.
|
||||||
|
|
||||||
|
This is only intended to satisfy radiosonde_auto_rx's optional web UI
|
||||||
|
dependency in environments where ``flask_socketio`` is not installed.
|
||||||
|
It provides the small subset of the API that auto_rx imports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class SocketIO:
|
||||||
|
"""Very small subset of Flask-SocketIO's SocketIO interface."""
|
||||||
|
|
||||||
|
def __init__(self, app, async_mode: str | None = None, *args, **kwargs):
|
||||||
|
self.app = app
|
||||||
|
self.async_mode = async_mode or "threading"
|
||||||
|
self._handlers: dict[tuple[str, str | None], Callable[..., Any]] = {}
|
||||||
|
|
||||||
|
def on(self, event: str, namespace: str | None = None):
|
||||||
|
"""Register an event handler decorator."""
|
||||||
|
|
||||||
|
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
|
self._handlers[(event, namespace)] = func
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
def emit(self, event: str, data: Any = None, namespace: str | None = None, *args, **kwargs) -> None:
|
||||||
|
"""No-op emit used when the real Socket.IO server is unavailable."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def run(self, app=None, host: str = "127.0.0.1", port: int = 5000, *args, **kwargs) -> None:
|
||||||
|
"""Fallback to Flask's built-in development server."""
|
||||||
|
flask_app = app or self.app
|
||||||
|
flask_app.run(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
threaded=True,
|
||||||
|
use_reloader=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Gunicorn configuration for INTERCEPT."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.filterwarnings(
|
||||||
|
'ignore',
|
||||||
|
message='Patching more than once',
|
||||||
|
category=DeprecationWarning,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def post_fork(server, worker):
|
||||||
|
"""Apply gevent monkey-patching immediately after fork.
|
||||||
|
|
||||||
|
Gunicorn's built-in gevent worker is supposed to handle this, but on
|
||||||
|
some platforms (notably Raspberry Pi / ARM) the worker deadlocks during
|
||||||
|
its own init_process() before it gets to patch. Doing it here — right
|
||||||
|
after fork, before any worker initialisation — avoids the race.
|
||||||
|
|
||||||
|
Gunicorn's gevent worker will call patch_all() again in init_process();
|
||||||
|
the duplicate call is harmless (gevent unions the flags) and the
|
||||||
|
MonkeyPatchWarning is suppressed above.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from gevent import monkey
|
||||||
|
monkey.patch_all()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Silence the spurious AssertionError in gevent's fork hooks that fires
|
||||||
|
# when subprocesses fork after a double monkey-patch.
|
||||||
|
try:
|
||||||
|
from gevent.threading import _ForkHooks
|
||||||
|
_orig = _ForkHooks.after_fork_in_child
|
||||||
|
|
||||||
|
def _safe_after_fork(self):
|
||||||
|
with contextlib.suppress(AssertionError):
|
||||||
|
_orig(self)
|
||||||
|
|
||||||
|
_ForkHooks.after_fork_in_child = _safe_after_fork
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def post_worker_init(worker):
|
||||||
|
"""Suppress noisy SystemExit tracebacks during gevent worker shutdown.
|
||||||
|
|
||||||
|
When gunicorn receives SIGINT, the gevent worker's handle_quit()
|
||||||
|
calls sys.exit(0) inside a greenlet. Gevent treats SystemExit as
|
||||||
|
an error by default and prints a traceback. Adding it to NOT_ERROR
|
||||||
|
silences this harmless noise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
from gevent import get_hub
|
||||||
|
hub = get_hub()
|
||||||
|
suppress = (SystemExit, ssl.SSLZeroReturnError, ssl.SSLError)
|
||||||
|
for exc in suppress:
|
||||||
|
if exc not in hub.NOT_ERROR:
|
||||||
|
hub.NOT_ERROR = hub.NOT_ERROR + (exc,)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -16,14 +16,6 @@ Requires RTL-SDR hardware for RF modes.
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Check Python version early, before imports that use 3.9+ syntax
|
# Check Python version early, before imports that use 3.9+ syntax
|
||||||
if sys.version_info < (3, 9):
|
|
||||||
print(f"Error: Python 3.9 or higher is required.")
|
|
||||||
print(f"You are running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
|
|
||||||
print("\nTo fix this:")
|
|
||||||
print(" - On Ubuntu/Debian: sudo apt install python3.9 (or newer)")
|
|
||||||
print(" - On macOS: brew install python@3.11")
|
|
||||||
print(" - Or use pyenv to install a newer version")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Handle --version early before other imports
|
# Handle --version early before other imports
|
||||||
if '--version' in sys.argv or '-V' in sys.argv:
|
if '--version' in sys.argv or '-V' in sys.argv:
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# INTERCEPT AGENT CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
# This file configures the Intercept remote agent.
|
||||||
|
# Copy this file and customize for your deployment.
|
||||||
|
|
||||||
|
[agent]
|
||||||
|
# Agent name (used to identify this node in the controller)
|
||||||
|
# Default: system hostname
|
||||||
|
name = sensor-node-1
|
||||||
|
|
||||||
|
# HTTP server port
|
||||||
|
# Default: 8020
|
||||||
|
port = 8020
|
||||||
|
|
||||||
|
# Comma-separated list of allowed client IPs (empty = allow all)
|
||||||
|
# Example: 192.168.1.100, 192.168.1.101, 10.0.0.0/8
|
||||||
|
allowed_ips =
|
||||||
|
|
||||||
|
# Enable CORS headers for browser-based clients
|
||||||
|
# Default: false
|
||||||
|
allow_cors = false
|
||||||
|
|
||||||
|
|
||||||
|
[controller]
|
||||||
|
# Controller URL for push mode
|
||||||
|
# Example: http://192.168.1.100:5050
|
||||||
|
url =
|
||||||
|
|
||||||
|
# API key for controller authentication (shared secret)
|
||||||
|
api_key =
|
||||||
|
|
||||||
|
# Enable automatic push of scan data to controller
|
||||||
|
# Default: false
|
||||||
|
push_enabled = false
|
||||||
|
|
||||||
|
# Push interval in seconds (minimum time between pushes)
|
||||||
|
# Default: 5
|
||||||
|
push_interval = 5
|
||||||
|
|
||||||
|
|
||||||
|
[modes]
|
||||||
|
# Enable/disable specific modes on this agent
|
||||||
|
# Set to false to disable a mode even if tools are available
|
||||||
|
# Default: all true
|
||||||
|
|
||||||
|
pager = true
|
||||||
|
sensor = true
|
||||||
|
adsb = true
|
||||||
|
ais = true
|
||||||
|
acars = true
|
||||||
|
aprs = true
|
||||||
|
wifi = true
|
||||||
|
bluetooth = true
|
||||||
|
dsc = true
|
||||||
|
rtlamr = true
|
||||||
|
tscm = true
|
||||||
|
satellite = true
|
||||||
|
listening_post = true
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.0.0"
|
version = "2.27.0"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
license = {text = "MIT"}
|
license = {text = "Apache-2.0"}
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Intercept Contributors"}
|
{name = "Intercept Contributors"}
|
||||||
]
|
]
|
||||||
@@ -14,7 +14,7 @@ classifiers = [
|
|||||||
"Environment :: Web Environment",
|
"Environment :: Web Environment",
|
||||||
"Framework :: Flask",
|
"Framework :: Flask",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: Apache Software License",
|
||||||
"Operating System :: POSIX :: Linux",
|
"Operating System :: POSIX :: Linux",
|
||||||
"Operating System :: MacOS",
|
"Operating System :: MacOS",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
@@ -26,9 +26,18 @@ classifiers = [
|
|||||||
"Topic :: System :: Networking :: Monitoring",
|
"Topic :: System :: Networking :: Monitoring",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flask>=2.0.0",
|
"flask>=3.0.0",
|
||||||
|
"flask-wtf>=1.2.0",
|
||||||
|
"flask-compress>=1.15",
|
||||||
|
"flask-limiter>=2.5.4",
|
||||||
|
"flask-sock",
|
||||||
|
"simple-websocket>=0.5.1",
|
||||||
|
"websocket-client>=1.6.0",
|
||||||
"skyfield>=1.45",
|
"skyfield>=1.45",
|
||||||
"pyserial>=3.5",
|
"pyserial>=3.5",
|
||||||
|
"Werkzeug>=3.1.5",
|
||||||
|
"bleak>=0.21.0",
|
||||||
|
"requests>=2.28.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -45,6 +54,22 @@ dev = [
|
|||||||
"black>=23.0.0",
|
"black>=23.0.0",
|
||||||
"mypy>=1.0.0",
|
"mypy>=1.0.0",
|
||||||
"types-flask>=1.1.0",
|
"types-flask>=1.1.0",
|
||||||
|
"pre-commit>=3.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
optionals = [
|
||||||
|
"scipy>=1.10.0",
|
||||||
|
"qrcode[pil]>=7.4",
|
||||||
|
"numpy>=1.24.0",
|
||||||
|
"Pillow>=9.0.0",
|
||||||
|
"meshtastic>=2.0.0",
|
||||||
|
"meshcore>=1.0.0",
|
||||||
|
"psycopg2-binary>=2.9.9",
|
||||||
|
"scapy>=2.4.5",
|
||||||
|
"cryptography>=41.0.0",
|
||||||
|
"psutil>=5.9.0",
|
||||||
|
"gunicorn>=21.2.0",
|
||||||
|
"gevent>=23.9.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -77,8 +102,32 @@ ignore = [
|
|||||||
"B008", # do not perform function calls in argument defaults
|
"B008", # do not perform function calls in argument defaults
|
||||||
"B905", # zip without explicit strict
|
"B905", # zip without explicit strict
|
||||||
"SIM108", # use ternary operator instead of if-else
|
"SIM108", # use ternary operator instead of if-else
|
||||||
|
"SIM102", # collapsible if statements
|
||||||
|
"SIM105", # use contextlib.suppress (stylistic, not a bug)
|
||||||
|
"SIM115", # use context manager for open (not always applicable)
|
||||||
|
"SIM116", # use dict instead of if/elif chain (stylistic)
|
||||||
|
"SIM117", # combine nested with statements (stylistic)
|
||||||
|
"E402", # module-level import not at top (needed for conditional imports)
|
||||||
|
"E741", # ambiguous variable name
|
||||||
|
"E721", # type comparison (use isinstance)
|
||||||
|
"E722", # bare except
|
||||||
|
"B904", # raise from within except (stylistic)
|
||||||
|
"B007", # unused loop variable (use _ prefix)
|
||||||
|
"B023", # function definition doesn't bind loop variable
|
||||||
|
"F601", # membership test with duplicate items
|
||||||
|
"F821", # undefined name (too many false positives with conditional imports)
|
||||||
|
"UP035", # deprecated typing imports
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"__init__.py" = ["F401"] # re-exports in __init__.py are intentional
|
||||||
|
"utils/bluetooth/capability_check.py" = ["F401"] # imports used for availability checking
|
||||||
|
"utils/bluetooth/fallback_scanner.py" = ["F401"] # imports used for availability checking
|
||||||
|
"utils/tscm/ble_scanner.py" = ["F401"] # imports used for availability checking
|
||||||
|
"utils/wifi/deauth_detector.py" = ["F401"] # imports used for availability checking
|
||||||
|
"routes/dsc.py" = ["F401"] # imports used for availability checking
|
||||||
|
"intercept_agent.py" = ["F401"] # conditional imports
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
known-first-party = ["app", "config", "routes", "utils", "data"]
|
known-first-party = ["app", "config", "routes", "utils", "data"]
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pytest-mock>=3.15.1
|
|||||||
ruff>=0.1.0
|
ruff>=0.1.0
|
||||||
black>=23.0.0
|
black>=23.0.0
|
||||||
mypy>=1.0.0
|
mypy>=1.0.0
|
||||||
|
pre-commit>=3.0.0
|
||||||
|
|
||||||
# Type stubs
|
# Type stubs
|
||||||
types-flask>=1.1.0
|
types-flask>=1.1.0
|
||||||
|
|||||||
@@ -1,17 +1,57 @@
|
|||||||
# Core dependencies
|
# Core dependencies
|
||||||
flask>=2.0.0
|
flask>=3.0.0
|
||||||
|
flask-wtf>=1.2.0
|
||||||
|
flask-compress>=1.15
|
||||||
|
flask-limiter>=2.5.4
|
||||||
requests>=2.28.0
|
requests>=2.28.0
|
||||||
|
Werkzeug>=3.1.5
|
||||||
|
|
||||||
|
# ADS-B history (optional - only needed for Postgres persistence)
|
||||||
|
psycopg2-binary>=2.9.9
|
||||||
|
|
||||||
|
# BLE scanning with manufacturer data detection (optional - for TSCM)
|
||||||
|
bleak>=0.21.0
|
||||||
|
|
||||||
# Satellite tracking (optional - only needed for satellite features)
|
# Satellite tracking (optional - only needed for satellite features)
|
||||||
skyfield>=1.45
|
skyfield>=1.45
|
||||||
|
|
||||||
|
# DSC decoding and SSTV decoding (DSP pipeline)
|
||||||
|
scipy>=1.10.0
|
||||||
|
numpy>=1.24.0
|
||||||
|
|
||||||
|
# SSTV image output (optional - needed for SSTV image decoding)
|
||||||
|
Pillow>=9.0.0
|
||||||
|
|
||||||
# GPS dongle support (optional - only needed for USB GPS receivers)
|
# GPS dongle support (optional - only needed for USB GPS receivers)
|
||||||
pyserial>=3.5
|
pyserial>=3.5
|
||||||
|
|
||||||
|
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
|
||||||
|
meshtastic>=2.0.0
|
||||||
|
meshcore>=1.0.0
|
||||||
|
|
||||||
|
# Deauthentication attack detection (optional - for WiFi TSCM)
|
||||||
|
scapy>=2.4.5
|
||||||
|
|
||||||
|
# QR code generation for Meshtastic channels (optional)
|
||||||
|
qrcode[pil]>=7.4
|
||||||
|
|
||||||
|
# BLE RPA resolution for BT Locate (optional - for SAR device tracking)
|
||||||
|
cryptography>=41.0.0
|
||||||
|
|
||||||
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
||||||
# pytest>=7.0.0
|
# pytest>=7.0.0
|
||||||
# pytest-cov>=4.0.0
|
# pytest-cov>=4.0.0
|
||||||
# ruff>=0.1.0
|
# ruff>=0.1.0
|
||||||
# black>=23.0.0
|
# black>=23.0.0
|
||||||
# mypy>=1.0.0
|
# mypy>=1.0.0
|
||||||
flask-sock
|
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
||||||
|
flask-sock
|
||||||
|
simple-websocket>=0.5.1
|
||||||
|
websocket-client>=1.6.0
|
||||||
|
|
||||||
|
# System health monitoring (optional - graceful fallback if unavailable)
|
||||||
|
psutil>=5.9.0
|
||||||
|
|
||||||
|
# Production WSGI server (optional - falls back to Flask dev server)
|
||||||
|
gunicorn>=21.2.0
|
||||||
|
gevent>=23.9.0
|
||||||
|
|||||||
@@ -1,25 +1,108 @@
|
|||||||
# Routes package - registers all blueprints with the Flask app
|
# Routes package - registers all blueprints with the Flask app
|
||||||
|
|
||||||
|
|
||||||
def register_blueprints(app):
|
def register_blueprints(app):
|
||||||
"""Register all route blueprints with the Flask app."""
|
"""Register all route blueprints with the Flask app."""
|
||||||
from .pager import pager_bp
|
# Import CSRF to exempt API blueprints (they use JSON, not form tokens)
|
||||||
from .sensor import sensor_bp
|
try:
|
||||||
from .wifi import wifi_bp
|
from app import csrf as _csrf
|
||||||
from .bluetooth import bluetooth_bp
|
except ImportError:
|
||||||
|
_csrf = None
|
||||||
|
from .acars import acars_bp
|
||||||
from .adsb import adsb_bp
|
from .adsb import adsb_bp
|
||||||
from .satellite import satellite_bp
|
from .ais import ais_bp
|
||||||
from .gps import gps_bp
|
from .alerts import alerts_bp
|
||||||
from .settings import settings_bp
|
from .aprs import aprs_bp
|
||||||
|
from .bluetooth import bluetooth_bp
|
||||||
|
from .bluetooth_v2 import bluetooth_v2_bp
|
||||||
|
from .bt_locate import bt_locate_bp
|
||||||
|
from .controller import controller_bp
|
||||||
from .correlation import correlation_bp
|
from .correlation import correlation_bp
|
||||||
from .listening_post import listening_post_bp
|
from .drone import drone_bp
|
||||||
|
from .dsc import dsc_bp
|
||||||
|
from .gps import gps_bp
|
||||||
|
from .ground_station import ground_station_bp
|
||||||
|
from .listening_post import receiver_bp
|
||||||
|
from .meshcore import meshcore_bp
|
||||||
|
from .meshtastic import meshtastic_bp
|
||||||
|
from .meteor_websocket import meteor_bp
|
||||||
|
from .morse import morse_bp
|
||||||
|
from .offline import offline_bp
|
||||||
|
from .ook import ook_bp
|
||||||
|
from .pager import pager_bp
|
||||||
|
from .radiosonde import radiosonde_bp
|
||||||
|
from .recordings import recordings_bp
|
||||||
|
from .rtlamr import rtlamr_bp
|
||||||
|
from .satellite import satellite_bp
|
||||||
|
from .sensor import sensor_bp
|
||||||
|
from .settings import settings_bp
|
||||||
|
from .signalid import signalid_bp
|
||||||
|
from .space_weather import space_weather_bp
|
||||||
|
from .spy_stations import spy_stations_bp
|
||||||
|
from .sstv import sstv_bp
|
||||||
|
from .sstv_general import sstv_general_bp
|
||||||
|
from .subghz import subghz_bp
|
||||||
|
from .system import system_bp
|
||||||
|
from .tscm import init_tscm_state, tscm_bp
|
||||||
|
from .updater import updater_bp
|
||||||
|
from .vdl2 import vdl2_bp
|
||||||
|
from .weather_sat import weather_sat_bp
|
||||||
|
from .websdr import websdr_bp
|
||||||
|
from .wefax import wefax_bp
|
||||||
|
from .wifi import wifi_bp
|
||||||
|
from .wifi_v2 import wifi_v2_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
|
app.register_blueprint(rtlamr_bp)
|
||||||
app.register_blueprint(wifi_bp)
|
app.register_blueprint(wifi_bp)
|
||||||
|
app.register_blueprint(wifi_v2_bp) # New unified WiFi API
|
||||||
app.register_blueprint(bluetooth_bp)
|
app.register_blueprint(bluetooth_bp)
|
||||||
|
app.register_blueprint(bluetooth_v2_bp) # New unified Bluetooth API
|
||||||
app.register_blueprint(adsb_bp)
|
app.register_blueprint(adsb_bp)
|
||||||
|
app.register_blueprint(ais_bp)
|
||||||
|
app.register_blueprint(dsc_bp) # VHF DSC maritime distress
|
||||||
|
app.register_blueprint(acars_bp)
|
||||||
|
app.register_blueprint(vdl2_bp)
|
||||||
|
app.register_blueprint(aprs_bp)
|
||||||
app.register_blueprint(satellite_bp)
|
app.register_blueprint(satellite_bp)
|
||||||
app.register_blueprint(gps_bp)
|
app.register_blueprint(gps_bp)
|
||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
app.register_blueprint(correlation_bp)
|
app.register_blueprint(correlation_bp)
|
||||||
app.register_blueprint(listening_post_bp)
|
app.register_blueprint(receiver_bp)
|
||||||
|
app.register_blueprint(meshtastic_bp)
|
||||||
|
app.register_blueprint(meshcore_bp)
|
||||||
|
app.register_blueprint(tscm_bp)
|
||||||
|
app.register_blueprint(spy_stations_bp)
|
||||||
|
app.register_blueprint(controller_bp) # Remote agent controller
|
||||||
|
app.register_blueprint(offline_bp) # Offline mode settings
|
||||||
|
app.register_blueprint(updater_bp) # GitHub update checking
|
||||||
|
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
||||||
|
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
|
||||||
|
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
||||||
|
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
||||||
|
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||||
|
app.register_blueprint(recordings_bp) # Session recordings
|
||||||
|
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||||
|
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||||
|
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
||||||
|
app.register_blueprint(signalid_bp) # External signal ID enrichment
|
||||||
|
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
|
||||||
|
app.register_blueprint(meteor_bp) # Meteor scatter detection
|
||||||
|
app.register_blueprint(morse_bp) # CW/Morse code decoder
|
||||||
|
app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking
|
||||||
|
app.register_blueprint(system_bp) # System health monitoring
|
||||||
|
app.register_blueprint(ook_bp) # Generic OOK signal decoder
|
||||||
|
app.register_blueprint(ground_station_bp) # Ground station automation
|
||||||
|
app.register_blueprint(drone_bp) # Drone intelligence / UAV detection
|
||||||
|
|
||||||
|
# Exempt all API blueprints from CSRF (they use JSON, not form tokens)
|
||||||
|
if _csrf:
|
||||||
|
for bp in app.blueprints.values():
|
||||||
|
_csrf.exempt(bp)
|
||||||
|
|
||||||
|
# Initialize TSCM state with queue and lock from app
|
||||||
|
import app as app_module
|
||||||
|
|
||||||
|
if hasattr(app_module, "tscm_queue") and hasattr(app_module, "tscm_lock"):
|
||||||
|
init_tscm_state(app_module.tscm_queue, app_module.tscm_lock)
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
"""ACARS aircraft messaging routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import pty
|
||||||
|
import queue
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
from utils.acars_translator import translate_message
|
||||||
|
from utils.constants import (
|
||||||
|
PROCESS_START_WAIT,
|
||||||
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
)
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.flight_correlator import get_flight_correlator
|
||||||
|
from utils.logging import sensor_logger as logger
|
||||||
|
from utils.process import register_process, unregister_process
|
||||||
|
from utils.responses import api_error
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
|
|
||||||
|
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
||||||
|
|
||||||
|
# Default VHF ACARS frequencies (MHz) - North America primary
|
||||||
|
DEFAULT_ACARS_FREQUENCIES = [
|
||||||
|
'131.550', # Primary worldwide / North America
|
||||||
|
'130.025', # North America secondary
|
||||||
|
'129.125', # North America tertiary
|
||||||
|
'131.725', # North America (major US carriers)
|
||||||
|
'131.825', # North America (major US carriers)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Message counter for statistics
|
||||||
|
acars_message_count = 0
|
||||||
|
acars_last_message_time = None
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
acars_active_device: int | None = None
|
||||||
|
acars_active_sdr_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def find_acarsdec():
|
||||||
|
"""Find acarsdec binary."""
|
||||||
|
return shutil.which('acarsdec')
|
||||||
|
|
||||||
|
|
||||||
|
def get_acarsdec_json_flag(acarsdec_path: str) -> str:
|
||||||
|
"""Detect which JSON output flag acarsdec supports.
|
||||||
|
|
||||||
|
Different forks use different flags:
|
||||||
|
- TLeconte v4.0+: uses -j for JSON stdout
|
||||||
|
- TLeconte v3.x: uses -o 4 for JSON stdout
|
||||||
|
- f00b4r0 fork (DragonOS): uses --output json:file:- for JSON stdout
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get help/version by running acarsdec with no args (shows usage)
|
||||||
|
result = subprocess.run(
|
||||||
|
[acarsdec_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
output = result.stdout + result.stderr
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Check for f00b4r0 fork signature: uses --output instead of -j/-o
|
||||||
|
# f00b4r0's help shows "--output" for output configuration
|
||||||
|
if '--output' in output or 'json:file:' in output.lower():
|
||||||
|
logger.debug("Detected f00b4r0 acarsdec fork (--output syntax)")
|
||||||
|
return '--output'
|
||||||
|
|
||||||
|
# Parse version from output like "Acarsdec v4.3.1" or "Acarsdec/acarsserv 3.7"
|
||||||
|
version_match = re.search(r'acarsdec[^\d]*v?(\d+)\.(\d+)', output, re.IGNORECASE)
|
||||||
|
if version_match:
|
||||||
|
major = int(version_match.group(1))
|
||||||
|
# Version 4.0+ uses -j for JSON stdout
|
||||||
|
if major >= 4:
|
||||||
|
return '-j'
|
||||||
|
# Version 3.x uses -o for output mode
|
||||||
|
else:
|
||||||
|
return '-o'
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not detect acarsdec version: {e}")
|
||||||
|
|
||||||
|
# Default to -j (TLeconte modern standard)
|
||||||
|
return '-j'
|
||||||
|
|
||||||
|
|
||||||
|
def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
|
||||||
|
"""Stream acarsdec JSON output to queue."""
|
||||||
|
global acars_message_count, acars_last_message_time
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_module.acars_queue.put({'type': 'status', 'status': 'started'})
|
||||||
|
|
||||||
|
# Use appropriate sentinel based on mode (text mode for pty on macOS)
|
||||||
|
sentinel = '' if is_text_mode else b''
|
||||||
|
for line in iter(process.stdout.readline, sentinel):
|
||||||
|
if is_text_mode:
|
||||||
|
line = line.strip()
|
||||||
|
else:
|
||||||
|
line = line.decode('utf-8', errors='replace').strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# acarsdec -o 4 outputs JSON, one message per line
|
||||||
|
data = json.loads(line)
|
||||||
|
|
||||||
|
# Add our metadata
|
||||||
|
data['type'] = 'acars'
|
||||||
|
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
||||||
|
|
||||||
|
# Enrich with translated label and parsed fields
|
||||||
|
try:
|
||||||
|
translation = translate_message(data)
|
||||||
|
data['label_description'] = translation['label_description']
|
||||||
|
data['message_type'] = translation['message_type']
|
||||||
|
data['parsed'] = translation['parsed']
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Update stats
|
||||||
|
acars_message_count += 1
|
||||||
|
acars_last_message_time = time.time()
|
||||||
|
|
||||||
|
app_module.acars_queue.put(data)
|
||||||
|
|
||||||
|
# Feed flight correlator
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
get_flight_correlator().add_acars_message(data)
|
||||||
|
|
||||||
|
# Log if enabled
|
||||||
|
if app_module.logging_enabled:
|
||||||
|
try:
|
||||||
|
with open(app_module.log_file_path, 'a') as f:
|
||||||
|
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
f.write(f"{ts} | ACARS | {json.dumps(data)}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Not JSON - could be status message
|
||||||
|
if line:
|
||||||
|
logger.debug(f"acarsdec non-JSON: {line[:100]}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"ACARS stream error: {e}")
|
||||||
|
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
|
||||||
|
finally:
|
||||||
|
global acars_active_device, acars_active_sdr_type
|
||||||
|
# Ensure process is terminated
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
process.kill()
|
||||||
|
unregister_process(process)
|
||||||
|
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
|
with app_module.acars_lock:
|
||||||
|
app_module.acars_process = None
|
||||||
|
# Release SDR device
|
||||||
|
if acars_active_device is not None:
|
||||||
|
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||||
|
acars_active_device = None
|
||||||
|
acars_active_sdr_type = None
|
||||||
|
|
||||||
|
|
||||||
|
@acars_bp.route('/tools')
|
||||||
|
def check_acars_tools() -> Response:
|
||||||
|
"""Check for ACARS decoding tools."""
|
||||||
|
has_acarsdec = find_acarsdec() is not None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'acarsdec': has_acarsdec,
|
||||||
|
'ready': has_acarsdec
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@acars_bp.route('/status')
|
||||||
|
def acars_status() -> Response:
|
||||||
|
"""Get ACARS decoder status."""
|
||||||
|
running = False
|
||||||
|
if app_module.acars_process:
|
||||||
|
running = app_module.acars_process.poll() is None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'running': running,
|
||||||
|
'message_count': acars_message_count,
|
||||||
|
'last_message_time': acars_last_message_time,
|
||||||
|
'queue_size': app_module.acars_queue.qsize()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@acars_bp.route('/start', methods=['POST'])
|
||||||
|
def start_acars() -> Response:
|
||||||
|
"""Start ACARS decoder."""
|
||||||
|
global acars_message_count, acars_last_message_time, acars_active_device, acars_active_sdr_type
|
||||||
|
|
||||||
|
with app_module.acars_lock:
|
||||||
|
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||||
|
return api_error('ACARS decoder already running', 409)
|
||||||
|
|
||||||
|
# Check for acarsdec
|
||||||
|
acarsdec_path = find_acarsdec()
|
||||||
|
if not acarsdec_path:
|
||||||
|
return api_error('acarsdec not found. Install with: sudo apt install acarsdec', 400)
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
try:
|
||||||
|
device = validate_device_index(data.get('device', '0'))
|
||||||
|
gain = validate_gain(data.get('gain', '40'))
|
||||||
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
|
except ValueError as e:
|
||||||
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
|
# Resolve SDR type for device selection
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
|
||||||
|
# Check if device is available
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str)
|
||||||
|
if error:
|
||||||
|
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||||
|
|
||||||
|
acars_active_device = device_int
|
||||||
|
acars_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
|
# Get frequencies - use provided or defaults
|
||||||
|
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
|
||||||
|
if isinstance(frequencies, str):
|
||||||
|
frequencies = [f.strip() for f in frequencies.split(',')]
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not app_module.acars_queue.empty():
|
||||||
|
try:
|
||||||
|
app_module.acars_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Reset stats
|
||||||
|
acars_message_count = 0
|
||||||
|
acars_last_message_time = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
|
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||||
|
|
||||||
|
# Build acarsdec command
|
||||||
|
# Different forks have different syntax:
|
||||||
|
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
|
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
|
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
|
||||||
|
# SoapySDR devices: TLeconte uses -d <device_string>, f00b4r0 uses --soapysdr <device_string>
|
||||||
|
# Note: gain/ppm must come BEFORE -r/-d
|
||||||
|
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
||||||
|
cmd = [acarsdec_path]
|
||||||
|
if json_flag == '--output':
|
||||||
|
# f00b4r0 fork: --output json:file (no path = stdout)
|
||||||
|
cmd.extend(['--output', 'json:file'])
|
||||||
|
elif json_flag == '-j':
|
||||||
|
cmd.append('-j') # JSON output (TLeconte v4+)
|
||||||
|
else:
|
||||||
|
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
|
||||||
|
|
||||||
|
# Add gain if not auto (must be before -r/-d)
|
||||||
|
if gain and str(gain) != '0':
|
||||||
|
cmd.extend(['-g', str(gain)])
|
||||||
|
|
||||||
|
# Add PPM correction if specified (must be before -r/-d)
|
||||||
|
if ppm and str(ppm) != '0':
|
||||||
|
cmd.extend(['-p', str(ppm)])
|
||||||
|
|
||||||
|
# Add device and frequencies
|
||||||
|
if is_soapy:
|
||||||
|
# SoapySDR device (SDRplay, LimeSDR, Airspy, etc.)
|
||||||
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
|
||||||
|
# Build SoapySDR driver string (e.g., "driver=sdrplay,serial=...")
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
device_str = builder._build_device_string(sdr_device)
|
||||||
|
if json_flag == '--output':
|
||||||
|
cmd.extend(['-m', '256'])
|
||||||
|
cmd.extend(['--soapysdr', device_str])
|
||||||
|
else:
|
||||||
|
cmd.extend(['-d', device_str])
|
||||||
|
elif json_flag == '--output':
|
||||||
|
# f00b4r0 fork RTL-SDR: --rtlsdr <device>
|
||||||
|
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
|
||||||
|
cmd.extend(['-m', '256'])
|
||||||
|
cmd.extend(['--rtlsdr', str(device)])
|
||||||
|
else:
|
||||||
|
# TLeconte fork RTL-SDR: -r <device>
|
||||||
|
cmd.extend(['-r', str(device)])
|
||||||
|
cmd.extend(frequencies)
|
||||||
|
|
||||||
|
logger.info(f"Starting ACARS decoder: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
is_text_mode = False
|
||||||
|
|
||||||
|
# On macOS, use pty to avoid stdout buffering issues
|
||||||
|
if platform.system() == 'Darwin':
|
||||||
|
master_fd, slave_fd = pty.openpty()
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=slave_fd,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
os.close(slave_fd)
|
||||||
|
# Wrap master_fd as a text file for line-buffered reading
|
||||||
|
process.stdout = open(master_fd, buffering=1)
|
||||||
|
is_text_mode = True
|
||||||
|
else:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait briefly to check if process started
|
||||||
|
time.sleep(PROCESS_START_WAIT)
|
||||||
|
|
||||||
|
if process.poll() is not None:
|
||||||
|
# Process died - release device
|
||||||
|
if acars_active_device is not None:
|
||||||
|
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||||
|
acars_active_device = None
|
||||||
|
acars_active_sdr_type = None
|
||||||
|
stderr = ''
|
||||||
|
if process.stderr:
|
||||||
|
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||||
|
if stderr:
|
||||||
|
logger.error(f"acarsdec stderr:\n{stderr}")
|
||||||
|
error_msg = 'acarsdec failed to start'
|
||||||
|
if stderr:
|
||||||
|
error_msg += f': {stderr[:500]}'
|
||||||
|
logger.error(error_msg)
|
||||||
|
return api_error(error_msg, 500)
|
||||||
|
|
||||||
|
app_module.acars_process = process
|
||||||
|
register_process(process)
|
||||||
|
|
||||||
|
# Start output streaming thread
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=stream_acars_output,
|
||||||
|
args=(process, is_text_mode),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'frequencies': frequencies,
|
||||||
|
'device': device,
|
||||||
|
'gain': gain
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
if acars_active_device is not None:
|
||||||
|
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||||
|
acars_active_device = None
|
||||||
|
acars_active_sdr_type = None
|
||||||
|
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||||
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@acars_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_acars() -> Response:
|
||||||
|
"""Stop ACARS decoder."""
|
||||||
|
global acars_active_device, acars_active_sdr_type
|
||||||
|
|
||||||
|
with app_module.acars_lock:
|
||||||
|
if not app_module.acars_process:
|
||||||
|
return api_error('ACARS decoder not running', 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_module.acars_process.terminate()
|
||||||
|
app_module.acars_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
app_module.acars_process.kill()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping ACARS: {e}")
|
||||||
|
|
||||||
|
app_module.acars_process = None
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if acars_active_device is not None:
|
||||||
|
app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr')
|
||||||
|
acars_active_device = None
|
||||||
|
acars_active_sdr_type = None
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@acars_bp.route('/stream')
|
||||||
|
def stream_acars() -> Response:
|
||||||
|
"""SSE stream for ACARS messages."""
|
||||||
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
|
process_event('acars', msg, msg.get('type'))
|
||||||
|
|
||||||
|
response = Response(
|
||||||
|
sse_stream_fanout(
|
||||||
|
source_queue=app_module.acars_queue,
|
||||||
|
channel_key='acars',
|
||||||
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
|
on_message=_on_msg,
|
||||||
|
),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
)
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@acars_bp.route('/messages')
|
||||||
|
def get_acars_messages() -> Response:
|
||||||
|
"""Get recent ACARS messages from correlator (for history reload)."""
|
||||||
|
limit = request.args.get('limit', 50, type=int)
|
||||||
|
limit = max(1, min(limit, 200))
|
||||||
|
msgs = get_flight_correlator().get_recent_messages('acars', limit)
|
||||||
|
return jsonify(msgs)
|
||||||
|
|
||||||
|
|
||||||
|
@acars_bp.route('/clear', methods=['POST'])
|
||||||
|
def clear_acars_messages() -> Response:
|
||||||
|
"""Clear stored ACARS messages and reset counter."""
|
||||||
|
global acars_message_count, acars_last_message_time
|
||||||
|
get_flight_correlator().clear_acars()
|
||||||
|
acars_message_count = 0
|
||||||
|
acars_last_message_time = None
|
||||||
|
return jsonify({'status': 'cleared'})
|
||||||
|
|
||||||
|
|
||||||
|
@acars_bp.route('/frequencies')
|
||||||
|
def get_frequencies() -> Response:
|
||||||
|
"""Get default ACARS frequencies."""
|
||||||
|
return jsonify({
|
||||||
|
'default': DEFAULT_ACARS_FREQUENCIES,
|
||||||
|
'regions': {
|
||||||
|
'north_america': ['131.550', '130.025', '129.125', '131.725', '131.825'],
|
||||||
|
'europe': ['131.525', '131.725', '131.550'],
|
||||||
|
'asia_pacific': ['131.550', '131.450'],
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
"""AIS vessel tracking routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, render_template, request
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE, SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
|
from utils.constants import (
|
||||||
|
AIS_RECONNECT_DELAY,
|
||||||
|
AIS_SOCKET_TIMEOUT,
|
||||||
|
AIS_TCP_PORT,
|
||||||
|
AIS_TERMINATE_TIMEOUT,
|
||||||
|
AIS_UPDATE_INTERVAL,
|
||||||
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
|
SOCKET_BUFFER_SIZE,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
)
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.responses import api_error, api_success
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
from utils.validation import validate_device_index, validate_gain
|
||||||
|
|
||||||
|
logger = get_logger('intercept.ais')
|
||||||
|
|
||||||
|
ais_bp = Blueprint('ais', __name__, url_prefix='/ais')
|
||||||
|
|
||||||
|
# Track AIS state
|
||||||
|
ais_running = False
|
||||||
|
ais_connected = False
|
||||||
|
ais_messages_received = 0
|
||||||
|
ais_last_message_time = None
|
||||||
|
ais_active_device = None
|
||||||
|
ais_active_sdr_type: str | None = None
|
||||||
|
_ais_error_logged = True
|
||||||
|
|
||||||
|
# Common installation paths for AIS-catcher
|
||||||
|
AIS_CATCHER_PATHS = [
|
||||||
|
'/usr/local/bin/AIS-catcher',
|
||||||
|
'/usr/bin/AIS-catcher',
|
||||||
|
'/opt/homebrew/bin/AIS-catcher',
|
||||||
|
'/opt/homebrew/bin/aiscatcher',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def find_ais_catcher():
|
||||||
|
"""Find AIS-catcher binary, checking PATH and common locations."""
|
||||||
|
# First try PATH
|
||||||
|
for name in ['AIS-catcher', 'aiscatcher']:
|
||||||
|
path = shutil.which(name)
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
# Check common installation paths
|
||||||
|
for path in AIS_CATCHER_PATHS:
|
||||||
|
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ais_stream(port: int):
|
||||||
|
"""Parse JSON data from AIS-catcher TCP server."""
|
||||||
|
global ais_running, ais_connected, ais_messages_received, ais_last_message_time, _ais_error_logged
|
||||||
|
|
||||||
|
logger.info(f"AIS stream parser started, connecting to localhost:{port}")
|
||||||
|
ais_connected = True
|
||||||
|
ais_messages_received = 0
|
||||||
|
_ais_error_logged = True
|
||||||
|
|
||||||
|
while ais_running:
|
||||||
|
sock = None
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(AIS_SOCKET_TIMEOUT)
|
||||||
|
sock.connect(('localhost', port))
|
||||||
|
ais_connected = True
|
||||||
|
_ais_error_logged = True
|
||||||
|
logger.info("Connected to AIS-catcher TCP server")
|
||||||
|
|
||||||
|
buffer = ""
|
||||||
|
last_update = time.time()
|
||||||
|
pending_updates = set()
|
||||||
|
|
||||||
|
while ais_running:
|
||||||
|
try:
|
||||||
|
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
|
||||||
|
if not data:
|
||||||
|
logger.warning("AIS connection closed (no data)")
|
||||||
|
break
|
||||||
|
buffer += data
|
||||||
|
|
||||||
|
while '\n' in buffer:
|
||||||
|
line, buffer = buffer.split('\n', 1)
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = json.loads(line)
|
||||||
|
vessel = process_ais_message(msg)
|
||||||
|
if vessel:
|
||||||
|
mmsi = vessel.get('mmsi')
|
||||||
|
if mmsi:
|
||||||
|
app_module.ais_vessels.set(mmsi, vessel)
|
||||||
|
pending_updates.add(mmsi)
|
||||||
|
ais_messages_received += 1
|
||||||
|
ais_last_message_time = time.time()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
if ais_messages_received < 5:
|
||||||
|
logger.debug(f"Invalid JSON: {line[:100]}")
|
||||||
|
|
||||||
|
# Batch updates
|
||||||
|
now = time.time()
|
||||||
|
if now - last_update >= AIS_UPDATE_INTERVAL:
|
||||||
|
for mmsi in pending_updates:
|
||||||
|
if mmsi in app_module.ais_vessels:
|
||||||
|
_vessel_snap = app_module.ais_vessels[mmsi]
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
app_module.ais_queue.put_nowait({
|
||||||
|
'type': 'vessel',
|
||||||
|
**_vessel_snap
|
||||||
|
})
|
||||||
|
# Geofence check
|
||||||
|
_v_lat = _vessel_snap.get('lat')
|
||||||
|
_v_lon = _vessel_snap.get('lon')
|
||||||
|
if _v_lat and _v_lon:
|
||||||
|
try:
|
||||||
|
from utils.geofence import get_geofence_manager
|
||||||
|
for _gf_evt in get_geofence_manager().check_position(
|
||||||
|
mmsi, 'vessel', _v_lat, _v_lon,
|
||||||
|
{'name': _vessel_snap.get('name'), 'ship_type': _vessel_snap.get('ship_type_text')}
|
||||||
|
):
|
||||||
|
process_event('ais', _gf_evt, 'geofence')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
pending_updates.clear()
|
||||||
|
last_update = now
|
||||||
|
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ais_connected = False
|
||||||
|
except OSError as e:
|
||||||
|
ais_connected = False
|
||||||
|
if not _ais_error_logged:
|
||||||
|
logger.warning(f"AIS connection error: {e}, reconnecting...")
|
||||||
|
_ais_error_logged = True
|
||||||
|
time.sleep(AIS_RECONNECT_DELAY)
|
||||||
|
finally:
|
||||||
|
if sock:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
ais_connected = False
|
||||||
|
logger.info("AIS stream parser stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def process_ais_message(msg: dict) -> dict | None:
|
||||||
|
"""Process AIS-catcher JSON message and extract vessel data."""
|
||||||
|
# AIS-catcher outputs different message types
|
||||||
|
# We're interested in position reports and static data
|
||||||
|
|
||||||
|
mmsi = msg.get('mmsi')
|
||||||
|
if not mmsi:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mmsi = str(mmsi)
|
||||||
|
|
||||||
|
# Get existing vessel data or create new
|
||||||
|
vessel = app_module.ais_vessels.get(mmsi) or {'mmsi': mmsi}
|
||||||
|
|
||||||
|
# Extract common fields
|
||||||
|
# AIS-catcher JSON_FULL uses 'longitude'/'latitude', but some versions use 'lon'/'lat'
|
||||||
|
lat_val = msg.get('latitude') or msg.get('lat')
|
||||||
|
lon_val = msg.get('longitude') or msg.get('lon')
|
||||||
|
if lat_val is not None and lon_val is not None:
|
||||||
|
try:
|
||||||
|
lat = float(lat_val)
|
||||||
|
lon = float(lon_val)
|
||||||
|
# Validate coordinates (AIS uses 181 for unavailable)
|
||||||
|
if -90 <= lat <= 90 and -180 <= lon <= 180:
|
||||||
|
vessel['lat'] = lat
|
||||||
|
vessel['lon'] = lon
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Speed over ground (knots)
|
||||||
|
if 'speed' in msg:
|
||||||
|
try:
|
||||||
|
speed = float(msg['speed'])
|
||||||
|
if speed < 102.3: # 102.3 = not available
|
||||||
|
vessel['speed'] = round(speed, 1)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Course over ground (degrees)
|
||||||
|
if 'course' in msg:
|
||||||
|
try:
|
||||||
|
course = float(msg['course'])
|
||||||
|
if course < 360: # 360 = not available
|
||||||
|
vessel['course'] = round(course, 1)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# True heading (degrees)
|
||||||
|
if 'heading' in msg:
|
||||||
|
try:
|
||||||
|
heading = int(msg['heading'])
|
||||||
|
if heading < 511: # 511 = not available
|
||||||
|
vessel['heading'] = heading
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Navigation status
|
||||||
|
if 'status' in msg:
|
||||||
|
vessel['nav_status'] = msg['status']
|
||||||
|
if 'status_text' in msg:
|
||||||
|
vessel['nav_status_text'] = msg['status_text']
|
||||||
|
|
||||||
|
# Vessel name (from Type 5 or Type 24 messages)
|
||||||
|
if 'shipname' in msg:
|
||||||
|
name = msg['shipname'].strip().strip('@')
|
||||||
|
if name:
|
||||||
|
vessel['name'] = name
|
||||||
|
|
||||||
|
# Callsign
|
||||||
|
if 'callsign' in msg:
|
||||||
|
callsign = msg['callsign'].strip().strip('@')
|
||||||
|
if callsign:
|
||||||
|
vessel['callsign'] = callsign
|
||||||
|
|
||||||
|
# Ship type
|
||||||
|
if 'shiptype' in msg:
|
||||||
|
vessel['ship_type'] = msg['shiptype']
|
||||||
|
if 'shiptype_text' in msg:
|
||||||
|
vessel['ship_type_text'] = msg['shiptype_text']
|
||||||
|
|
||||||
|
# Destination
|
||||||
|
if 'destination' in msg:
|
||||||
|
dest = msg['destination'].strip().strip('@')
|
||||||
|
if dest:
|
||||||
|
vessel['destination'] = dest
|
||||||
|
|
||||||
|
# ETA
|
||||||
|
if 'eta' in msg:
|
||||||
|
vessel['eta'] = msg['eta']
|
||||||
|
|
||||||
|
# Dimensions
|
||||||
|
if 'to_bow' in msg and 'to_stern' in msg:
|
||||||
|
try:
|
||||||
|
length = int(msg['to_bow']) + int(msg['to_stern'])
|
||||||
|
if length > 0:
|
||||||
|
vessel['length'] = length
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if 'to_port' in msg and 'to_starboard' in msg:
|
||||||
|
try:
|
||||||
|
width = int(msg['to_port']) + int(msg['to_starboard'])
|
||||||
|
if width > 0:
|
||||||
|
vessel['width'] = width
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Draught
|
||||||
|
if 'draught' in msg:
|
||||||
|
try:
|
||||||
|
draught = float(msg['draught'])
|
||||||
|
if draught > 0:
|
||||||
|
vessel['draught'] = draught
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Rate of turn
|
||||||
|
if 'turn' in msg:
|
||||||
|
try:
|
||||||
|
turn = float(msg['turn'])
|
||||||
|
if -127 <= turn <= 127: # Valid range
|
||||||
|
vessel['rate_of_turn'] = turn
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Message type for debugging
|
||||||
|
if 'type' in msg:
|
||||||
|
vessel['last_msg_type'] = msg['type']
|
||||||
|
|
||||||
|
# Timestamp
|
||||||
|
vessel['last_seen'] = time.time()
|
||||||
|
|
||||||
|
# Check for DSC DISTRESS matching this MMSI
|
||||||
|
try:
|
||||||
|
for _dsc_key, _dsc_msg in app_module.dsc_messages.items():
|
||||||
|
if (str(_dsc_msg.get('source_mmsi', '')) == mmsi
|
||||||
|
and _dsc_msg.get('category', '').upper() == 'DISTRESS'):
|
||||||
|
vessel['dsc_distress'] = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return vessel
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/tools')
|
||||||
|
def check_ais_tools():
|
||||||
|
"""Check for AIS decoding tools and hardware."""
|
||||||
|
has_ais_catcher = find_ais_catcher() is not None
|
||||||
|
|
||||||
|
# Check what SDR hardware is detected
|
||||||
|
devices = SDRFactory.detect_devices()
|
||||||
|
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'ais_catcher': has_ais_catcher,
|
||||||
|
'ais_catcher_path': find_ais_catcher(),
|
||||||
|
'has_rtlsdr': has_rtlsdr,
|
||||||
|
'device_count': len(devices)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/status')
|
||||||
|
def ais_status():
|
||||||
|
"""Get AIS tracking status for debugging."""
|
||||||
|
process_running = False
|
||||||
|
if app_module.ais_process:
|
||||||
|
process_running = app_module.ais_process.poll() is None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'tracking_active': ais_running,
|
||||||
|
'active_device': ais_active_device,
|
||||||
|
'connected': ais_connected,
|
||||||
|
'messages_received': ais_messages_received,
|
||||||
|
'last_message_time': ais_last_message_time,
|
||||||
|
'vessel_count': len(app_module.ais_vessels),
|
||||||
|
'vessels': dict(app_module.ais_vessels),
|
||||||
|
'queue_size': app_module.ais_queue.qsize(),
|
||||||
|
'ais_catcher_path': find_ais_catcher(),
|
||||||
|
'process_running': process_running
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/start', methods=['POST'])
|
||||||
|
def start_ais():
|
||||||
|
"""Start AIS tracking."""
|
||||||
|
global ais_running, ais_active_device, ais_active_sdr_type
|
||||||
|
|
||||||
|
with app_module.ais_lock:
|
||||||
|
if ais_running:
|
||||||
|
return api_error('AIS tracking already active', 409)
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
try:
|
||||||
|
gain = int(validate_gain(data.get('gain', '40')))
|
||||||
|
device = validate_device_index(data.get('device', '0'))
|
||||||
|
except ValueError as e:
|
||||||
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
|
# Find AIS-catcher
|
||||||
|
ais_catcher_path = find_ais_catcher()
|
||||||
|
if not ais_catcher_path:
|
||||||
|
return api_error('AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases', 400)
|
||||||
|
|
||||||
|
# Get SDR type from request
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
|
# Kill any existing process
|
||||||
|
if app_module.ais_process:
|
||||||
|
try:
|
||||||
|
pgid = os.getpgid(app_module.ais_process.pid)
|
||||||
|
os.killpg(pgid, 15)
|
||||||
|
app_module.ais_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||||
|
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||||
|
try:
|
||||||
|
pgid = os.getpgid(app_module.ais_process.pid)
|
||||||
|
os.killpg(pgid, 9)
|
||||||
|
except (ProcessLookupError, OSError):
|
||||||
|
pass
|
||||||
|
app_module.ais_process = None
|
||||||
|
logger.info("Killed existing AIS process")
|
||||||
|
|
||||||
|
# Check if device is available
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str)
|
||||||
|
if error:
|
||||||
|
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||||
|
|
||||||
|
# Build command using SDR abstraction
|
||||||
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
|
||||||
|
bias_t = data.get('bias_t', False)
|
||||||
|
tcp_port = AIS_TCP_PORT
|
||||||
|
|
||||||
|
# Optional UDP NMEA forwarding (e.g. for OpenCPN on port 10110)
|
||||||
|
udp_host = data.get('udp_host') or None
|
||||||
|
udp_port = None
|
||||||
|
if udp_host:
|
||||||
|
try:
|
||||||
|
udp_port = int(data.get('udp_port', 10110))
|
||||||
|
if not 1 <= udp_port <= 65535:
|
||||||
|
raise ValueError
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return api_error('Invalid udp_port (1-65535)', 400)
|
||||||
|
|
||||||
|
cmd = builder.build_ais_command(
|
||||||
|
device=sdr_device,
|
||||||
|
gain=float(gain),
|
||||||
|
bias_t=bias_t,
|
||||||
|
tcp_port=tcp_port,
|
||||||
|
udp_host=udp_host,
|
||||||
|
udp_port=udp_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use the found AIS-catcher path
|
||||||
|
cmd[0] = ais_catcher_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Starting AIS-catcher with device {device}: {' '.join(cmd)}")
|
||||||
|
app_module.ais_process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for process to start
|
||||||
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
if app_module.ais_process.poll() is not None:
|
||||||
|
# Release device on failure
|
||||||
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
|
stderr_output = ''
|
||||||
|
if app_module.ais_process.stderr:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||||
|
if stderr_output:
|
||||||
|
logger.error(f"AIS-catcher stderr:\n{stderr_output}")
|
||||||
|
error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
|
||||||
|
if stderr_output:
|
||||||
|
error_msg += f' Error: {stderr_output[:500]}'
|
||||||
|
return api_error(error_msg, 500)
|
||||||
|
|
||||||
|
ais_running = True
|
||||||
|
ais_active_device = device
|
||||||
|
ais_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
|
# Start TCP parser thread
|
||||||
|
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'message': 'AIS tracking started',
|
||||||
|
'device': device,
|
||||||
|
'port': tcp_port
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||||
|
logger.error(f"Failed to start AIS-catcher: {e}")
|
||||||
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_ais():
|
||||||
|
"""Stop AIS tracking."""
|
||||||
|
global ais_running, ais_active_device, ais_active_sdr_type
|
||||||
|
|
||||||
|
with app_module.ais_lock:
|
||||||
|
if app_module.ais_process:
|
||||||
|
try:
|
||||||
|
pgid = os.getpgid(app_module.ais_process.pid)
|
||||||
|
os.killpg(pgid, 15)
|
||||||
|
app_module.ais_process.wait(timeout=AIS_TERMINATE_TIMEOUT)
|
||||||
|
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||||
|
try:
|
||||||
|
pgid = os.getpgid(app_module.ais_process.pid)
|
||||||
|
os.killpg(pgid, 9)
|
||||||
|
except (ProcessLookupError, OSError):
|
||||||
|
pass
|
||||||
|
app_module.ais_process = None
|
||||||
|
logger.info("AIS process stopped")
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if ais_active_device is not None:
|
||||||
|
app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr')
|
||||||
|
|
||||||
|
ais_running = False
|
||||||
|
ais_active_device = None
|
||||||
|
ais_active_sdr_type = None
|
||||||
|
|
||||||
|
app_module.ais_vessels.clear()
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/stream')
|
||||||
|
def stream_ais():
|
||||||
|
"""SSE stream for AIS vessels."""
|
||||||
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
|
process_event('ais', msg, msg.get('type'))
|
||||||
|
|
||||||
|
response = Response(
|
||||||
|
sse_stream_fanout(
|
||||||
|
source_queue=app_module.ais_queue,
|
||||||
|
channel_key='ais',
|
||||||
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
|
on_message=_on_msg,
|
||||||
|
),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
)
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/vessel/<mmsi>/dsc')
|
||||||
|
def get_vessel_dsc(mmsi: str):
|
||||||
|
"""Get DSC messages associated with a vessel MMSI."""
|
||||||
|
if not mmsi or not mmsi.isdigit():
|
||||||
|
return api_error('Invalid MMSI', 400)
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
try:
|
||||||
|
for _key, msg in app_module.dsc_messages.items():
|
||||||
|
if str(msg.get('source_mmsi', '')) == mmsi:
|
||||||
|
matches.append(dict(msg))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return api_success(data={'mmsi': mmsi, 'dsc_messages': matches})
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/vessels')
|
||||||
|
def ais_vessels():
|
||||||
|
"""Export current AIS vessel data as JSON.
|
||||||
|
|
||||||
|
Returns a snapshot of all tracked vessels suitable for integration
|
||||||
|
with external tools (OpenCPN, ship tracking apps, etc.).
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
mmsi: Filter to a specific MMSI (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with vessel list and metadata.
|
||||||
|
"""
|
||||||
|
vessels = dict(app_module.ais_vessels)
|
||||||
|
|
||||||
|
mmsi_filter = request.args.get('mmsi')
|
||||||
|
if mmsi_filter:
|
||||||
|
vessels = {k: v for k, v in vessels.items() if str(k) == str(mmsi_filter)}
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'count': len(vessels),
|
||||||
|
'vessels': list(vessels.values()),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/dashboard')
|
||||||
|
def ais_dashboard():
|
||||||
|
"""Popout AIS dashboard."""
|
||||||
|
embedded = request.args.get('embedded', 'false') == 'true'
|
||||||
|
return render_template(
|
||||||
|
'ais_dashboard.html',
|
||||||
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
default_latitude=DEFAULT_LATITUDE,
|
||||||
|
default_longitude=DEFAULT_LONGITUDE,
|
||||||
|
embedded=embedded,
|
||||||
|
)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Alerting API endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, request
|
||||||
|
|
||||||
|
from utils.alerts import get_alert_manager
|
||||||
|
from utils.responses import api_error, api_success
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
|
||||||
|
alerts_bp = Blueprint("alerts", __name__, url_prefix="/alerts")
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route("/rules", methods=["GET"])
|
||||||
|
def list_rules():
|
||||||
|
manager = get_alert_manager()
|
||||||
|
include_disabled = request.args.get("all") in ("1", "true", "yes")
|
||||||
|
return api_success(data={"rules": manager.list_rules(include_disabled=include_disabled)})
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route("/rules", methods=["POST"])
|
||||||
|
def create_rule():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
if not isinstance(data.get("match", {}), dict):
|
||||||
|
return api_error("match must be a JSON object", 400)
|
||||||
|
|
||||||
|
manager = get_alert_manager()
|
||||||
|
rule_id = manager.add_rule(data)
|
||||||
|
return api_success(data={"rule_id": rule_id})
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route("/rules/<int:rule_id>", methods=["PUT", "PATCH"])
|
||||||
|
def update_rule(rule_id: int):
|
||||||
|
data = request.get_json() or {}
|
||||||
|
manager = get_alert_manager()
|
||||||
|
ok = manager.update_rule(rule_id, data)
|
||||||
|
if not ok:
|
||||||
|
return api_error("Rule not found or no changes", 404)
|
||||||
|
return api_success()
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route("/rules/<int:rule_id>", methods=["DELETE"])
|
||||||
|
def delete_rule(rule_id: int):
|
||||||
|
manager = get_alert_manager()
|
||||||
|
ok = manager.delete_rule(rule_id)
|
||||||
|
if not ok:
|
||||||
|
return api_error("Rule not found", 404)
|
||||||
|
return api_success()
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route("/events", methods=["GET"])
|
||||||
|
def list_events():
|
||||||
|
manager = get_alert_manager()
|
||||||
|
limit = request.args.get("limit", default=100, type=int)
|
||||||
|
mode = request.args.get("mode")
|
||||||
|
severity = request.args.get("severity")
|
||||||
|
events = manager.list_events(limit=limit, mode=mode, severity=severity)
|
||||||
|
return api_success(data={"events": events})
|
||||||
|
|
||||||
|
|
||||||
|
@alerts_bp.route("/stream", methods=["GET"])
|
||||||
|
def stream_alerts() -> Response:
|
||||||
|
manager = get_alert_manager()
|
||||||
|
response = Response(
|
||||||
|
sse_stream_fanout(
|
||||||
|
source_queue=manager._queue,
|
||||||
|
channel_key="alerts",
|
||||||
|
timeout=1.0,
|
||||||
|
keepalive_interval=30.0,
|
||||||
|
),
|
||||||
|
mimetype="text/event-stream",
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-cache"
|
||||||
|
response.headers["X-Accel-Buffering"] = "no"
|
||||||
|
response.headers["Connection"] = "keep-alive"
|
||||||
|
return response
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
"""WebSocket-based audio streaming for SDR."""
|
"""WebSocket-based audio streaming for SDR."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import shutil
|
|
||||||
import json
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
# Try to import flask-sock
|
# Try to import flask-sock
|
||||||
@@ -15,6 +17,8 @@ except ImportError:
|
|||||||
WEBSOCKET_AVAILABLE = False
|
WEBSOCKET_AVAILABLE = False
|
||||||
Sock = None
|
Sock = None
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
|
||||||
logger = get_logger('intercept.audio_ws')
|
logger = get_logger('intercept.audio_ws')
|
||||||
@@ -36,11 +40,17 @@ def find_rtl_fm():
|
|||||||
return shutil.which('rtl_fm')
|
return shutil.which('rtl_fm')
|
||||||
|
|
||||||
|
|
||||||
def find_ffmpeg():
|
def find_ffmpeg():
|
||||||
return shutil.which('ffmpeg')
|
return shutil.which('ffmpeg')
|
||||||
|
|
||||||
|
|
||||||
def kill_audio_processes():
|
def _rtl_fm_demod_mode(modulation):
|
||||||
|
"""Map UI modulation names to rtl_fm demod tokens."""
|
||||||
|
mod = str(modulation or '').lower().strip()
|
||||||
|
return 'wbfm' if mod == 'wfm' else mod
|
||||||
|
|
||||||
|
|
||||||
|
def kill_audio_processes():
|
||||||
"""Kill any running audio processes."""
|
"""Kill any running audio processes."""
|
||||||
global audio_process, rtl_process
|
global audio_process, rtl_process
|
||||||
|
|
||||||
@@ -49,10 +59,8 @@ def kill_audio_processes():
|
|||||||
audio_process.terminate()
|
audio_process.terminate()
|
||||||
audio_process.wait(timeout=0.5)
|
audio_process.wait(timeout=0.5)
|
||||||
except:
|
except:
|
||||||
try:
|
with contextlib.suppress(BaseException):
|
||||||
audio_process.kill()
|
audio_process.kill()
|
||||||
except:
|
|
||||||
pass
|
|
||||||
audio_process = None
|
audio_process = None
|
||||||
|
|
||||||
if rtl_process:
|
if rtl_process:
|
||||||
@@ -60,18 +68,10 @@ def kill_audio_processes():
|
|||||||
rtl_process.terminate()
|
rtl_process.terminate()
|
||||||
rtl_process.wait(timeout=0.5)
|
rtl_process.wait(timeout=0.5)
|
||||||
except:
|
except:
|
||||||
try:
|
with contextlib.suppress(BaseException):
|
||||||
rtl_process.kill()
|
rtl_process.kill()
|
||||||
except:
|
|
||||||
pass
|
|
||||||
rtl_process = None
|
rtl_process = None
|
||||||
|
|
||||||
# Kill any orphaned processes
|
|
||||||
try:
|
|
||||||
subprocess.run(['pkill', '-9', '-f', 'rtl_fm'], capture_output=True, timeout=1)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
|
||||||
@@ -109,14 +109,14 @@ def start_audio_stream(config):
|
|||||||
|
|
||||||
freq_hz = int(freq * 1e6)
|
freq_hz = int(freq * 1e6)
|
||||||
|
|
||||||
rtl_cmd = [
|
rtl_cmd = [
|
||||||
rtl_fm,
|
rtl_fm,
|
||||||
'-M', mod,
|
'-M', _rtl_fm_demod_mode(mod),
|
||||||
'-f', str(freq_hz),
|
'-f', str(freq_hz),
|
||||||
'-s', str(sample_rate),
|
'-s', str(sample_rate),
|
||||||
'-r', str(resample_rate),
|
'-r', str(resample_rate),
|
||||||
'-g', str(gain),
|
'-g', str(gain),
|
||||||
'-d', str(device),
|
'-d', str(device),
|
||||||
'-l', str(squelch),
|
'-l', str(squelch),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -229,7 +229,11 @@ def init_audio_websocket(app: Flask):
|
|||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if "timed out" not in str(e).lower():
|
msg = str(e).lower()
|
||||||
|
if "connection closed" in msg:
|
||||||
|
logger.info("WebSocket closed by client")
|
||||||
|
break
|
||||||
|
if "timed out" not in msg:
|
||||||
logger.error(f"WebSocket receive error: {e}")
|
logger.error(f"WebSocket receive error: {e}")
|
||||||
|
|
||||||
# Stream audio data if active
|
# Stream audio data if active
|
||||||
@@ -253,4 +257,13 @@ def init_audio_websocket(app: Flask):
|
|||||||
finally:
|
finally:
|
||||||
with process_lock:
|
with process_lock:
|
||||||
kill_audio_processes()
|
kill_audio_processes()
|
||||||
|
# Complete WebSocket close handshake, then shut down the
|
||||||
|
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||||
|
# on top of the WebSocket stream.
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
ws.close()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
ws.sock.close()
|
||||||
logger.info("WebSocket audio client disconnected")
|
logger.info("WebSocket audio client disconnected")
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import fcntl
|
import contextlib
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import pty
|
import pty
|
||||||
@@ -13,31 +12,42 @@ import select
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any, Generator
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.dependencies import check_tool
|
from data.oui import OUI_DATABASE, get_manufacturer, load_oui_database
|
||||||
from utils.logging import bluetooth_logger as logger
|
from data.patterns import AIRTAG_PREFIXES, SAMSUNG_TRACKER, TILE_PREFIXES
|
||||||
from utils.sse import format_sse
|
|
||||||
from utils.validation import validate_bluetooth_interface
|
|
||||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
|
||||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
BT_TERMINATE_TIMEOUT,
|
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
|
||||||
SSE_QUEUE_TIMEOUT,
|
|
||||||
SUBPROCESS_TIMEOUT_SHORT,
|
SUBPROCESS_TIMEOUT_SHORT,
|
||||||
SERVICE_ENUM_TIMEOUT,
|
|
||||||
PROCESS_START_WAIT,
|
|
||||||
BT_RESET_DELAY,
|
|
||||||
BT_ADAPTER_DOWN_WAIT,
|
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
|
||||||
)
|
)
|
||||||
|
from utils.dependencies import check_tool
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.logging import bluetooth_logger as logger
|
||||||
|
from utils.responses import api_error, api_success
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
from utils.validation import validate_bluetooth_interface
|
||||||
|
|
||||||
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
|
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
|
||||||
|
|
||||||
|
# --- v1 deprecation ---
|
||||||
|
# These endpoints are deprecated in favor of /api/bluetooth/*.
|
||||||
|
# Frontend still uses v1, so they remain active.
|
||||||
|
# Migration: switch frontend to v2 endpoints, then remove this file.
|
||||||
|
_v1_deprecation_logged = set()
|
||||||
|
|
||||||
|
|
||||||
|
@bluetooth_bp.after_request
|
||||||
|
def _add_deprecation_header(response):
|
||||||
|
"""Add X-Deprecated header to all v1 Bluetooth responses."""
|
||||||
|
response.headers['X-Deprecated'] = 'Use /api/bluetooth/* endpoints instead'
|
||||||
|
endpoint = request.endpoint or ''
|
||||||
|
if endpoint not in _v1_deprecation_logged:
|
||||||
|
_v1_deprecation_logged.add(endpoint)
|
||||||
|
logger.warning(f"Deprecated v1 Bluetooth endpoint called: {request.path} — migrate to /api/bluetooth/*")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def classify_bt_device(name, device_class, services, manufacturer=None):
|
def classify_bt_device(name, device_class, services, manufacturer=None):
|
||||||
"""Classify Bluetooth device type based on available info."""
|
"""Classify Bluetooth device type based on available info."""
|
||||||
@@ -309,10 +319,8 @@ def stream_bt_scan(process, scan_mode):
|
|||||||
except OSError:
|
except OSError:
|
||||||
break
|
break
|
||||||
|
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app_module.bt_queue.put({'type': 'error', 'text': str(e)})
|
app_module.bt_queue.put({'type': 'error', 'text': str(e)})
|
||||||
@@ -330,8 +338,8 @@ def reload_oui_database_route():
|
|||||||
if new_db:
|
if new_db:
|
||||||
OUI_DATABASE.clear()
|
OUI_DATABASE.clear()
|
||||||
OUI_DATABASE.update(new_db)
|
OUI_DATABASE.update(new_db)
|
||||||
return jsonify({'status': 'success', 'entries': len(OUI_DATABASE)})
|
return api_success(data={'entries': len(OUI_DATABASE)})
|
||||||
return jsonify({'status': 'error', 'message': 'Could not load oui_database.json'})
|
return api_error('Could not load oui_database.json')
|
||||||
|
|
||||||
|
|
||||||
@bluetooth_bp.route('/interfaces')
|
@bluetooth_bp.route('/interfaces')
|
||||||
@@ -358,7 +366,7 @@ def start_bt_scan():
|
|||||||
with app_module.bt_lock:
|
with app_module.bt_lock:
|
||||||
if app_module.bt_process:
|
if app_module.bt_process:
|
||||||
if app_module.bt_process.poll() is None:
|
if app_module.bt_process.poll() is None:
|
||||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
return api_error('Scan already running')
|
||||||
else:
|
else:
|
||||||
app_module.bt_process = None
|
app_module.bt_process = None
|
||||||
|
|
||||||
@@ -370,7 +378,7 @@ def start_bt_scan():
|
|||||||
try:
|
try:
|
||||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
app_module.bt_interface = interface
|
app_module.bt_interface = interface
|
||||||
app_module.bt_devices = {}
|
app_module.bt_devices = {}
|
||||||
@@ -412,14 +420,14 @@ def start_bt_scan():
|
|||||||
os.write(master_fd, b'scan on\n')
|
os.write(master_fd, b'scan on\n')
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return jsonify({'status': 'error', 'message': f'Unknown scan mode: {scan_mode}'})
|
return api_error(f'Unknown scan mode: {scan_mode}')
|
||||||
|
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
if app_module.bt_process.poll() is not None:
|
if app_module.bt_process.poll() is not None:
|
||||||
stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip()
|
stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip()
|
||||||
app_module.bt_process = None
|
app_module.bt_process = None
|
||||||
return jsonify({'status': 'error', 'message': stderr_output or 'Process failed to start'})
|
return api_error(stderr_output or 'Process failed to start')
|
||||||
|
|
||||||
thread = threading.Thread(target=stream_bt_scan, args=(app_module.bt_process, scan_mode))
|
thread = threading.Thread(target=stream_bt_scan, args=(app_module.bt_process, scan_mode))
|
||||||
thread.daemon = True
|
thread.daemon = True
|
||||||
@@ -429,9 +437,9 @@ def start_bt_scan():
|
|||||||
return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface})
|
return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface})
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
return api_error(f'Tool not found: {e.filename}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
|
|
||||||
|
|
||||||
@bluetooth_bp.route('/scan/stop', methods=['POST'])
|
@bluetooth_bp.route('/scan/stop', methods=['POST'])
|
||||||
@@ -458,7 +466,7 @@ def reset_bt_adapter():
|
|||||||
try:
|
try:
|
||||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
with app_module.bt_lock:
|
with app_module.bt_lock:
|
||||||
if app_module.bt_process:
|
if app_module.bt_process:
|
||||||
@@ -466,10 +474,8 @@ def reset_bt_adapter():
|
|||||||
app_module.bt_process.terminate()
|
app_module.bt_process.terminate()
|
||||||
app_module.bt_process.wait(timeout=2)
|
app_module.bt_process.wait(timeout=2)
|
||||||
except (subprocess.TimeoutExpired, OSError):
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
try:
|
with contextlib.suppress(OSError):
|
||||||
app_module.bt_process.kill()
|
app_module.bt_process.kill()
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
app_module.bt_process = None
|
app_module.bt_process = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -488,12 +494,12 @@ def reset_bt_adapter():
|
|||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success' if is_up else 'warning',
|
'status': 'success' if is_up else 'warning',
|
||||||
'message': f'Adapter {interface} reset' if is_up else f'Reset attempted but adapter may still be down',
|
'message': f'Adapter {interface} reset' if is_up else 'Reset attempted but adapter may still be down',
|
||||||
'is_up': is_up
|
'is_up': is_up
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
|
|
||||||
|
|
||||||
@bluetooth_bp.route('/enum', methods=['POST'])
|
@bluetooth_bp.route('/enum', methods=['POST'])
|
||||||
@@ -503,7 +509,7 @@ def enum_bt_services():
|
|||||||
target_mac = data.get('mac')
|
target_mac = data.get('mac')
|
||||||
|
|
||||||
if not target_mac:
|
if not target_mac:
|
||||||
return jsonify({'status': 'error', 'message': 'Target MAC required'})
|
return api_error('Target MAC required')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -528,18 +534,17 @@ def enum_bt_services():
|
|||||||
|
|
||||||
app_module.bt_services[target_mac] = services
|
app_module.bt_services[target_mac] = services
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={
|
||||||
'status': 'success',
|
|
||||||
'mac': target_mac,
|
'mac': target_mac,
|
||||||
'services': services
|
'services': services
|
||||||
})
|
})
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return jsonify({'status': 'error', 'message': 'Connection timed out'})
|
return api_error('Connection timed out')
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return jsonify({'status': 'error', 'message': 'sdptool not found'})
|
return api_error('sdptool not found')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)})
|
return api_error(str(e))
|
||||||
|
|
||||||
|
|
||||||
@bluetooth_bp.route('/devices')
|
@bluetooth_bp.route('/devices')
|
||||||
@@ -555,22 +560,19 @@ def get_bt_devices():
|
|||||||
@bluetooth_bp.route('/stream')
|
@bluetooth_bp.route('/stream')
|
||||||
def stream_bt():
|
def stream_bt():
|
||||||
"""SSE stream for Bluetooth events."""
|
"""SSE stream for Bluetooth events."""
|
||||||
def generate():
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('bluetooth', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.bt_queue.get(timeout=1)
|
source_queue=app_module.bt_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='bluetooth',
|
||||||
yield format_sse(msg)
|
timeout=1.0,
|
||||||
except queue.Empty:
|
keepalive_interval=30.0,
|
||||||
now = time.time()
|
on_message=_on_msg,
|
||||||
if now - last_keepalive >= keepalive_interval:
|
),
|
||||||
yield format_sse({'type': 'keepalive'})
|
mimetype='text/event-stream',
|
||||||
last_keepalive = now
|
)
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""
|
||||||
|
BT Locate — Bluetooth SAR Device Location Flask Blueprint.
|
||||||
|
|
||||||
|
Provides endpoints for managing locate sessions, streaming detection events,
|
||||||
|
and retrieving GPS-tagged signal trails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.bluetooth.irk_extractor import get_paired_irks
|
||||||
|
from utils.bt_locate import (
|
||||||
|
Environment,
|
||||||
|
LocateTarget,
|
||||||
|
get_locate_session,
|
||||||
|
resolve_rpa,
|
||||||
|
start_locate_session,
|
||||||
|
stop_locate_session,
|
||||||
|
)
|
||||||
|
from utils.responses import api_error
|
||||||
|
from utils.sse import format_sse
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.bt_locate')
|
||||||
|
|
||||||
|
bt_locate_bp = Blueprint('bt_locate', __name__, url_prefix='/bt_locate')
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/start', methods=['POST'])
|
||||||
|
def start_session():
|
||||||
|
"""
|
||||||
|
Start a locate session.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
- mac_address: Target MAC address (optional)
|
||||||
|
- name_pattern: Target name substring (optional)
|
||||||
|
- irk_hex: Identity Resolving Key hex string (optional)
|
||||||
|
- device_id: Device ID from Bluetooth scanner (optional)
|
||||||
|
- device_key: Stable device key from Bluetooth scanner (optional)
|
||||||
|
- fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional)
|
||||||
|
- known_name: Hand-off device name (optional)
|
||||||
|
- known_manufacturer: Hand-off manufacturer (optional)
|
||||||
|
- last_known_rssi: Hand-off last RSSI (optional)
|
||||||
|
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
|
||||||
|
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with session status.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
|
||||||
|
# Build target
|
||||||
|
target = LocateTarget(
|
||||||
|
mac_address=data.get('mac_address'),
|
||||||
|
name_pattern=data.get('name_pattern'),
|
||||||
|
irk_hex=data.get('irk_hex'),
|
||||||
|
device_id=data.get('device_id'),
|
||||||
|
device_key=data.get('device_key'),
|
||||||
|
fingerprint_id=data.get('fingerprint_id'),
|
||||||
|
known_name=data.get('known_name'),
|
||||||
|
known_manufacturer=data.get('known_manufacturer'),
|
||||||
|
last_known_rssi=data.get('last_known_rssi'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# At least one identifier required
|
||||||
|
if not any([
|
||||||
|
target.mac_address,
|
||||||
|
target.name_pattern,
|
||||||
|
target.irk_hex,
|
||||||
|
target.device_id,
|
||||||
|
target.device_key,
|
||||||
|
target.fingerprint_id,
|
||||||
|
]):
|
||||||
|
return api_error(
|
||||||
|
'At least one target identifier required '
|
||||||
|
'(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)',
|
||||||
|
400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse environment
|
||||||
|
env_str = data.get('environment', 'OUTDOOR').upper()
|
||||||
|
try:
|
||||||
|
environment = Environment[env_str]
|
||||||
|
except KeyError:
|
||||||
|
return api_error(f'Invalid environment: {env_str}', 400)
|
||||||
|
|
||||||
|
custom_exponent = data.get('custom_exponent')
|
||||||
|
if custom_exponent is not None:
|
||||||
|
try:
|
||||||
|
custom_exponent = float(custom_exponent)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return api_error('custom_exponent must be a number', 400)
|
||||||
|
|
||||||
|
# Fallback coordinates when GPS is unavailable (from user settings)
|
||||||
|
fallback_lat = None
|
||||||
|
fallback_lon = None
|
||||||
|
if data.get('fallback_lat') is not None and data.get('fallback_lon') is not None:
|
||||||
|
try:
|
||||||
|
fallback_lat = float(data['fallback_lat'])
|
||||||
|
fallback_lon = float(data['fallback_lon'])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Starting locate session: target={target.to_dict()}, "
|
||||||
|
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = start_locate_session(
|
||||||
|
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.warning(f"Unable to start BT Locate session: {exc}")
|
||||||
|
return api_error('Bluetooth scanner could not be started. Check adapter permissions/capabilities.', 503)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(f"Unexpected error starting BT Locate session: {exc}")
|
||||||
|
return api_error('Failed to start locate session', 500)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'session': session.get_status(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_session():
|
||||||
|
"""Stop the active locate session."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return jsonify({'status': 'no_session'})
|
||||||
|
|
||||||
|
stop_locate_session()
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/status', methods=['GET'])
|
||||||
|
def get_status():
|
||||||
|
"""Get locate session status."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return jsonify({
|
||||||
|
'active': False,
|
||||||
|
'target': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes')
|
||||||
|
return jsonify(session.get_status(include_debug=include_debug))
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/trail', methods=['GET'])
|
||||||
|
def get_trail():
|
||||||
|
"""Get detection trail data."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return jsonify({'trail': [], 'gps_trail': []})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'trail': session.get_trail(),
|
||||||
|
'gps_trail': session.get_gps_trail(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/stream', methods=['GET'])
|
||||||
|
def stream_detections():
|
||||||
|
"""SSE stream of detection events."""
|
||||||
|
|
||||||
|
def event_generator() -> Generator[str, None, None]:
|
||||||
|
while True:
|
||||||
|
# Re-fetch session each iteration in case it changes
|
||||||
|
s = get_locate_session()
|
||||||
|
if not s:
|
||||||
|
yield format_sse({'type': 'session_ended'}, event='session_ended')
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = s.event_queue.get(timeout=2.0)
|
||||||
|
yield format_sse(event, event='detection')
|
||||||
|
except Exception:
|
||||||
|
yield format_sse({}, event='ping')
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
event_generator(),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
headers={
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/resolve_rpa', methods=['POST'])
|
||||||
|
def test_resolve_rpa():
|
||||||
|
"""
|
||||||
|
Test if an IRK resolves to a given address.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
- irk_hex: 16-byte IRK as hex string
|
||||||
|
- address: BLE address string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with resolution result.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
irk_hex = data.get('irk_hex', '')
|
||||||
|
address = data.get('address', '')
|
||||||
|
|
||||||
|
if not irk_hex or not address:
|
||||||
|
return api_error('irk_hex and address are required', 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
irk = bytes.fromhex(irk_hex)
|
||||||
|
except ValueError:
|
||||||
|
return api_error('Invalid IRK hex string', 400)
|
||||||
|
|
||||||
|
if len(irk) != 16:
|
||||||
|
return api_error('IRK must be exactly 16 bytes (32 hex characters)', 400)
|
||||||
|
|
||||||
|
result = resolve_rpa(irk, address)
|
||||||
|
return jsonify({
|
||||||
|
'resolved': result,
|
||||||
|
'irk_hex': irk_hex,
|
||||||
|
'address': address,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/environment', methods=['POST'])
|
||||||
|
def set_environment():
|
||||||
|
"""Update the environment on the active session."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return api_error('no active session', 400)
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
env_str = data.get('environment', '').upper()
|
||||||
|
try:
|
||||||
|
environment = Environment[env_str]
|
||||||
|
except KeyError:
|
||||||
|
return api_error(f'Invalid environment: {env_str}', 400)
|
||||||
|
|
||||||
|
custom_exponent = data.get('custom_exponent')
|
||||||
|
if custom_exponent is not None:
|
||||||
|
try:
|
||||||
|
custom_exponent = float(custom_exponent)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
custom_exponent = None
|
||||||
|
|
||||||
|
session.set_environment(environment, custom_exponent)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'updated',
|
||||||
|
'environment': environment.name,
|
||||||
|
'path_loss_exponent': session.estimator.n,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/debug', methods=['GET'])
|
||||||
|
def debug_matching():
|
||||||
|
"""Debug endpoint showing scanner devices and match results."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return api_error('no session')
|
||||||
|
|
||||||
|
scanner = session._scanner
|
||||||
|
if not scanner:
|
||||||
|
return api_error('no scanner')
|
||||||
|
|
||||||
|
devices = scanner.get_devices(max_age_seconds=30)
|
||||||
|
return jsonify({
|
||||||
|
'target': session.target.to_dict(),
|
||||||
|
'device_count': len(devices),
|
||||||
|
'devices': [
|
||||||
|
{
|
||||||
|
'device_id': d.device_id,
|
||||||
|
'address': d.address,
|
||||||
|
'name': d.name,
|
||||||
|
'rssi': d.rssi_current,
|
||||||
|
'matches': session.target.matches(d),
|
||||||
|
}
|
||||||
|
for d in devices
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/paired_irks', methods=['GET'])
|
||||||
|
def paired_irks():
|
||||||
|
"""Return paired Bluetooth devices that have IRKs."""
|
||||||
|
try:
|
||||||
|
devices = get_paired_irks()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to read paired IRKs")
|
||||||
|
return jsonify({'devices': [], 'error': str(e)})
|
||||||
|
|
||||||
|
return jsonify({'devices': devices})
|
||||||
|
|
||||||
|
|
||||||
|
@bt_locate_bp.route('/clear_trail', methods=['POST'])
|
||||||
|
def clear_trail():
|
||||||
|
"""Clear the detection trail."""
|
||||||
|
session = get_locate_session()
|
||||||
|
if not session:
|
||||||
|
return jsonify({'status': 'no_session'})
|
||||||
|
|
||||||
|
session.clear_trail()
|
||||||
|
return jsonify({'status': 'cleared'})
|
||||||
@@ -0,0 +1,902 @@
|
|||||||
|
"""
|
||||||
|
Controller routes for managing remote Intercept agents.
|
||||||
|
|
||||||
|
This blueprint provides:
|
||||||
|
- Agent CRUD operations
|
||||||
|
- Proxy endpoints to forward requests to agents
|
||||||
|
- Push data ingestion endpoint
|
||||||
|
- Multi-agent SSE stream
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Generator
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.agent_client import AgentClient, AgentConnectionError, AgentHTTPError, create_client_from_agent
|
||||||
|
from utils.database import (
|
||||||
|
create_agent,
|
||||||
|
delete_agent,
|
||||||
|
get_agent,
|
||||||
|
get_agent_by_name,
|
||||||
|
get_recent_payloads,
|
||||||
|
list_agents,
|
||||||
|
store_push_payload,
|
||||||
|
update_agent,
|
||||||
|
)
|
||||||
|
from utils.responses import api_error
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.trilateration import (
|
||||||
|
DeviceLocationTracker,
|
||||||
|
PathLossModel,
|
||||||
|
Trilateration,
|
||||||
|
estimate_location_from_observations,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.controller')
|
||||||
|
|
||||||
|
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||||
|
AGENT_HEALTH_TIMEOUT_SECONDS = 2.0
|
||||||
|
AGENT_STATUS_TIMEOUT_SECONDS = 2.5
|
||||||
|
|
||||||
|
# Multi-agent SSE fanout state (per-client queues).
|
||||||
|
_agent_stream_subscribers: set[queue.Queue] = set()
|
||||||
|
_agent_stream_subscribers_lock = threading.Lock()
|
||||||
|
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _broadcast_agent_data(payload: dict) -> None:
|
||||||
|
"""Fan out an ingested payload to all active /controller/stream/all clients."""
|
||||||
|
with _agent_stream_subscribers_lock:
|
||||||
|
subscribers = tuple(_agent_stream_subscribers)
|
||||||
|
|
||||||
|
for subscriber in subscribers:
|
||||||
|
try:
|
||||||
|
subscriber.put_nowait(payload)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
subscriber.get_nowait()
|
||||||
|
subscriber.put_nowait(payload)
|
||||||
|
except (queue.Empty, queue.Full):
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Agent CRUD
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/agents', methods=['GET'])
|
||||||
|
def get_agents():
|
||||||
|
"""List all registered agents."""
|
||||||
|
active_only = request.args.get('active_only', 'true').lower() == 'true'
|
||||||
|
agents = list_agents(active_only=active_only)
|
||||||
|
|
||||||
|
# Optionally refresh status for each agent
|
||||||
|
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||||
|
if refresh:
|
||||||
|
for agent in agents:
|
||||||
|
try:
|
||||||
|
client = AgentClient(
|
||||||
|
agent['base_url'],
|
||||||
|
api_key=agent.get('api_key'),
|
||||||
|
timeout=AGENT_HEALTH_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
agent['healthy'] = client.health_check()
|
||||||
|
except Exception:
|
||||||
|
agent['healthy'] = False
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agents': agents,
|
||||||
|
'count': len(agents)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents', methods=['POST'])
|
||||||
|
def register_agent():
|
||||||
|
"""
|
||||||
|
Register a new remote agent.
|
||||||
|
|
||||||
|
Expected JSON body:
|
||||||
|
{
|
||||||
|
"name": "sensor-node-1",
|
||||||
|
"base_url": "http://192.168.1.50:8020",
|
||||||
|
"api_key": "optional-shared-secret",
|
||||||
|
"description": "Optional description"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
base_url = data.get('base_url', '').strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return api_error('Agent name is required', 400)
|
||||||
|
if not base_url:
|
||||||
|
return api_error('Base URL is required', 400)
|
||||||
|
|
||||||
|
# Validate URL format
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
try:
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
if parsed.scheme not in ('http', 'https'):
|
||||||
|
return api_error('URL must start with http:// or https://', 400)
|
||||||
|
if not parsed.netloc:
|
||||||
|
return api_error('Invalid URL format', 400)
|
||||||
|
except Exception:
|
||||||
|
return api_error('Invalid URL format', 400)
|
||||||
|
|
||||||
|
# Check if agent already exists
|
||||||
|
existing = get_agent_by_name(name)
|
||||||
|
if existing:
|
||||||
|
return api_error(f'Agent with name "{name}" already exists', 409)
|
||||||
|
|
||||||
|
# Try to connect and get capabilities
|
||||||
|
api_key = data.get('api_key', '').strip() or None
|
||||||
|
client = AgentClient(base_url, api_key=api_key)
|
||||||
|
|
||||||
|
capabilities = None
|
||||||
|
interfaces = None
|
||||||
|
try:
|
||||||
|
caps = client.get_capabilities()
|
||||||
|
capabilities = caps.get('modes', {})
|
||||||
|
interfaces = {'devices': caps.get('devices', [])}
|
||||||
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
|
logger.warning(f"Could not fetch capabilities from {base_url}: {e}")
|
||||||
|
|
||||||
|
# Create agent
|
||||||
|
try:
|
||||||
|
agent_id = create_agent(
|
||||||
|
name=name,
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=api_key,
|
||||||
|
description=data.get('description'),
|
||||||
|
capabilities=capabilities,
|
||||||
|
interfaces=interfaces
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update last_seen since we just connected
|
||||||
|
if capabilities is not None:
|
||||||
|
update_agent(agent_id, update_last_seen=True)
|
||||||
|
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
message = 'Agent registered successfully'
|
||||||
|
if capabilities is None:
|
||||||
|
message += ' (could not connect - agent may be offline)'
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': message,
|
||||||
|
'agent': agent
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to create agent")
|
||||||
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>', methods=['GET'])
|
||||||
|
def get_agent_detail(agent_id: int):
|
||||||
|
"""Get details of a specific agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
|
# Optionally refresh from agent
|
||||||
|
refresh = request.args.get('refresh', 'false').lower() == 'true'
|
||||||
|
if refresh:
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
metadata = client.refresh_metadata()
|
||||||
|
if metadata['healthy']:
|
||||||
|
caps = metadata['capabilities'] or {}
|
||||||
|
# Store full interfaces structure (wifi, bt, sdr)
|
||||||
|
agent_interfaces = caps.get('interfaces', {})
|
||||||
|
# Fallback: also include top-level devices for backwards compatibility
|
||||||
|
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
|
||||||
|
agent_interfaces['sdr_devices'] = caps.get('devices', [])
|
||||||
|
update_agent(
|
||||||
|
agent_id,
|
||||||
|
capabilities=caps.get('modes'),
|
||||||
|
interfaces=agent_interfaces,
|
||||||
|
update_last_seen=True
|
||||||
|
)
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
agent['healthy'] = True
|
||||||
|
else:
|
||||||
|
agent['healthy'] = False
|
||||||
|
except Exception:
|
||||||
|
agent['healthy'] = False
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'agent': agent})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>', methods=['PUT', 'PATCH'])
|
||||||
|
def update_agent_detail(agent_id: int):
|
||||||
|
"""Update an agent's details."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Update allowed fields
|
||||||
|
update_agent(
|
||||||
|
agent_id,
|
||||||
|
base_url=data.get('base_url'),
|
||||||
|
description=data.get('description'),
|
||||||
|
api_key=data.get('api_key'),
|
||||||
|
is_active=data.get('is_active')
|
||||||
|
)
|
||||||
|
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
return jsonify({'status': 'success', 'agent': agent})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>', methods=['DELETE'])
|
||||||
|
def remove_agent(agent_id: int):
|
||||||
|
"""Delete an agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
|
delete_agent(agent_id)
|
||||||
|
return jsonify({'status': 'success', 'message': 'Agent deleted'})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/refresh', methods=['POST'])
|
||||||
|
def refresh_agent_metadata(agent_id: int):
|
||||||
|
"""Refresh an agent's capabilities and status."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
metadata = client.refresh_metadata()
|
||||||
|
|
||||||
|
if metadata['healthy']:
|
||||||
|
caps = metadata['capabilities'] or {}
|
||||||
|
# Store full interfaces structure (wifi, bt, sdr)
|
||||||
|
agent_interfaces = caps.get('interfaces', {})
|
||||||
|
# Fallback: also include top-level devices for backwards compatibility
|
||||||
|
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
|
||||||
|
agent_interfaces['sdr_devices'] = caps.get('devices', [])
|
||||||
|
update_agent(
|
||||||
|
agent_id,
|
||||||
|
capabilities=caps.get('modes'),
|
||||||
|
interfaces=agent_interfaces,
|
||||||
|
update_last_seen=True
|
||||||
|
)
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent': agent,
|
||||||
|
'metadata': metadata
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return api_error('Agent is not reachable', 503)
|
||||||
|
|
||||||
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
|
return api_error(f'Failed to reach agent: {e}', 503)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Agent Status - Get running state
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/status', methods=['GET'])
|
||||||
|
def get_agent_status(agent_id: int):
|
||||||
|
"""Get an agent's current status including running modes."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
status = client.get_status()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'agent_name': agent['name'],
|
||||||
|
'agent_status': status
|
||||||
|
})
|
||||||
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
|
return api_error(f'Failed to reach agent: {e}', 503)
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/health', methods=['GET'])
|
||||||
|
def check_all_agents_health():
|
||||||
|
"""
|
||||||
|
Check health of all registered agents in one call.
|
||||||
|
|
||||||
|
More efficient than checking each agent individually.
|
||||||
|
Returns health status, response time, and running modes for each agent.
|
||||||
|
"""
|
||||||
|
agents_list = list_agents(active_only=True)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for agent in agents_list:
|
||||||
|
result = {
|
||||||
|
'id': agent['id'],
|
||||||
|
'name': agent['name'],
|
||||||
|
'healthy': False,
|
||||||
|
'response_time_ms': None,
|
||||||
|
'running_modes': [],
|
||||||
|
'error': None
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = AgentClient(
|
||||||
|
agent['base_url'],
|
||||||
|
api_key=agent.get('api_key'),
|
||||||
|
timeout=AGENT_HEALTH_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Time the health check
|
||||||
|
start_time = time.time()
|
||||||
|
is_healthy = client.health_check()
|
||||||
|
response_time = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
|
result['healthy'] = is_healthy
|
||||||
|
result['response_time_ms'] = round(response_time, 1)
|
||||||
|
|
||||||
|
if is_healthy:
|
||||||
|
# Update last_seen in database
|
||||||
|
update_agent(agent['id'], update_last_seen=True)
|
||||||
|
|
||||||
|
# Also fetch running modes
|
||||||
|
try:
|
||||||
|
status_client = AgentClient(
|
||||||
|
agent['base_url'],
|
||||||
|
api_key=agent.get('api_key'),
|
||||||
|
timeout=AGENT_STATUS_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
status = status_client.get_status()
|
||||||
|
result['running_modes'] = status.get('running_modes', [])
|
||||||
|
result['running_modes_detail'] = status.get('running_modes_detail', {})
|
||||||
|
except Exception:
|
||||||
|
pass # Status fetch is optional
|
||||||
|
|
||||||
|
except AgentConnectionError as e:
|
||||||
|
result['error'] = f'Connection failed: {str(e)}'
|
||||||
|
except AgentHTTPError as e:
|
||||||
|
result['error'] = f'HTTP error: {str(e)}'
|
||||||
|
except Exception as e:
|
||||||
|
result['error'] = str(e)
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'agents': results,
|
||||||
|
'total': len(results),
|
||||||
|
'healthy_count': sum(1 for r in results if r['healthy'])
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Proxy Operations - Forward requests to agents
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/start', methods=['POST'])
|
||||||
|
def proxy_start_mode(agent_id: int, mode: str):
|
||||||
|
"""Start a mode on a remote agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
|
params = request.json or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
result = client.start_mode(mode, params)
|
||||||
|
|
||||||
|
# Update last_seen
|
||||||
|
update_agent(agent_id, update_last_seen=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'mode': mode,
|
||||||
|
'result': result
|
||||||
|
})
|
||||||
|
|
||||||
|
except AgentConnectionError as e:
|
||||||
|
return api_error(f'Cannot connect to agent: {e}', 503)
|
||||||
|
except AgentHTTPError as e:
|
||||||
|
return api_error(f'Agent error: {e}', 502)
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/stop', methods=['POST'])
|
||||||
|
def proxy_stop_mode(agent_id: int, mode: str):
|
||||||
|
"""Stop a mode on a remote agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
result = client.stop_mode(mode)
|
||||||
|
|
||||||
|
update_agent(agent_id, update_last_seen=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'mode': mode,
|
||||||
|
'result': result
|
||||||
|
})
|
||||||
|
|
||||||
|
except AgentConnectionError as e:
|
||||||
|
return api_error(f'Cannot connect to agent: {e}', 503)
|
||||||
|
except AgentHTTPError as e:
|
||||||
|
return api_error(f'Agent error: {e}', 502)
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/status', methods=['GET'])
|
||||||
|
def proxy_mode_status(agent_id: int, mode: str):
|
||||||
|
"""Get mode status from a remote agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
result = client.get_mode_status(mode)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'mode': mode,
|
||||||
|
'result': result
|
||||||
|
})
|
||||||
|
|
||||||
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
|
return api_error(f'Agent error: {e}', 502)
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
|
||||||
|
def proxy_mode_data(agent_id: int, mode: str):
|
||||||
|
"""Get current data from a remote agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
result = client.get_mode_data(mode)
|
||||||
|
|
||||||
|
# Tag data with agent info
|
||||||
|
result['agent_id'] = agent_id
|
||||||
|
result['agent_name'] = agent['name']
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'agent_name': agent['name'],
|
||||||
|
'mode': mode,
|
||||||
|
'data': result
|
||||||
|
})
|
||||||
|
|
||||||
|
except (AgentHTTPError, AgentConnectionError) as e:
|
||||||
|
return api_error(f'Agent error: {e}', 502)
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/<mode>/stream')
|
||||||
|
def proxy_mode_stream(agent_id: int, mode: str):
|
||||||
|
"""Proxy SSE stream from a remote agent."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
query = request.query_string.decode('utf-8')
|
||||||
|
url = f"{client.base_url}/{mode}/stream"
|
||||||
|
if query:
|
||||||
|
url = f"{url}?{query}"
|
||||||
|
|
||||||
|
headers = {'Accept': 'text/event-stream'}
|
||||||
|
if agent.get('api_key'):
|
||||||
|
headers['X-API-Key'] = agent['api_key']
|
||||||
|
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
try:
|
||||||
|
with requests.get(url, headers=headers, stream=True, timeout=(5, 3600)) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
for chunk in resp.iter_content(chunk_size=1024):
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
yield chunk.decode('utf-8', errors='ignore')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SSE proxy error for agent {agent_id}/{mode}: {e}")
|
||||||
|
yield format_sse({
|
||||||
|
'type': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'mode': mode,
|
||||||
|
})
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/agents/<int:agent_id>/wifi/monitor', methods=['POST'])
|
||||||
|
def proxy_wifi_monitor(agent_id: int):
|
||||||
|
"""Toggle monitor mode on a remote agent's WiFi interface."""
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
if not agent:
|
||||||
|
return api_error('Agent not found', 404)
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = create_client_from_agent(agent)
|
||||||
|
result = client.post('/wifi/monitor', data)
|
||||||
|
|
||||||
|
# Refresh agent capabilities after monitor mode toggle so UI stays in sync
|
||||||
|
if result.get('status') == 'success':
|
||||||
|
try:
|
||||||
|
metadata = client.refresh_metadata()
|
||||||
|
if metadata.get('healthy'):
|
||||||
|
caps = metadata.get('capabilities') or {}
|
||||||
|
agent_interfaces = caps.get('interfaces', {})
|
||||||
|
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
|
||||||
|
agent_interfaces['sdr_devices'] = caps.get('devices', [])
|
||||||
|
update_agent(
|
||||||
|
agent_id,
|
||||||
|
capabilities=caps.get('modes'),
|
||||||
|
interfaces=agent_interfaces,
|
||||||
|
update_last_seen=True
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Non-fatal if refresh fails
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': result.get('status', 'error'),
|
||||||
|
'agent_id': agent_id,
|
||||||
|
'agent_name': agent['name'],
|
||||||
|
'monitor_interface': result.get('monitor_interface'),
|
||||||
|
'message': result.get('message')
|
||||||
|
})
|
||||||
|
|
||||||
|
except AgentConnectionError as e:
|
||||||
|
return api_error(f'Cannot connect to agent: {e}', 503)
|
||||||
|
except AgentHTTPError as e:
|
||||||
|
return api_error(f'Agent error: {e}', 502)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Push Data Ingestion
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/api/ingest', methods=['POST'])
|
||||||
|
def ingest_push_data():
|
||||||
|
"""
|
||||||
|
Receive pushed data from remote agents.
|
||||||
|
|
||||||
|
Expected JSON body:
|
||||||
|
{
|
||||||
|
"agent_name": "sensor-node-1",
|
||||||
|
"scan_type": "adsb",
|
||||||
|
"interface": "rtlsdr0",
|
||||||
|
"payload": {...},
|
||||||
|
"received_at": "2024-01-15T10:30:00Z"
|
||||||
|
}
|
||||||
|
|
||||||
|
Expected header:
|
||||||
|
X-API-Key: shared-secret (if agent has api_key configured)
|
||||||
|
"""
|
||||||
|
data = request.json
|
||||||
|
if not data:
|
||||||
|
return api_error('No data provided', 400)
|
||||||
|
|
||||||
|
agent_name = data.get('agent_name')
|
||||||
|
if not agent_name:
|
||||||
|
return api_error('agent_name required', 400)
|
||||||
|
|
||||||
|
# Find agent
|
||||||
|
agent = get_agent_by_name(agent_name)
|
||||||
|
if not agent:
|
||||||
|
return api_error('Unknown agent', 401)
|
||||||
|
|
||||||
|
# Validate API key if configured
|
||||||
|
if agent.get('api_key'):
|
||||||
|
provided_key = request.headers.get('X-API-Key', '')
|
||||||
|
if provided_key != agent['api_key']:
|
||||||
|
logger.warning(f"Invalid API key from agent {agent_name}")
|
||||||
|
return api_error('Invalid API key', 401)
|
||||||
|
|
||||||
|
# Store payload
|
||||||
|
try:
|
||||||
|
payload_id = store_push_payload(
|
||||||
|
agent_id=agent['id'],
|
||||||
|
scan_type=data.get('scan_type', 'unknown'),
|
||||||
|
payload=data.get('payload', {}),
|
||||||
|
interface=data.get('interface'),
|
||||||
|
received_at=data.get('received_at')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Emit to SSE stream (fanout to all connected clients)
|
||||||
|
_broadcast_agent_data({
|
||||||
|
'type': 'agent_data',
|
||||||
|
'agent_id': agent['id'],
|
||||||
|
'agent_name': agent_name,
|
||||||
|
'scan_type': data.get('scan_type'),
|
||||||
|
'interface': data.get('interface'),
|
||||||
|
'payload': data.get('payload'),
|
||||||
|
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'accepted',
|
||||||
|
'payload_id': payload_id
|
||||||
|
}), 202
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to store push payload")
|
||||||
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/payloads', methods=['GET'])
|
||||||
|
def get_payloads():
|
||||||
|
"""Get recent push payloads."""
|
||||||
|
agent_id = request.args.get('agent_id', type=int)
|
||||||
|
scan_type = request.args.get('scan_type')
|
||||||
|
limit = request.args.get('limit', 100, type=int)
|
||||||
|
|
||||||
|
payloads = get_recent_payloads(
|
||||||
|
agent_id=agent_id,
|
||||||
|
scan_type=scan_type,
|
||||||
|
limit=min(limit, 1000)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'payloads': payloads,
|
||||||
|
'count': len(payloads)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Multi-Agent SSE Stream
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/stream/all')
|
||||||
|
def stream_all_agents():
|
||||||
|
"""
|
||||||
|
Combined SSE stream for data from all agents.
|
||||||
|
|
||||||
|
This endpoint streams push data as it arrives from agents.
|
||||||
|
Each message is tagged with agent_id and agent_name.
|
||||||
|
"""
|
||||||
|
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
|
||||||
|
with _agent_stream_subscribers_lock:
|
||||||
|
_agent_stream_subscribers.add(client_queue)
|
||||||
|
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = client_queue.get(timeout=1.0)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
finally:
|
||||||
|
with _agent_stream_subscribers_lock:
|
||||||
|
_agent_stream_subscribers.discard(client_queue)
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Agent Management Page
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@controller_bp.route('/manage')
|
||||||
|
def agent_management_page():
|
||||||
|
"""Render the agent management page."""
|
||||||
|
from flask import render_template
|
||||||
|
|
||||||
|
from config import VERSION
|
||||||
|
return render_template('agents.html', version=VERSION)
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/monitor')
|
||||||
|
def network_monitor_page():
|
||||||
|
"""Render the network monitor page for multi-agent aggregated view."""
|
||||||
|
from flask import render_template
|
||||||
|
|
||||||
|
from config import VERSION
|
||||||
|
return render_template('network_monitor.html', version=VERSION)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Device Location Estimation (Trilateration)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Global device location tracker
|
||||||
|
device_tracker = DeviceLocationTracker(
|
||||||
|
trilateration=Trilateration(
|
||||||
|
path_loss_model=PathLossModel('outdoor'),
|
||||||
|
min_observations=2
|
||||||
|
),
|
||||||
|
observation_window_seconds=120.0, # 2 minute window
|
||||||
|
min_observations=2
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/location/observe', methods=['POST'])
|
||||||
|
def add_location_observation():
|
||||||
|
"""
|
||||||
|
Add an observation for device location estimation.
|
||||||
|
|
||||||
|
Expected JSON body:
|
||||||
|
{
|
||||||
|
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"agent_name": "sensor-node-1",
|
||||||
|
"agent_lat": 40.7128,
|
||||||
|
"agent_lon": -74.0060,
|
||||||
|
"rssi": -55,
|
||||||
|
"frequency_mhz": 2400 (optional)
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns location estimate if enough data, null otherwise.
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi']
|
||||||
|
for field in required:
|
||||||
|
if field not in data:
|
||||||
|
return api_error(f'Missing required field: {field}', 400)
|
||||||
|
|
||||||
|
# Look up agent GPS from database if not provided
|
||||||
|
agent_lat = data.get('agent_lat')
|
||||||
|
agent_lon = data.get('agent_lon')
|
||||||
|
|
||||||
|
if agent_lat is None or agent_lon is None:
|
||||||
|
agent = get_agent_by_name(data['agent_name'])
|
||||||
|
if agent and agent.get('gps_coords'):
|
||||||
|
coords = agent['gps_coords']
|
||||||
|
agent_lat = coords.get('lat') or coords.get('latitude')
|
||||||
|
agent_lon = coords.get('lon') or coords.get('longitude')
|
||||||
|
|
||||||
|
if agent_lat is None or agent_lon is None:
|
||||||
|
return api_error('Agent GPS coordinates required', 400)
|
||||||
|
|
||||||
|
estimate = device_tracker.add_observation(
|
||||||
|
device_id=data['device_id'],
|
||||||
|
agent_name=data['agent_name'],
|
||||||
|
agent_lat=float(agent_lat),
|
||||||
|
agent_lon=float(agent_lon),
|
||||||
|
rssi=float(data['rssi']),
|
||||||
|
frequency_mhz=data.get('frequency_mhz')
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'device_id': data['device_id'],
|
||||||
|
'location': estimate.to_dict() if estimate else None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/location/estimate', methods=['POST'])
|
||||||
|
def estimate_location():
|
||||||
|
"""
|
||||||
|
Estimate device location from provided observations.
|
||||||
|
|
||||||
|
Expected JSON body:
|
||||||
|
{
|
||||||
|
"observations": [
|
||||||
|
{"agent_lat": 40.7128, "agent_lon": -74.0060, "rssi": -55, "agent_name": "node-1"},
|
||||||
|
{"agent_lat": 40.7135, "agent_lon": -74.0055, "rssi": -70, "agent_name": "node-2"},
|
||||||
|
{"agent_lat": 40.7120, "agent_lon": -74.0050, "rssi": -62, "agent_name": "node-3"}
|
||||||
|
],
|
||||||
|
"environment": "outdoor" (optional: outdoor, indoor, free_space)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
observations = data.get('observations', [])
|
||||||
|
if len(observations) < 2:
|
||||||
|
return api_error('At least 2 observations required', 400)
|
||||||
|
|
||||||
|
environment = data.get('environment', 'outdoor')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = estimate_location_from_observations(observations, environment)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success' if result else 'insufficient_data',
|
||||||
|
'location': result
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Location estimation failed")
|
||||||
|
return api_error(str(e), 500)
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/location/<device_id>', methods=['GET'])
|
||||||
|
def get_device_location(device_id: str):
|
||||||
|
"""Get the latest location estimate for a device."""
|
||||||
|
estimate = device_tracker.get_location(device_id)
|
||||||
|
|
||||||
|
if not estimate:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'not_found',
|
||||||
|
'device_id': device_id,
|
||||||
|
'location': None
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'device_id': device_id,
|
||||||
|
'location': estimate.to_dict()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/location/all', methods=['GET'])
|
||||||
|
def get_all_locations():
|
||||||
|
"""Get all current device location estimates."""
|
||||||
|
locations = device_tracker.get_all_locations()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'count': len(locations),
|
||||||
|
'devices': {
|
||||||
|
device_id: estimate.to_dict()
|
||||||
|
for device_id, estimate in locations.items()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@controller_bp.route('/api/location/near', methods=['GET'])
|
||||||
|
def get_devices_near():
|
||||||
|
"""
|
||||||
|
Find devices near a location.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
lat: latitude
|
||||||
|
lon: longitude
|
||||||
|
radius: radius in meters (default 100)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
lat = float(request.args.get('lat', 0))
|
||||||
|
lon = float(request.args.get('lon', 0))
|
||||||
|
radius = float(request.args.get('radius', 100))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return api_error('Invalid coordinates', 400)
|
||||||
|
|
||||||
|
results = device_tracker.get_devices_near(lat, lon, radius)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'center': {'lat': lat, 'lon': lon},
|
||||||
|
'radius_meters': radius,
|
||||||
|
'count': len(results),
|
||||||
|
'devices': [
|
||||||
|
{'device_id': device_id, 'location': estimate.to_dict()}
|
||||||
|
for device_id, estimate in results
|
||||||
|
]
|
||||||
|
})
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, request
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.correlation import get_correlations
|
from utils.correlation import get_correlations
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
from utils.responses import api_error, api_success
|
||||||
|
|
||||||
logger = get_logger('intercept.correlation')
|
logger = get_logger('intercept.correlation')
|
||||||
|
|
||||||
@@ -39,18 +40,14 @@ def get_device_correlations() -> Response:
|
|||||||
include_historical=include_historical
|
include_historical=include_historical
|
||||||
)
|
)
|
||||||
|
|
||||||
return jsonify({
|
return api_success(data={
|
||||||
'status': 'success',
|
|
||||||
'correlations': correlations,
|
'correlations': correlations,
|
||||||
'wifi_count': len(wifi_devices),
|
'wifi_count': len(wifi_devices),
|
||||||
'bt_count': len(bt_devices)
|
'bt_count': len(bt_devices)
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error calculating correlations: {e}")
|
logger.error(f"Error calculating correlations: {e}")
|
||||||
return jsonify({
|
return api_error(str(e), 500)
|
||||||
'status': 'error',
|
|
||||||
'message': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@correlation_bp.route('/analyze', methods=['POST'])
|
@correlation_bp.route('/analyze', methods=['POST'])
|
||||||
@@ -67,10 +64,7 @@ def analyze_correlation() -> Response:
|
|||||||
bt_mac = data.get('bt_mac')
|
bt_mac = data.get('bt_mac')
|
||||||
|
|
||||||
if not wifi_mac or not bt_mac:
|
if not wifi_mac or not bt_mac:
|
||||||
return jsonify({
|
return api_error('wifi_mac and bt_mac are required', 400)
|
||||||
'status': 'error',
|
|
||||||
'message': 'wifi_mac and bt_mac are required'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get device data
|
# Get device data
|
||||||
@@ -81,16 +75,10 @@ def analyze_correlation() -> Response:
|
|||||||
bt_device = app_module.bt_devices.get(bt_mac)
|
bt_device = app_module.bt_devices.get(bt_mac)
|
||||||
|
|
||||||
if not wifi_device:
|
if not wifi_device:
|
||||||
return jsonify({
|
return api_error(f'WiFi device {wifi_mac} not found', 404)
|
||||||
'status': 'error',
|
|
||||||
'message': f'WiFi device {wifi_mac} not found'
|
|
||||||
}), 404
|
|
||||||
|
|
||||||
if not bt_device:
|
if not bt_device:
|
||||||
return jsonify({
|
return api_error(f'Bluetooth device {bt_mac} not found', 404)
|
||||||
'status': 'error',
|
|
||||||
'message': f'Bluetooth device {bt_mac} not found'
|
|
||||||
}), 404
|
|
||||||
|
|
||||||
# Calculate correlation for this specific pair
|
# Calculate correlation for this specific pair
|
||||||
correlations = get_correlations(
|
correlations = get_correlations(
|
||||||
@@ -101,19 +89,9 @@ def analyze_correlation() -> Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if correlations:
|
if correlations:
|
||||||
return jsonify({
|
return api_success(data={'correlation': correlations[0]})
|
||||||
'status': 'success',
|
|
||||||
'correlation': correlations[0]
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return api_success(data={'correlation': None}, message='No correlation detected between these devices')
|
||||||
'status': 'success',
|
|
||||||
'correlation': None,
|
|
||||||
'message': 'No correlation detected between these devices'
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error analyzing correlation: {e}")
|
logger.error(f"Error analyzing correlation: {e}")
|
||||||
return jsonify({
|
return api_error(str(e), 500)
|
||||||
'status': 'error',
|
|
||||||
'message': str(e)
|
|
||||||
}), 500
|
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""Drone intelligence routes — multi-vector UAV detection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import queue
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
|
||||||
|
from utils.drone.correlator import DroneCorrelator
|
||||||
|
from utils.drone.remote_id import RemoteIDScanner
|
||||||
|
from utils.drone.rf_detector import RFDetector
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
from utils.validation import validate_device_index
|
||||||
|
|
||||||
|
logger = logging.getLogger("intercept.drone")
|
||||||
|
|
||||||
|
drone_bp = Blueprint("drone", __name__, url_prefix="/drone")
|
||||||
|
|
||||||
|
_correlator: DroneCorrelator | None = None
|
||||||
|
_remote_id_scanner: RemoteIDScanner | None = None
|
||||||
|
_rf_detector: RFDetector | None = None
|
||||||
|
_obs_queue: queue.Queue | None = None # raw observations from scanners/detectors
|
||||||
|
_relay_thread: threading.Thread | None = None
|
||||||
|
_drone_running = False
|
||||||
|
_drone_lock = threading.Lock()
|
||||||
|
|
||||||
|
_SENTINEL = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _relay_observations() -> None:
|
||||||
|
"""Read raw observations from _obs_queue and feed them into the correlator."""
|
||||||
|
while True:
|
||||||
|
obs = _obs_queue.get()
|
||||||
|
if obs is _SENTINEL:
|
||||||
|
break
|
||||||
|
if _correlator is not None:
|
||||||
|
_correlator.process(obs)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_workers() -> None:
|
||||||
|
global _correlator, _remote_id_scanner, _rf_detector, _obs_queue, _relay_thread
|
||||||
|
if _obs_queue is None:
|
||||||
|
_obs_queue = queue.Queue(maxsize=512)
|
||||||
|
if _correlator is None:
|
||||||
|
_correlator = DroneCorrelator(output_queue=app_module.drone_queue)
|
||||||
|
if _remote_id_scanner is None:
|
||||||
|
_remote_id_scanner = RemoteIDScanner(output_queue=_obs_queue)
|
||||||
|
if _rf_detector is None:
|
||||||
|
_rf_detector = RFDetector(output_queue=_obs_queue)
|
||||||
|
if _relay_thread is None or not _relay_thread.is_alive():
|
||||||
|
_relay_thread = threading.Thread(target=_relay_observations, daemon=True)
|
||||||
|
_relay_thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
@drone_bp.route("/devices")
|
||||||
|
def devices():
|
||||||
|
"""Return available WiFi interfaces and SDR devices for drone detection."""
|
||||||
|
result: dict = {"wifi_interfaces": [], "sdr_devices": []}
|
||||||
|
|
||||||
|
# WiFi interfaces via iw/iwconfig
|
||||||
|
if platform.system() == "Darwin":
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["networksetup", "-listallhardwareports"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
).stdout
|
||||||
|
lines = out.split("\n")
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if "Wi-Fi" in line or "AirPort" in line:
|
||||||
|
port = line.replace("Hardware Port:", "").strip()
|
||||||
|
for j in range(i + 1, min(i + 3, len(lines))):
|
||||||
|
if "Device:" in lines[j]:
|
||||||
|
dev = lines[j].split("Device:")[1].strip()
|
||||||
|
result["wifi_interfaces"].append(
|
||||||
|
{
|
||||||
|
"name": dev,
|
||||||
|
"display_name": f"{port} ({dev})",
|
||||||
|
"type": "internal",
|
||||||
|
"monitor_capable": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["iw", "dev"], capture_output=True, text=True, timeout=5).stdout
|
||||||
|
current: str | None = None
|
||||||
|
for line in out.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("Interface"):
|
||||||
|
current = line.split()[1]
|
||||||
|
elif current and "type" in line:
|
||||||
|
iface_type = line.split()[-1]
|
||||||
|
result["wifi_interfaces"].append(
|
||||||
|
{
|
||||||
|
"name": current,
|
||||||
|
"display_name": f"{current} ({iface_type})",
|
||||||
|
"type": iface_type,
|
||||||
|
"monitor_capable": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
current = None
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["iwconfig"], capture_output=True, text=True, timeout=5).stdout
|
||||||
|
for line in out.split("\n"):
|
||||||
|
if "IEEE 802.11" in line:
|
||||||
|
iface = line.split()[0]
|
||||||
|
result["wifi_interfaces"].append(
|
||||||
|
{
|
||||||
|
"name": iface,
|
||||||
|
"display_name": f"{iface} (managed)",
|
||||||
|
"type": "managed",
|
||||||
|
"monitor_capable": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# SDR devices
|
||||||
|
try:
|
||||||
|
from utils.sdr import SDRFactory
|
||||||
|
|
||||||
|
for sdr in SDRFactory.detect_devices():
|
||||||
|
sdr_type = sdr.sdr_type.value if hasattr(sdr.sdr_type, "value") else str(sdr.sdr_type)
|
||||||
|
display = sdr.name
|
||||||
|
if sdr.serial and sdr.serial not in ("N/A", "Unknown"):
|
||||||
|
display = f"{sdr.name} (SN: {sdr.serial[-8:]})"
|
||||||
|
result["sdr_devices"].append(
|
||||||
|
{"index": sdr.index, "name": sdr.name, "display_name": display, "type": sdr_type}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
running_as_root = os.geteuid() == 0
|
||||||
|
warnings = []
|
||||||
|
if not running_as_root:
|
||||||
|
warnings.append(
|
||||||
|
{
|
||||||
|
"type": "privileges",
|
||||||
|
"message": "Not running as root — WiFi monitor mode may be unavailable.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"devices": result,
|
||||||
|
"running_as_root": running_as_root,
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@drone_bp.route("/status")
|
||||||
|
def status():
|
||||||
|
vectors = []
|
||||||
|
if _remote_id_scanner and _remote_id_scanner.running:
|
||||||
|
vectors.append("REMOTE_ID")
|
||||||
|
if _rf_detector and _rf_detector.running:
|
||||||
|
vectors.append("RF")
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"running": _drone_running,
|
||||||
|
"vectors": vectors,
|
||||||
|
"contact_count": len(_correlator.get_all()) if _correlator else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@drone_bp.route("/contacts")
|
||||||
|
def contacts():
|
||||||
|
if not _correlator:
|
||||||
|
return jsonify([])
|
||||||
|
return jsonify(_correlator.get_all())
|
||||||
|
|
||||||
|
|
||||||
|
@drone_bp.route("/start", methods=["POST"])
|
||||||
|
def start():
|
||||||
|
global _drone_running
|
||||||
|
body = request.json or {}
|
||||||
|
wifi_iface = body.get("wifi_iface") or None
|
||||||
|
try:
|
||||||
|
rtl_index = validate_device_index(body.get("rtl_sdr_index", 0))
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
use_hackrf = bool(body.get("use_hackrf", True))
|
||||||
|
|
||||||
|
with _drone_lock:
|
||||||
|
_ensure_workers()
|
||||||
|
if not _drone_running:
|
||||||
|
if _remote_id_scanner:
|
||||||
|
_remote_id_scanner.start(wifi_iface=wifi_iface)
|
||||||
|
if _rf_detector:
|
||||||
|
_rf_detector.start(rtl_sdr_index=rtl_index, use_hackrf=use_hackrf)
|
||||||
|
_drone_running = True
|
||||||
|
logger.info("Drone detection started")
|
||||||
|
|
||||||
|
return jsonify({"status": "ok", "running": True})
|
||||||
|
|
||||||
|
|
||||||
|
@drone_bp.route("/stop", methods=["POST"])
|
||||||
|
def stop():
|
||||||
|
global _drone_running
|
||||||
|
with _drone_lock:
|
||||||
|
if _remote_id_scanner:
|
||||||
|
_remote_id_scanner.stop()
|
||||||
|
if _rf_detector:
|
||||||
|
_rf_detector.stop()
|
||||||
|
if _obs_queue is not None:
|
||||||
|
_obs_queue.put_nowait(_SENTINEL)
|
||||||
|
_drone_running = False
|
||||||
|
logger.info("Drone detection stopped")
|
||||||
|
return jsonify({"status": "ok", "running": False})
|
||||||
|
|
||||||
|
|
||||||
|
@drone_bp.route("/stream")
|
||||||
|
def stream():
|
||||||
|
return Response(
|
||||||
|
sse_stream_fanout(
|
||||||
|
source_queue=app_module.drone_queue,
|
||||||
|
channel_key="drone",
|
||||||
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
|
),
|
||||||
|
mimetype="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
"""VHF DSC (Digital Selective Calling) routes.
|
||||||
|
|
||||||
|
DSC operates on VHF Channel 70 (156.525 MHz) for maritime
|
||||||
|
distress and safety communications per ITU-R M.493.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import queue
|
||||||
|
import select
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
from utils.constants import (
|
||||||
|
DSC_SAMPLE_RATE,
|
||||||
|
DSC_TERMINATE_TIMEOUT,
|
||||||
|
DSC_VHF_FREQUENCY_MHZ,
|
||||||
|
)
|
||||||
|
from utils.database import (
|
||||||
|
acknowledge_dsc_alert,
|
||||||
|
get_dsc_alert,
|
||||||
|
get_dsc_alert_summary,
|
||||||
|
get_dsc_alerts,
|
||||||
|
store_dsc_alert,
|
||||||
|
)
|
||||||
|
from utils.dependencies import get_tool_path
|
||||||
|
from utils.dsc.parser import parse_dsc_message
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.process import register_process, unregister_process
|
||||||
|
from utils.responses import api_error
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
from utils.validation import (
|
||||||
|
validate_device_index,
|
||||||
|
validate_gain,
|
||||||
|
validate_rtl_tcp_host,
|
||||||
|
validate_rtl_tcp_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.dsc')
|
||||||
|
|
||||||
|
dsc_bp = Blueprint('dsc', __name__, url_prefix='/dsc')
|
||||||
|
|
||||||
|
# Module state (track if running independent of process state)
|
||||||
|
dsc_running = False
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
dsc_active_device: int | None = None
|
||||||
|
dsc_active_sdr_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dsc_decoder_path() -> str | None:
|
||||||
|
"""Get path to DSC decoder."""
|
||||||
|
# Check for our custom decoder
|
||||||
|
project_bin = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'bin', 'dsc-decoder')
|
||||||
|
if os.path.isfile(project_bin) and os.access(project_bin, os.X_OK):
|
||||||
|
return project_bin
|
||||||
|
|
||||||
|
# Check system PATH
|
||||||
|
system_decoder = shutil.which('dsc-decoder')
|
||||||
|
if system_decoder:
|
||||||
|
return system_decoder
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _check_dsc_tools() -> dict:
|
||||||
|
"""Check availability of DSC decoding tools."""
|
||||||
|
rtl_fm_path = get_tool_path('rtl_fm')
|
||||||
|
decoder_path = _get_dsc_decoder_path()
|
||||||
|
|
||||||
|
# Check for scipy/numpy (needed for decoder)
|
||||||
|
scipy_available = False
|
||||||
|
try:
|
||||||
|
import numpy
|
||||||
|
import scipy
|
||||||
|
scipy_available = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
'rtl_fm': {
|
||||||
|
'available': rtl_fm_path is not None,
|
||||||
|
'path': rtl_fm_path
|
||||||
|
},
|
||||||
|
'dsc_decoder': {
|
||||||
|
'available': decoder_path is not None,
|
||||||
|
'path': decoder_path
|
||||||
|
},
|
||||||
|
'scipy': {
|
||||||
|
'available': scipy_available,
|
||||||
|
'note': 'Required for DSC signal processing'
|
||||||
|
},
|
||||||
|
'ready': rtl_fm_path is not None and decoder_path is not None and scipy_available
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> None:
|
||||||
|
"""
|
||||||
|
Stream DSC decoder output to queue using PTY for unbuffered output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
master_fd: PTY master file descriptor
|
||||||
|
decoder_process: Decoder subprocess
|
||||||
|
"""
|
||||||
|
global dsc_running
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_module.dsc_queue.put({'type': 'status', 'status': 'started'})
|
||||||
|
|
||||||
|
buffer = ""
|
||||||
|
while dsc_running:
|
||||||
|
try:
|
||||||
|
ready, _, _ = select.select([master_fd], [], [], 1.0)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
if ready:
|
||||||
|
try:
|
||||||
|
data = os.read(master_fd, 1024)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
buffer += data.decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
while '\n' in buffer:
|
||||||
|
line, buffer = buffer.split('\n', 1)
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse DSC message
|
||||||
|
parsed = parse_dsc_message(line)
|
||||||
|
if parsed:
|
||||||
|
# Generate unique message ID
|
||||||
|
msg_id = f"{parsed['source_mmsi']}_{int(time.time() * 1000)}"
|
||||||
|
parsed['id'] = msg_id
|
||||||
|
|
||||||
|
# Store in transient DataStore
|
||||||
|
app_module.dsc_messages.set(msg_id, parsed)
|
||||||
|
|
||||||
|
# Queue for SSE
|
||||||
|
try:
|
||||||
|
app_module.dsc_queue.put_nowait(parsed)
|
||||||
|
except queue.Full:
|
||||||
|
logger.warning("DSC queue full, dropping message")
|
||||||
|
|
||||||
|
# Store critical alerts permanently
|
||||||
|
if parsed.get('is_critical'):
|
||||||
|
_store_critical_alert(parsed)
|
||||||
|
else:
|
||||||
|
# Raw output for debugging
|
||||||
|
app_module.dsc_queue.put({
|
||||||
|
'type': 'raw',
|
||||||
|
'text': line
|
||||||
|
})
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if process is still running
|
||||||
|
if decoder_process.poll() is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DSC decoder error: {e}")
|
||||||
|
app_module.dsc_queue.put({
|
||||||
|
'type': 'error',
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
global dsc_active_device, dsc_active_sdr_type
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.close(master_fd)
|
||||||
|
dsc_running = False
|
||||||
|
# Cleanup both processes
|
||||||
|
with app_module.dsc_lock:
|
||||||
|
rtl_proc = app_module.dsc_rtl_process
|
||||||
|
for proc in [rtl_proc, decoder_process]:
|
||||||
|
if proc:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
proc.kill()
|
||||||
|
unregister_process(proc)
|
||||||
|
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
|
with app_module.dsc_lock:
|
||||||
|
app_module.dsc_process = None
|
||||||
|
app_module.dsc_rtl_process = None
|
||||||
|
# Release SDR device
|
||||||
|
if dsc_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||||
|
dsc_active_device = None
|
||||||
|
dsc_active_sdr_type = None
|
||||||
|
|
||||||
|
|
||||||
|
def _store_critical_alert(msg: dict) -> None:
|
||||||
|
"""Store critical DSC alert (DISTRESS/URGENCY) to database."""
|
||||||
|
try:
|
||||||
|
store_dsc_alert(
|
||||||
|
source_mmsi=msg.get('source_mmsi', ''),
|
||||||
|
format_code=str(msg.get('format_code', '')),
|
||||||
|
category=msg.get('category', 'UNKNOWN'),
|
||||||
|
source_name=msg.get('source_name'),
|
||||||
|
dest_mmsi=msg.get('dest_mmsi'),
|
||||||
|
nature_of_distress=msg.get('nature_of_distress'),
|
||||||
|
latitude=msg.get('latitude'),
|
||||||
|
longitude=msg.get('longitude'),
|
||||||
|
raw_message=msg.get('raw_message')
|
||||||
|
)
|
||||||
|
logger.info(f"Stored {msg.get('category')} alert from {msg.get('source_mmsi')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to store DSC alert: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_rtl_stderr(process: subprocess.Popen) -> None:
|
||||||
|
"""Monitor rtl_fm stderr for errors."""
|
||||||
|
global dsc_running
|
||||||
|
|
||||||
|
try:
|
||||||
|
for line in process.stderr:
|
||||||
|
if not dsc_running:
|
||||||
|
break
|
||||||
|
err_text = line.decode('utf-8', errors='replace').strip()
|
||||||
|
if err_text:
|
||||||
|
logger.debug(f"[RTL_FM] {err_text}")
|
||||||
|
|
||||||
|
# Check for device busy error
|
||||||
|
if 'usb_claim_interface' in err_text.lower():
|
||||||
|
app_module.dsc_queue.put({
|
||||||
|
'type': 'error',
|
||||||
|
'error': 'SDR device busy',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'suggestion': 'Use a different SDR device or stop other SDR processes'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for other common errors
|
||||||
|
if 'no supported devices' in err_text.lower():
|
||||||
|
app_module.dsc_queue.put({
|
||||||
|
'type': 'error',
|
||||||
|
'error': 'No SDR device found',
|
||||||
|
'error_type': 'NO_DEVICE'
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/status')
|
||||||
|
def get_status() -> Response:
|
||||||
|
"""Get DSC decoder status."""
|
||||||
|
global dsc_running
|
||||||
|
|
||||||
|
with app_module.dsc_lock:
|
||||||
|
running = (
|
||||||
|
dsc_running and
|
||||||
|
app_module.dsc_process is not None and
|
||||||
|
app_module.dsc_process.poll() is None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get message counts
|
||||||
|
message_count = len(app_module.dsc_messages)
|
||||||
|
alert_summary = get_dsc_alert_summary()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'running': running,
|
||||||
|
'frequency': DSC_VHF_FREQUENCY_MHZ,
|
||||||
|
'message_count': message_count,
|
||||||
|
'alerts': alert_summary
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/tools')
|
||||||
|
def check_tools() -> Response:
|
||||||
|
"""Check DSC decoder tool availability."""
|
||||||
|
tools = _check_dsc_tools()
|
||||||
|
return jsonify(tools)
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/start', methods=['POST'])
|
||||||
|
def start_decoding() -> Response:
|
||||||
|
"""Start DSC decoder."""
|
||||||
|
global dsc_running
|
||||||
|
|
||||||
|
with app_module.dsc_lock:
|
||||||
|
if app_module.dsc_process and app_module.dsc_process.poll() is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'DSC decoder already running'
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Check tools
|
||||||
|
tools = _check_dsc_tools()
|
||||||
|
if not tools['ready']:
|
||||||
|
missing = []
|
||||||
|
if not tools['rtl_fm']['available']:
|
||||||
|
missing.append('rtl_fm')
|
||||||
|
if not tools['dsc_decoder']['available']:
|
||||||
|
missing.append('dsc-decoder')
|
||||||
|
if not tools['scipy']['available']:
|
||||||
|
missing.append('scipy/numpy')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Missing required tools: {", ".join(missing)}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Validate device
|
||||||
|
try:
|
||||||
|
device = validate_device_index(data.get('device', '0'))
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate gain
|
||||||
|
try:
|
||||||
|
gain = validate_gain(data.get('gain', '40'))
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Get SDR type from request
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
|
||||||
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
|
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||||
|
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
|
# Check if device is available using centralized registry (skip for remote rtl_tcp)
|
||||||
|
global dsc_active_device, dsc_active_sdr_type
|
||||||
|
if not rtl_tcp_host:
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
dsc_active_device = device_int
|
||||||
|
dsc_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not app_module.dsc_queue.empty():
|
||||||
|
try:
|
||||||
|
app_module.dsc_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Build rtl_fm command via SDR abstraction layer
|
||||||
|
decoder_path = tools['dsc_decoder']['path']
|
||||||
|
|
||||||
|
if rtl_tcp_host:
|
||||||
|
try:
|
||||||
|
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||||
|
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||||
|
except ValueError as e:
|
||||||
|
return api_error(str(e), 400)
|
||||||
|
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||||
|
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||||
|
else:
|
||||||
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=int(device))
|
||||||
|
|
||||||
|
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||||
|
rtl_cmd = list(builder.build_fm_demod_command(
|
||||||
|
device=sdr_device,
|
||||||
|
frequency_mhz=DSC_VHF_FREQUENCY_MHZ,
|
||||||
|
sample_rate=DSC_SAMPLE_RATE,
|
||||||
|
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||||
|
modulation='fm',
|
||||||
|
squelch=0,
|
||||||
|
))
|
||||||
|
# Ensure trailing '-' for stdin piping and add DC blocking filter
|
||||||
|
if rtl_cmd and rtl_cmd[-1] == '-':
|
||||||
|
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-']
|
||||||
|
|
||||||
|
# Decoder command
|
||||||
|
decoder_cmd = [decoder_path]
|
||||||
|
|
||||||
|
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(decoder_cmd)
|
||||||
|
logger.info(f"Starting DSC decoder: {full_cmd}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start rtl_fm subprocess
|
||||||
|
rtl_process = subprocess.Popen(
|
||||||
|
rtl_cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
register_process(rtl_process)
|
||||||
|
|
||||||
|
# Start stderr monitor thread
|
||||||
|
stderr_thread = threading.Thread(
|
||||||
|
target=monitor_rtl_stderr,
|
||||||
|
args=(rtl_process,),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
stderr_thread.start()
|
||||||
|
|
||||||
|
# Create PTY for decoder output
|
||||||
|
master_fd, slave_fd = pty.openpty()
|
||||||
|
|
||||||
|
# Start decoder subprocess
|
||||||
|
decoder_process = subprocess.Popen(
|
||||||
|
decoder_cmd,
|
||||||
|
stdin=rtl_process.stdout,
|
||||||
|
stdout=slave_fd,
|
||||||
|
stderr=slave_fd,
|
||||||
|
close_fds=True
|
||||||
|
)
|
||||||
|
register_process(decoder_process)
|
||||||
|
|
||||||
|
os.close(slave_fd)
|
||||||
|
rtl_process.stdout.close()
|
||||||
|
|
||||||
|
# Store process references
|
||||||
|
app_module.dsc_process = decoder_process
|
||||||
|
app_module.dsc_rtl_process = rtl_process
|
||||||
|
dsc_running = True
|
||||||
|
|
||||||
|
# Start output streaming thread
|
||||||
|
output_thread = threading.Thread(
|
||||||
|
target=stream_dsc_decoder,
|
||||||
|
args=(master_fd, decoder_process),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
output_thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'frequency': DSC_VHF_FREQUENCY_MHZ,
|
||||||
|
'device': device,
|
||||||
|
'gain': gain,
|
||||||
|
'command': full_cmd
|
||||||
|
})
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
# Kill orphaned rtl_fm process
|
||||||
|
try:
|
||||||
|
rtl_process.terminate()
|
||||||
|
rtl_process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
rtl_process.kill()
|
||||||
|
# Release device on failure
|
||||||
|
if dsc_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||||
|
dsc_active_device = None
|
||||||
|
dsc_active_sdr_type = None
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Tool not found: {e.filename}'
|
||||||
|
}), 400
|
||||||
|
except Exception as e:
|
||||||
|
# Kill orphaned rtl_fm process if it was started
|
||||||
|
try:
|
||||||
|
rtl_process.terminate()
|
||||||
|
rtl_process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
rtl_process.kill()
|
||||||
|
# Release device on failure
|
||||||
|
if dsc_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||||
|
dsc_active_device = None
|
||||||
|
dsc_active_sdr_type = None
|
||||||
|
logger.error(f"Failed to start DSC decoder: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_decoding() -> Response:
|
||||||
|
"""Stop DSC decoder."""
|
||||||
|
global dsc_running, dsc_active_device, dsc_active_sdr_type
|
||||||
|
|
||||||
|
with app_module.dsc_lock:
|
||||||
|
if not app_module.dsc_process:
|
||||||
|
return jsonify({'status': 'not_running'})
|
||||||
|
|
||||||
|
dsc_running = False
|
||||||
|
|
||||||
|
# Terminate rtl_fm process first
|
||||||
|
if app_module.dsc_rtl_process:
|
||||||
|
try:
|
||||||
|
app_module.dsc_rtl_process.terminate()
|
||||||
|
app_module.dsc_rtl_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
app_module.dsc_rtl_process.kill()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Terminate decoder process
|
||||||
|
if app_module.dsc_process:
|
||||||
|
try:
|
||||||
|
app_module.dsc_process.terminate()
|
||||||
|
app_module.dsc_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
app_module.dsc_process.kill()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
app_module.dsc_process = None
|
||||||
|
app_module.dsc_rtl_process = None
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if dsc_active_device is not None:
|
||||||
|
app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr')
|
||||||
|
dsc_active_device = None
|
||||||
|
dsc_active_sdr_type = None
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/stream')
|
||||||
|
def stream() -> Response:
|
||||||
|
"""SSE stream for real-time DSC messages."""
|
||||||
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
|
process_event('dsc', msg, msg.get('type'))
|
||||||
|
|
||||||
|
response = Response(
|
||||||
|
sse_stream_fanout(
|
||||||
|
source_queue=app_module.dsc_queue,
|
||||||
|
channel_key='dsc',
|
||||||
|
timeout=1.0,
|
||||||
|
keepalive_interval=30.0,
|
||||||
|
on_message=_on_msg,
|
||||||
|
),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
)
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/messages')
|
||||||
|
def get_messages() -> Response:
|
||||||
|
"""Get current DSC messages from transient store."""
|
||||||
|
messages = list(app_module.dsc_messages.values())
|
||||||
|
|
||||||
|
# Sort by timestamp (newest first)
|
||||||
|
messages.sort(key=lambda m: m.get('timestamp', ''), reverse=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'count': len(messages),
|
||||||
|
'messages': messages
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/alerts')
|
||||||
|
def get_alerts_endpoint() -> Response:
|
||||||
|
"""Get stored DSC alerts (paginated)."""
|
||||||
|
# Parse query params
|
||||||
|
category = request.args.get('category')
|
||||||
|
acknowledged = request.args.get('acknowledged')
|
||||||
|
limit = min(int(request.args.get('limit', 50)), 200)
|
||||||
|
offset = int(request.args.get('offset', 0))
|
||||||
|
|
||||||
|
# Convert acknowledged param
|
||||||
|
ack_filter = None
|
||||||
|
if acknowledged is not None:
|
||||||
|
ack_filter = acknowledged.lower() in ('true', '1', 'yes')
|
||||||
|
|
||||||
|
alerts = get_dsc_alerts(
|
||||||
|
category=category,
|
||||||
|
acknowledged=ack_filter,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = get_dsc_alert_summary()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'alerts': alerts,
|
||||||
|
'count': len(alerts),
|
||||||
|
'summary': summary,
|
||||||
|
'pagination': {
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/alerts/<int:alert_id>')
|
||||||
|
def get_alert(alert_id: int) -> Response:
|
||||||
|
"""Get a specific DSC alert by ID."""
|
||||||
|
alert = get_dsc_alert(alert_id)
|
||||||
|
if not alert:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Alert not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
return jsonify(alert)
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/alerts/<int:alert_id>/acknowledge', methods=['POST'])
|
||||||
|
def acknowledge_alert(alert_id: int) -> Response:
|
||||||
|
"""Acknowledge a DSC alert."""
|
||||||
|
data = request.json or {}
|
||||||
|
notes = data.get('notes')
|
||||||
|
|
||||||
|
success = acknowledge_dsc_alert(alert_id, notes)
|
||||||
|
if not success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Alert not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'acknowledged',
|
||||||
|
'alert_id': alert_id
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/alerts/summary')
|
||||||
|
def get_alerts_summary() -> Response:
|
||||||
|
"""Get summary of unacknowledged DSC alerts."""
|
||||||
|
summary = get_dsc_alert_summary()
|
||||||
|
return jsonify(summary)
|
||||||
@@ -3,20 +3,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import queue
|
import queue
|
||||||
import time
|
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, Response, jsonify
|
||||||
|
|
||||||
from utils.logging import get_logger
|
|
||||||
from utils.sse import format_sse
|
|
||||||
from utils.gps import (
|
from utils.gps import (
|
||||||
get_gps_reader,
|
|
||||||
start_gpsd,
|
|
||||||
stop_gps,
|
|
||||||
get_current_position,
|
|
||||||
GPSPosition,
|
GPSPosition,
|
||||||
|
GPSSkyData,
|
||||||
|
detect_gps_devices,
|
||||||
|
get_current_position,
|
||||||
|
get_gps_reader,
|
||||||
|
is_gpsd_running,
|
||||||
|
start_gpsd,
|
||||||
|
start_gpsd_daemon,
|
||||||
|
stop_gps,
|
||||||
|
stop_gpsd_daemon,
|
||||||
)
|
)
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
|
||||||
logger = get_logger('intercept.gps')
|
logger = get_logger('intercept.gps')
|
||||||
|
|
||||||
@@ -29,12 +32,24 @@ _gps_queue: queue.Queue = queue.Queue(maxsize=100)
|
|||||||
def _position_callback(position: GPSPosition) -> None:
|
def _position_callback(position: GPSPosition) -> None:
|
||||||
"""Callback to queue position updates for SSE stream."""
|
"""Callback to queue position updates for SSE stream."""
|
||||||
try:
|
try:
|
||||||
_gps_queue.put_nowait(position.to_dict())
|
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
# Discard oldest if queue is full
|
# Discard oldest if queue is full
|
||||||
try:
|
try:
|
||||||
_gps_queue.get_nowait()
|
_gps_queue.get_nowait()
|
||||||
_gps_queue.put_nowait(position.to_dict())
|
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _sky_callback(sky: GPSSkyData) -> None:
|
||||||
|
"""Callback to queue sky data updates for SSE stream."""
|
||||||
|
try:
|
||||||
|
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
_gps_queue.get_nowait()
|
||||||
|
_gps_queue.put_nowait({'type': 'sky', **sky.to_dict()})
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -45,36 +60,47 @@ def auto_connect_gps():
|
|||||||
Automatically connect to gpsd if available.
|
Automatically connect to gpsd if available.
|
||||||
|
|
||||||
Called on page load to seamlessly enable GPS if gpsd is running.
|
Called on page load to seamlessly enable GPS if gpsd is running.
|
||||||
|
If gpsd is not running, attempts to detect GPS devices and start gpsd.
|
||||||
Returns current status if already connected.
|
Returns current status if already connected.
|
||||||
"""
|
"""
|
||||||
import socket
|
|
||||||
|
|
||||||
# Check if already running
|
# Check if already running
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
if reader and reader.is_running:
|
if reader and reader.is_running:
|
||||||
|
# Ensure stream callbacks are attached for this process.
|
||||||
|
reader.add_callback(_position_callback)
|
||||||
|
reader.add_sky_callback(_sky_callback)
|
||||||
position = reader.position
|
position = reader.position
|
||||||
|
sky = reader.sky
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'connected',
|
'status': 'connected',
|
||||||
'source': 'gpsd',
|
'source': 'gpsd',
|
||||||
'has_fix': position is not None,
|
'has_fix': position is not None,
|
||||||
'position': position.to_dict() if position else None
|
'position': position.to_dict() if position else None,
|
||||||
|
'sky': sky.to_dict() if sky else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Try to connect to gpsd on localhost:2947
|
|
||||||
host = 'localhost'
|
host = 'localhost'
|
||||||
port = 2947
|
port = 2947
|
||||||
|
|
||||||
# First check if gpsd is reachable
|
# If gpsd isn't running, try to detect a device and start it
|
||||||
try:
|
if not is_gpsd_running(host, port):
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
devices = detect_gps_devices()
|
||||||
sock.settimeout(1.0)
|
if not devices:
|
||||||
sock.connect((host, port))
|
return jsonify({
|
||||||
sock.close()
|
'status': 'unavailable',
|
||||||
except Exception:
|
'message': 'No GPS device detected'
|
||||||
return jsonify({
|
})
|
||||||
'status': 'unavailable',
|
|
||||||
'message': 'gpsd not running'
|
# Try to start gpsd with the first detected device
|
||||||
})
|
device_path = devices[0]['path']
|
||||||
|
success, msg = start_gpsd_daemon(device_path, host, port)
|
||||||
|
if not success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'unavailable',
|
||||||
|
'message': msg,
|
||||||
|
'devices': devices,
|
||||||
|
})
|
||||||
|
logger.info(f"Auto-started gpsd on {device_path}")
|
||||||
|
|
||||||
# Clear the queue
|
# Clear the queue
|
||||||
while not _gps_queue.empty():
|
while not _gps_queue.empty():
|
||||||
@@ -84,14 +110,17 @@ def auto_connect_gps():
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Start the gpsd client
|
# Start the gpsd client
|
||||||
success = start_gpsd(host, port, callback=_position_callback)
|
success = start_gpsd(host, port,
|
||||||
|
callback=_position_callback,
|
||||||
|
sky_callback=_sky_callback)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'connected',
|
'status': 'connected',
|
||||||
'source': 'gpsd',
|
'source': 'gpsd',
|
||||||
'has_fix': False,
|
'has_fix': False,
|
||||||
'position': None
|
'position': None,
|
||||||
|
'sky': None,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -100,14 +129,26 @@ def auto_connect_gps():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@gps_bp.route('/devices')
|
||||||
|
def list_gps_devices():
|
||||||
|
"""List detected GPS serial devices."""
|
||||||
|
devices = detect_gps_devices()
|
||||||
|
return jsonify({
|
||||||
|
'devices': devices,
|
||||||
|
'gpsd_running': is_gpsd_running(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/stop', methods=['POST'])
|
@gps_bp.route('/stop', methods=['POST'])
|
||||||
def stop_gps_reader():
|
def stop_gps_reader():
|
||||||
"""Stop GPS client."""
|
"""Stop GPS client and gpsd daemon if we started it."""
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
if reader:
|
if reader:
|
||||||
reader.remove_callback(_position_callback)
|
reader.remove_callback(_position_callback)
|
||||||
|
reader.remove_sky_callback(_sky_callback)
|
||||||
|
|
||||||
stop_gps()
|
stop_gps()
|
||||||
|
stop_gpsd_daemon()
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -122,15 +163,18 @@ def get_gps_status():
|
|||||||
'running': False,
|
'running': False,
|
||||||
'device': None,
|
'device': None,
|
||||||
'position': None,
|
'position': None,
|
||||||
|
'sky': None,
|
||||||
'error': None,
|
'error': None,
|
||||||
'message': 'GPS client not started'
|
'message': 'GPS client not started'
|
||||||
})
|
})
|
||||||
|
|
||||||
position = reader.position
|
position = reader.position
|
||||||
|
sky = reader.sky
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'running': reader.is_running,
|
'running': reader.is_running,
|
||||||
'device': reader.device_path,
|
'device': reader.device_path,
|
||||||
'position': position.to_dict() if position else None,
|
'position': position.to_dict() if position else None,
|
||||||
|
'sky': sky.to_dict() if sky else None,
|
||||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
||||||
'error': reader.error,
|
'error': reader.error,
|
||||||
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
|
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
|
||||||
@@ -161,51 +205,43 @@ def get_position():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/debug')
|
@gps_bp.route('/satellites')
|
||||||
def debug_gps():
|
def get_satellites():
|
||||||
"""Debug endpoint showing GPS client state."""
|
"""Get current satellite sky view data."""
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
|
|
||||||
if not reader:
|
if not reader or not reader.is_running:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'reader': None,
|
'status': 'waiting',
|
||||||
'message': 'No GPS client initialized'
|
'running': False,
|
||||||
|
'message': 'GPS client not running'
|
||||||
})
|
})
|
||||||
|
|
||||||
position = reader.position
|
sky = reader.sky
|
||||||
return jsonify({
|
if sky:
|
||||||
'running': reader.is_running,
|
return jsonify({
|
||||||
'source': 'gpsd',
|
'status': 'ok',
|
||||||
'device': reader.device_path,
|
'sky': sky.to_dict()
|
||||||
'host': reader.host,
|
})
|
||||||
'port': reader.port,
|
else:
|
||||||
'has_position': position is not None,
|
return jsonify({
|
||||||
'position': position.to_dict() if position else None,
|
'status': 'waiting',
|
||||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
'message': 'Waiting for satellite data'
|
||||||
'error': reader.error,
|
})
|
||||||
'callbacks_registered': len(reader._callbacks),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/stream')
|
@gps_bp.route('/stream')
|
||||||
def stream_gps():
|
def stream_gps():
|
||||||
"""SSE stream of GPS position updates."""
|
"""SSE stream of GPS position and sky updates."""
|
||||||
def generate() -> Generator[str, None, None]:
|
response = Response(
|
||||||
last_keepalive = time.time()
|
sse_stream_fanout(
|
||||||
keepalive_interval = 30.0
|
source_queue=_gps_queue,
|
||||||
|
channel_key='gps',
|
||||||
while True:
|
timeout=1.0,
|
||||||
try:
|
keepalive_interval=30.0,
|
||||||
position = _gps_queue.get(timeout=1)
|
),
|
||||||
last_keepalive = time.time()
|
mimetype='text/event-stream',
|
||||||
yield format_sse({'type': 'position', **position})
|
)
|
||||||
except queue.Empty:
|
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= keepalive_interval:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
@@ -0,0 +1,567 @@
|
|||||||
|
"""Ground Station REST API + SSE + WebSocket endpoints.
|
||||||
|
|
||||||
|
Phases implemented here:
|
||||||
|
1 — Profile CRUD, scheduler control, observation history, SSE stream
|
||||||
|
3 — SigMF recording browser (list / download / delete)
|
||||||
|
5 — /ws/satellite_waterfall WebSocket
|
||||||
|
6 — Rotator config / status / point / park endpoints
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request, send_file
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
|
||||||
|
logger = get_logger('intercept.ground_station.routes')
|
||||||
|
|
||||||
|
ground_station_bp = Blueprint('ground_station', __name__, url_prefix='/ground_station')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_scheduler():
|
||||||
|
from utils.ground_station.scheduler import get_ground_station_scheduler
|
||||||
|
return get_ground_station_scheduler()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_queue():
|
||||||
|
import app as _app
|
||||||
|
return getattr(_app, 'ground_station_queue', None) or queue.Queue()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 1 — Observation Profiles
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/profiles', methods=['GET'])
|
||||||
|
def list_profiles():
|
||||||
|
from utils.ground_station.observation_profile import list_profiles as _list
|
||||||
|
return jsonify([p.to_dict() for p in _list()])
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['GET'])
|
||||||
|
def get_profile(norad_id: int):
|
||||||
|
from utils.ground_station.observation_profile import get_profile as _get
|
||||||
|
p = _get(norad_id)
|
||||||
|
if not p:
|
||||||
|
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
|
||||||
|
return jsonify(p.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/profiles', methods=['POST'])
|
||||||
|
def create_profile():
|
||||||
|
data = request.get_json(force=True) or {}
|
||||||
|
try:
|
||||||
|
_validate_profile(data)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
|
||||||
|
from utils.ground_station.observation_profile import (
|
||||||
|
ObservationProfile,
|
||||||
|
legacy_decoder_to_tasks,
|
||||||
|
normalize_tasks,
|
||||||
|
save_profile,
|
||||||
|
tasks_to_legacy_decoder,
|
||||||
|
)
|
||||||
|
tasks = normalize_tasks(data.get('tasks'))
|
||||||
|
if not tasks:
|
||||||
|
tasks = legacy_decoder_to_tasks(
|
||||||
|
str(data.get('decoder_type', 'fm')),
|
||||||
|
bool(data.get('record_iq', False)),
|
||||||
|
)
|
||||||
|
profile = ObservationProfile(
|
||||||
|
norad_id=int(data['norad_id']),
|
||||||
|
name=str(data['name']),
|
||||||
|
frequency_mhz=float(data['frequency_mhz']),
|
||||||
|
decoder_type=tasks_to_legacy_decoder(tasks),
|
||||||
|
gain=float(data.get('gain', 40.0)),
|
||||||
|
bandwidth_hz=int(data.get('bandwidth_hz', 200_000)),
|
||||||
|
min_elevation=float(data.get('min_elevation', 10.0)),
|
||||||
|
enabled=bool(data.get('enabled', True)),
|
||||||
|
record_iq=bool(data.get('record_iq', False)) or ('record_iq' in tasks),
|
||||||
|
iq_sample_rate=int(data.get('iq_sample_rate', 2_400_000)),
|
||||||
|
tasks=tasks,
|
||||||
|
)
|
||||||
|
saved = save_profile(profile)
|
||||||
|
return jsonify(saved.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['PUT'])
|
||||||
|
def update_profile(norad_id: int):
|
||||||
|
data = request.get_json(force=True) or {}
|
||||||
|
from utils.ground_station.observation_profile import (
|
||||||
|
get_profile as _get,
|
||||||
|
)
|
||||||
|
from utils.ground_station.observation_profile import (
|
||||||
|
legacy_decoder_to_tasks,
|
||||||
|
normalize_tasks,
|
||||||
|
save_profile,
|
||||||
|
tasks_to_legacy_decoder,
|
||||||
|
)
|
||||||
|
existing = _get(norad_id)
|
||||||
|
if not existing:
|
||||||
|
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
|
||||||
|
|
||||||
|
# Apply updates
|
||||||
|
for field, cast in [
|
||||||
|
('name', str), ('frequency_mhz', float), ('decoder_type', str),
|
||||||
|
('gain', float), ('bandwidth_hz', int), ('min_elevation', float),
|
||||||
|
]:
|
||||||
|
if field in data:
|
||||||
|
setattr(existing, field, cast(data[field]))
|
||||||
|
for field in ('enabled', 'record_iq'):
|
||||||
|
if field in data:
|
||||||
|
setattr(existing, field, bool(data[field]))
|
||||||
|
if 'iq_sample_rate' in data:
|
||||||
|
existing.iq_sample_rate = int(data['iq_sample_rate'])
|
||||||
|
if 'tasks' in data:
|
||||||
|
existing.tasks = normalize_tasks(data['tasks'])
|
||||||
|
elif 'decoder_type' in data:
|
||||||
|
existing.tasks = legacy_decoder_to_tasks(
|
||||||
|
str(data.get('decoder_type', existing.decoder_type)),
|
||||||
|
bool(data.get('record_iq', existing.record_iq)),
|
||||||
|
)
|
||||||
|
|
||||||
|
existing.decoder_type = tasks_to_legacy_decoder(existing.tasks)
|
||||||
|
existing.record_iq = bool(existing.record_iq) or ('record_iq' in existing.tasks)
|
||||||
|
|
||||||
|
saved = save_profile(existing)
|
||||||
|
return jsonify(saved.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/profiles/<int:norad_id>', methods=['DELETE'])
|
||||||
|
def delete_profile(norad_id: int):
|
||||||
|
from utils.ground_station.observation_profile import delete_profile as _del
|
||||||
|
ok = _del(norad_id)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404
|
||||||
|
return jsonify({'status': 'deleted', 'norad_id': norad_id})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 1 — Scheduler control
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/scheduler/status', methods=['GET'])
|
||||||
|
def scheduler_status():
|
||||||
|
return jsonify(_get_scheduler().get_status())
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/scheduler/enable', methods=['POST'])
|
||||||
|
def scheduler_enable():
|
||||||
|
data = request.get_json(force=True) or {}
|
||||||
|
try:
|
||||||
|
lat = float(data.get('lat', 0.0))
|
||||||
|
lon = float(data.get('lon', 0.0))
|
||||||
|
device = int(data.get('device', 0))
|
||||||
|
sdr_type = str(data.get('sdr_type', 'rtlsdr'))
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
return jsonify({'error': str(e)}), 400
|
||||||
|
|
||||||
|
status = _get_scheduler().enable(lat=lat, lon=lon, device=device, sdr_type=sdr_type)
|
||||||
|
return jsonify(status)
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/scheduler/disable', methods=['POST'])
|
||||||
|
def scheduler_disable():
|
||||||
|
return jsonify(_get_scheduler().disable())
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/scheduler/observations', methods=['GET'])
|
||||||
|
def get_observations():
|
||||||
|
return jsonify(_get_scheduler().get_scheduled_observations())
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/scheduler/trigger/<int:norad_id>', methods=['POST'])
|
||||||
|
def trigger_manual(norad_id: int):
|
||||||
|
ok, msg = _get_scheduler().trigger_manual(norad_id)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({'error': msg}), 400
|
||||||
|
return jsonify({'status': 'started', 'message': msg})
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/scheduler/stop', methods=['POST'])
|
||||||
|
def stop_active():
|
||||||
|
return jsonify(_get_scheduler().stop_active())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 1 — Observation history (from DB)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/observations', methods=['GET'])
|
||||||
|
def observation_history():
|
||||||
|
limit = min(int(request.args.get('limit', 50)), 200)
|
||||||
|
try:
|
||||||
|
from utils.database import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
'''SELECT * FROM ground_station_observations
|
||||||
|
ORDER BY created_at DESC LIMIT ?''',
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return jsonify([dict(r) for r in rows])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch observation history: {e}")
|
||||||
|
return jsonify([])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 1 — SSE stream
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/stream')
|
||||||
|
def sse_stream():
|
||||||
|
gs_queue = _get_queue()
|
||||||
|
return Response(
|
||||||
|
sse_stream_fanout(gs_queue, 'ground_station'),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
headers={
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 3 — SigMF recording browser
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/recordings', methods=['GET'])
|
||||||
|
def list_recordings():
|
||||||
|
try:
|
||||||
|
from utils.database import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
'SELECT * FROM sigmf_recordings ORDER BY created_at DESC LIMIT 100'
|
||||||
|
).fetchall()
|
||||||
|
return jsonify([dict(r) for r in rows])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch recordings: {e}")
|
||||||
|
return jsonify([])
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/recordings/<int:rec_id>', methods=['GET'])
|
||||||
|
def get_recording(rec_id: int):
|
||||||
|
try:
|
||||||
|
from utils.database import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
'SELECT * FROM sigmf_recordings WHERE id=?', (rec_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'error': 'Not found'}), 404
|
||||||
|
return jsonify(dict(row))
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/recordings/<int:rec_id>', methods=['DELETE'])
|
||||||
|
def delete_recording(rec_id: int):
|
||||||
|
try:
|
||||||
|
from utils.database import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
'SELECT sigmf_data_path, sigmf_meta_path FROM sigmf_recordings WHERE id=?',
|
||||||
|
(rec_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'error': 'Not found'}), 404
|
||||||
|
# Remove files
|
||||||
|
for path_col in ('sigmf_data_path', 'sigmf_meta_path'):
|
||||||
|
p = Path(row[path_col])
|
||||||
|
if p.exists():
|
||||||
|
p.unlink(missing_ok=True)
|
||||||
|
conn.execute('DELETE FROM sigmf_recordings WHERE id=?', (rec_id,))
|
||||||
|
return jsonify({'status': 'deleted', 'id': rec_id})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/recordings/<int:rec_id>/download/<file_type>')
|
||||||
|
def download_recording(rec_id: int, file_type: str):
|
||||||
|
if file_type not in ('data', 'meta'):
|
||||||
|
return jsonify({'error': 'file_type must be data or meta'}), 400
|
||||||
|
try:
|
||||||
|
from utils.database import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
'SELECT sigmf_data_path, sigmf_meta_path FROM sigmf_recordings WHERE id=?',
|
||||||
|
(rec_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'error': 'Not found'}), 404
|
||||||
|
|
||||||
|
col = 'sigmf_data_path' if file_type == 'data' else 'sigmf_meta_path'
|
||||||
|
p = Path(row[col])
|
||||||
|
if not p.exists():
|
||||||
|
return jsonify({'error': 'File not found on disk'}), 404
|
||||||
|
|
||||||
|
mimetype = 'application/octet-stream' if file_type == 'data' else 'application/json'
|
||||||
|
return send_file(p, mimetype=mimetype, as_attachment=True, download_name=p.name)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/outputs', methods=['GET'])
|
||||||
|
def list_outputs():
|
||||||
|
try:
|
||||||
|
query = '''
|
||||||
|
SELECT * FROM ground_station_outputs
|
||||||
|
WHERE (? IS NULL OR norad_id = ?)
|
||||||
|
AND (? IS NULL OR observation_id = ?)
|
||||||
|
AND (? IS NULL OR output_type = ?)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
'''
|
||||||
|
norad_id = request.args.get('norad_id', type=int)
|
||||||
|
observation_id = request.args.get('observation_id', type=int)
|
||||||
|
output_type = request.args.get('type')
|
||||||
|
|
||||||
|
from utils.database import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
query,
|
||||||
|
(
|
||||||
|
norad_id, norad_id,
|
||||||
|
observation_id, observation_id,
|
||||||
|
output_type, output_type,
|
||||||
|
),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
metadata_raw = item.get('metadata_json')
|
||||||
|
if metadata_raw:
|
||||||
|
try:
|
||||||
|
item['metadata'] = json.loads(metadata_raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
item['metadata'] = {}
|
||||||
|
else:
|
||||||
|
item['metadata'] = {}
|
||||||
|
item.pop('metadata_json', None)
|
||||||
|
results.append(item)
|
||||||
|
return jsonify(results)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/outputs/<int:output_id>/download', methods=['GET'])
|
||||||
|
def download_output(output_id: int):
|
||||||
|
try:
|
||||||
|
from utils.database import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
'SELECT file_path FROM ground_station_outputs WHERE id=?',
|
||||||
|
(output_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'error': 'Not found'}), 404
|
||||||
|
p = Path(row['file_path'])
|
||||||
|
if not p.exists():
|
||||||
|
return jsonify({'error': 'File not found on disk'}), 404
|
||||||
|
return send_file(p, as_attachment=True, download_name=p.name)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/decode-jobs', methods=['GET'])
|
||||||
|
def list_decode_jobs():
|
||||||
|
try:
|
||||||
|
query = '''
|
||||||
|
SELECT * FROM ground_station_decode_jobs
|
||||||
|
WHERE (? IS NULL OR norad_id = ?)
|
||||||
|
AND (? IS NULL OR observation_id = ?)
|
||||||
|
AND (? IS NULL OR backend = ?)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
'''
|
||||||
|
norad_id = request.args.get('norad_id', type=int)
|
||||||
|
observation_id = request.args.get('observation_id', type=int)
|
||||||
|
backend = request.args.get('backend')
|
||||||
|
limit = min(request.args.get('limit', 20, type=int) or 20, 200)
|
||||||
|
|
||||||
|
from utils.database import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
query,
|
||||||
|
(
|
||||||
|
norad_id, norad_id,
|
||||||
|
observation_id, observation_id,
|
||||||
|
backend, backend,
|
||||||
|
limit,
|
||||||
|
),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
details_raw = item.get('details_json')
|
||||||
|
if details_raw:
|
||||||
|
try:
|
||||||
|
item['details'] = json.loads(details_raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
item['details'] = {}
|
||||||
|
else:
|
||||||
|
item['details'] = {}
|
||||||
|
item.pop('details_json', None)
|
||||||
|
results.append(item)
|
||||||
|
return jsonify(results)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 5 — Live waterfall WebSocket
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def init_ground_station_websocket(app) -> None:
|
||||||
|
"""Register the /ws/satellite_waterfall WebSocket endpoint."""
|
||||||
|
try:
|
||||||
|
from flask_sock import Sock
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("flask-sock not installed — satellite waterfall WebSocket disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
sock = Sock(app)
|
||||||
|
|
||||||
|
@sock.route('/ws/satellite_waterfall')
|
||||||
|
def satellite_waterfall_ws(ws):
|
||||||
|
"""Stream binary waterfall frames from the active ground station IQ bus."""
|
||||||
|
scheduler = _get_scheduler()
|
||||||
|
wf_queue = scheduler.waterfall_queue
|
||||||
|
|
||||||
|
from utils.sse import subscribe_fanout_queue
|
||||||
|
sub_queue, unsubscribe = subscribe_fanout_queue(
|
||||||
|
source_queue=wf_queue,
|
||||||
|
channel_key='gs_waterfall',
|
||||||
|
subscriber_queue_size=120,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
frame = sub_queue.get(timeout=1.0)
|
||||||
|
try:
|
||||||
|
ws.send(frame)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
except queue.Empty:
|
||||||
|
if not ws.connected:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
unsubscribe()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 6 — Rotator
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/rotator/status', methods=['GET'])
|
||||||
|
def rotator_status():
|
||||||
|
from utils.rotator import get_rotator
|
||||||
|
return jsonify(get_rotator().get_status())
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/rotator/config', methods=['POST'])
|
||||||
|
def rotator_config():
|
||||||
|
data = request.get_json(force=True) or {}
|
||||||
|
host = str(data.get('host', '127.0.0.1'))
|
||||||
|
port = int(data.get('port', 4533))
|
||||||
|
from utils.rotator import get_rotator
|
||||||
|
ok = get_rotator().connect(host, port)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({'error': f'Could not connect to rotctld at {host}:{port}'}), 503
|
||||||
|
return jsonify(get_rotator().get_status())
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/rotator/point', methods=['POST'])
|
||||||
|
def rotator_point():
|
||||||
|
data = request.get_json(force=True) or {}
|
||||||
|
try:
|
||||||
|
az = float(data['az'])
|
||||||
|
el = float(data['el'])
|
||||||
|
except (KeyError, TypeError, ValueError) as e:
|
||||||
|
return jsonify({'error': f'az and el required: {e}'}), 400
|
||||||
|
from utils.rotator import get_rotator
|
||||||
|
ok = get_rotator().point_to(az, el)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({'error': 'Rotator command failed'}), 503
|
||||||
|
return jsonify({'status': 'ok', 'az': az, 'el': el})
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/rotator/park', methods=['POST'])
|
||||||
|
def rotator_park():
|
||||||
|
from utils.rotator import get_rotator
|
||||||
|
ok = get_rotator().park()
|
||||||
|
if not ok:
|
||||||
|
return jsonify({'error': 'Rotator park failed'}), 503
|
||||||
|
return jsonify({'status': 'parked'})
|
||||||
|
|
||||||
|
|
||||||
|
@ground_station_bp.route('/rotator/disconnect', methods=['POST'])
|
||||||
|
def rotator_disconnect():
|
||||||
|
from utils.rotator import get_rotator
|
||||||
|
get_rotator().disconnect()
|
||||||
|
return jsonify({'status': 'disconnected'})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Input validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_profile(data: dict) -> None:
|
||||||
|
if 'norad_id' not in data:
|
||||||
|
raise ValueError("norad_id is required")
|
||||||
|
if 'name' not in data:
|
||||||
|
raise ValueError("name is required")
|
||||||
|
if 'frequency_mhz' not in data:
|
||||||
|
raise ValueError("frequency_mhz is required")
|
||||||
|
try:
|
||||||
|
norad_id = int(data['norad_id'])
|
||||||
|
if norad_id <= 0:
|
||||||
|
raise ValueError("norad_id must be positive")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError("norad_id must be a positive integer")
|
||||||
|
try:
|
||||||
|
freq = float(data['frequency_mhz'])
|
||||||
|
if not (0.1 <= freq <= 3000.0):
|
||||||
|
raise ValueError("frequency_mhz must be between 0.1 and 3000")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError("frequency_mhz must be a number between 0.1 and 3000")
|
||||||
|
from utils.ground_station.observation_profile import VALID_TASK_TYPES
|
||||||
|
|
||||||
|
valid_decoders = {'fm', 'afsk', 'gmsk', 'bpsk', 'iq_only'}
|
||||||
|
if 'tasks' in data:
|
||||||
|
if not isinstance(data['tasks'], list):
|
||||||
|
raise ValueError("tasks must be a list")
|
||||||
|
invalid = [
|
||||||
|
str(task) for task in data['tasks']
|
||||||
|
if str(task).strip().lower() not in VALID_TASK_TYPES
|
||||||
|
]
|
||||||
|
if invalid:
|
||||||
|
raise ValueError(
|
||||||
|
f"tasks contains unsupported values: {', '.join(invalid)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
dt = str(data.get('decoder_type', 'fm'))
|
||||||
|
if dt not in valid_decoders:
|
||||||
|
raise ValueError(f"decoder_type must be one of: {', '.join(sorted(valid_decoders))}")
|
||||||
@@ -1,895 +0,0 @@
|
|||||||
"""Listening Post routes for radio monitoring and frequency scanning."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import queue
|
|
||||||
import select
|
|
||||||
import signal
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Generator, Optional, List, Dict
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
|
||||||
|
|
||||||
from utils.logging import get_logger
|
|
||||||
from utils.sse import format_sse
|
|
||||||
from utils.constants import (
|
|
||||||
SSE_QUEUE_TIMEOUT,
|
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
|
||||||
)
|
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
|
|
||||||
logger = get_logger('intercept.listening_post')
|
|
||||||
|
|
||||||
listening_post_bp = Blueprint('listening_post', __name__, url_prefix='/listening')
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# GLOBAL STATE
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
# Audio demodulation state
|
|
||||||
audio_process = None
|
|
||||||
audio_rtl_process = None
|
|
||||||
audio_lock = threading.Lock()
|
|
||||||
audio_running = False
|
|
||||||
audio_frequency = 0.0
|
|
||||||
audio_modulation = 'fm'
|
|
||||||
|
|
||||||
# Scanner state
|
|
||||||
scanner_thread: Optional[threading.Thread] = None
|
|
||||||
scanner_running = False
|
|
||||||
scanner_lock = threading.Lock()
|
|
||||||
scanner_paused = False
|
|
||||||
scanner_current_freq = 0.0
|
|
||||||
scanner_config = {
|
|
||||||
'start_freq': 88.0,
|
|
||||||
'end_freq': 108.0,
|
|
||||||
'step': 0.1,
|
|
||||||
'modulation': 'wfm',
|
|
||||||
'squelch': 20,
|
|
||||||
'dwell_time': 10.0, # Seconds to stay on active frequency
|
|
||||||
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
|
|
||||||
'device': 0,
|
|
||||||
'gain': 40,
|
|
||||||
'bias_t': False, # Bias-T power for external LNA
|
|
||||||
'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay
|
|
||||||
}
|
|
||||||
|
|
||||||
# Activity log
|
|
||||||
activity_log: List[Dict] = []
|
|
||||||
activity_log_lock = threading.Lock()
|
|
||||||
MAX_LOG_ENTRIES = 500
|
|
||||||
|
|
||||||
# SSE queue for scanner events
|
|
||||||
scanner_queue: queue.Queue = queue.Queue(maxsize=100)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# HELPER FUNCTIONS
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
def find_rtl_fm() -> str | None:
|
|
||||||
"""Find rtl_fm binary."""
|
|
||||||
return shutil.which('rtl_fm')
|
|
||||||
|
|
||||||
|
|
||||||
def find_rx_fm() -> str | None:
|
|
||||||
"""Find rx_fm binary (SoapySDR FM demodulator for HackRF/Airspy/LimeSDR)."""
|
|
||||||
return shutil.which('rx_fm')
|
|
||||||
|
|
||||||
|
|
||||||
def find_ffmpeg() -> str | None:
|
|
||||||
"""Find ffmpeg for audio encoding."""
|
|
||||||
return shutil.which('ffmpeg')
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def add_activity_log(event_type: str, frequency: float, details: str = ''):
|
|
||||||
"""Add entry to activity log."""
|
|
||||||
with activity_log_lock:
|
|
||||||
entry = {
|
|
||||||
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
|
||||||
'type': event_type,
|
|
||||||
'frequency': frequency,
|
|
||||||
'details': details,
|
|
||||||
}
|
|
||||||
activity_log.insert(0, entry)
|
|
||||||
# Trim log
|
|
||||||
while len(activity_log) > MAX_LOG_ENTRIES:
|
|
||||||
activity_log.pop()
|
|
||||||
|
|
||||||
# Also push to SSE queue
|
|
||||||
try:
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'log',
|
|
||||||
'entry': entry
|
|
||||||
})
|
|
||||||
except queue.Full:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# SCANNER IMPLEMENTATION
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
def scanner_loop():
|
|
||||||
"""Main scanner loop - scans frequencies looking for signals."""
|
|
||||||
global scanner_running, scanner_paused, scanner_current_freq, scanner_skip_signal
|
|
||||||
global audio_process, audio_rtl_process, audio_running, audio_frequency
|
|
||||||
|
|
||||||
logger.info("Scanner thread started")
|
|
||||||
add_activity_log('scanner_start', scanner_config['start_freq'],
|
|
||||||
f"Scanning {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
|
|
||||||
|
|
||||||
rtl_fm_path = find_rtl_fm()
|
|
||||||
|
|
||||||
if not rtl_fm_path:
|
|
||||||
logger.error("rtl_fm not found")
|
|
||||||
add_activity_log('error', 0, 'rtl_fm not found')
|
|
||||||
scanner_running = False
|
|
||||||
return
|
|
||||||
|
|
||||||
current_freq = scanner_config['start_freq']
|
|
||||||
last_signal_time = 0
|
|
||||||
signal_detected = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
while scanner_running:
|
|
||||||
# Check if paused
|
|
||||||
if scanner_paused:
|
|
||||||
time.sleep(0.1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Read config values on each iteration (allows live updates)
|
|
||||||
step_mhz = scanner_config['step'] / 1000.0
|
|
||||||
squelch = scanner_config['squelch']
|
|
||||||
mod = scanner_config['modulation']
|
|
||||||
gain = scanner_config['gain']
|
|
||||||
device = scanner_config['device']
|
|
||||||
|
|
||||||
scanner_current_freq = current_freq
|
|
||||||
|
|
||||||
# Notify clients of frequency change
|
|
||||||
try:
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'freq_change',
|
|
||||||
'frequency': current_freq,
|
|
||||||
'scanning': not signal_detected
|
|
||||||
})
|
|
||||||
except queue.Full:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Start rtl_fm at this frequency
|
|
||||||
freq_hz = int(current_freq * 1e6)
|
|
||||||
|
|
||||||
# Sample rates
|
|
||||||
if mod == 'wfm':
|
|
||||||
sample_rate = 170000
|
|
||||||
resample_rate = 32000
|
|
||||||
elif mod in ['usb', 'lsb']:
|
|
||||||
sample_rate = 12000
|
|
||||||
resample_rate = 12000
|
|
||||||
else:
|
|
||||||
sample_rate = 24000
|
|
||||||
resample_rate = 24000
|
|
||||||
|
|
||||||
# Don't use squelch in rtl_fm - we want to analyze raw audio
|
|
||||||
rtl_cmd = [
|
|
||||||
rtl_fm_path,
|
|
||||||
'-M', mod,
|
|
||||||
'-f', str(freq_hz),
|
|
||||||
'-s', str(sample_rate),
|
|
||||||
'-r', str(resample_rate),
|
|
||||||
'-g', str(gain),
|
|
||||||
'-d', str(device),
|
|
||||||
]
|
|
||||||
# Add bias-t flag if enabled (for external LNA power)
|
|
||||||
if scanner_config.get('bias_t', False):
|
|
||||||
rtl_cmd.append('-T')
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Start rtl_fm
|
|
||||||
rtl_proc = subprocess.Popen(
|
|
||||||
rtl_cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.DEVNULL
|
|
||||||
)
|
|
||||||
|
|
||||||
# Read audio data for analysis
|
|
||||||
audio_data = b''
|
|
||||||
|
|
||||||
# Read audio samples for a short period
|
|
||||||
sample_duration = 0.25 # 250ms - balance between speed and detection
|
|
||||||
bytes_needed = int(resample_rate * 2 * sample_duration) # 16-bit mono
|
|
||||||
|
|
||||||
while len(audio_data) < bytes_needed and scanner_running:
|
|
||||||
chunk = rtl_proc.stdout.read(4096)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
audio_data += chunk
|
|
||||||
|
|
||||||
# Clean up rtl_fm
|
|
||||||
rtl_proc.terminate()
|
|
||||||
try:
|
|
||||||
rtl_proc.wait(timeout=1)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
rtl_proc.kill()
|
|
||||||
|
|
||||||
# Analyze audio level
|
|
||||||
audio_detected = False
|
|
||||||
rms = 0
|
|
||||||
threshold = 500
|
|
||||||
if len(audio_data) > 100:
|
|
||||||
import struct
|
|
||||||
samples = struct.unpack(f'{len(audio_data)//2}h', audio_data)
|
|
||||||
# Calculate RMS level (root mean square)
|
|
||||||
rms = (sum(s*s for s in samples) / len(samples)) ** 0.5
|
|
||||||
|
|
||||||
# Threshold based on squelch setting
|
|
||||||
# Lower squelch = more sensitive (lower threshold)
|
|
||||||
# squelch 0 = very sensitive, squelch 100 = only strong signals
|
|
||||||
if mod == 'wfm':
|
|
||||||
# WFM: threshold 500-10000 based on squelch
|
|
||||||
threshold = 500 + (squelch * 95)
|
|
||||||
else:
|
|
||||||
# AM/NFM: threshold 300-6500 based on squelch
|
|
||||||
threshold = 300 + (squelch * 62)
|
|
||||||
|
|
||||||
audio_detected = rms > threshold
|
|
||||||
|
|
||||||
# Send level info to clients
|
|
||||||
try:
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'scan_update',
|
|
||||||
'frequency': current_freq,
|
|
||||||
'level': int(rms),
|
|
||||||
'threshold': int(threshold) if 'threshold' in dir() else 0,
|
|
||||||
'detected': audio_detected
|
|
||||||
})
|
|
||||||
except queue.Full:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if audio_detected and scanner_running:
|
|
||||||
if not signal_detected:
|
|
||||||
# New signal found!
|
|
||||||
signal_detected = True
|
|
||||||
last_signal_time = time.time()
|
|
||||||
add_activity_log('signal_found', current_freq,
|
|
||||||
f'Signal detected on {current_freq:.3f} MHz ({mod.upper()})')
|
|
||||||
logger.info(f"Signal found at {current_freq} MHz")
|
|
||||||
|
|
||||||
# Start audio streaming for user
|
|
||||||
_start_audio_stream(current_freq, mod)
|
|
||||||
|
|
||||||
try:
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'signal_found',
|
|
||||||
'frequency': current_freq,
|
|
||||||
'modulation': mod,
|
|
||||||
'audio_streaming': True
|
|
||||||
})
|
|
||||||
except queue.Full:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check for skip signal
|
|
||||||
if scanner_skip_signal:
|
|
||||||
scanner_skip_signal = False
|
|
||||||
signal_detected = False
|
|
||||||
_stop_audio_stream()
|
|
||||||
try:
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'signal_skipped',
|
|
||||||
'frequency': current_freq
|
|
||||||
})
|
|
||||||
except queue.Full:
|
|
||||||
pass
|
|
||||||
# Move to next frequency (step is in kHz, convert to MHz)
|
|
||||||
current_freq += step_mhz
|
|
||||||
if current_freq > scanner_config['end_freq']:
|
|
||||||
current_freq = scanner_config['start_freq']
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Stay on this frequency (dwell) but check periodically
|
|
||||||
dwell_start = time.time()
|
|
||||||
while (time.time() - dwell_start) < scanner_config['dwell_time'] and scanner_running:
|
|
||||||
if scanner_skip_signal:
|
|
||||||
break
|
|
||||||
time.sleep(0.2)
|
|
||||||
|
|
||||||
last_signal_time = time.time()
|
|
||||||
|
|
||||||
else:
|
|
||||||
# No signal at this frequency
|
|
||||||
if signal_detected:
|
|
||||||
# Signal lost
|
|
||||||
duration = time.time() - last_signal_time + scanner_config['dwell_time']
|
|
||||||
add_activity_log('signal_lost', current_freq,
|
|
||||||
f'Signal lost after {duration:.1f}s')
|
|
||||||
signal_detected = False
|
|
||||||
|
|
||||||
# Stop audio
|
|
||||||
_stop_audio_stream()
|
|
||||||
|
|
||||||
try:
|
|
||||||
scanner_queue.put_nowait({
|
|
||||||
'type': 'signal_lost',
|
|
||||||
'frequency': current_freq
|
|
||||||
})
|
|
||||||
except queue.Full:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Move to next frequency (step is in kHz, convert to MHz)
|
|
||||||
current_freq += step_mhz
|
|
||||||
if current_freq > scanner_config['end_freq']:
|
|
||||||
current_freq = scanner_config['start_freq']
|
|
||||||
add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
|
|
||||||
|
|
||||||
time.sleep(scanner_config['scan_delay'])
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Scanner error at {current_freq} MHz: {e}")
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Scanner loop error: {e}")
|
|
||||||
finally:
|
|
||||||
scanner_running = False
|
|
||||||
_stop_audio_stream()
|
|
||||||
add_activity_log('scanner_stop', scanner_current_freq, 'Scanner stopped')
|
|
||||||
logger.info("Scanner thread stopped")
|
|
||||||
|
|
||||||
|
|
||||||
def _start_audio_stream(frequency: float, modulation: str):
|
|
||||||
"""Start audio streaming at given frequency."""
|
|
||||||
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
|
|
||||||
|
|
||||||
with audio_lock:
|
|
||||||
# Stop any existing stream
|
|
||||||
_stop_audio_stream_internal()
|
|
||||||
|
|
||||||
ffmpeg_path = find_ffmpeg()
|
|
||||||
if not ffmpeg_path:
|
|
||||||
logger.error("ffmpeg not found")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Determine SDR type and build appropriate command
|
|
||||||
sdr_type_str = scanner_config.get('sdr_type', 'rtlsdr')
|
|
||||||
try:
|
|
||||||
sdr_type = SDRType(sdr_type_str)
|
|
||||||
except ValueError:
|
|
||||||
sdr_type = SDRType.RTL_SDR
|
|
||||||
|
|
||||||
# Set sample rates based on modulation
|
|
||||||
if modulation == 'wfm':
|
|
||||||
sample_rate = 170000
|
|
||||||
resample_rate = 32000
|
|
||||||
elif modulation in ['usb', 'lsb']:
|
|
||||||
sample_rate = 12000
|
|
||||||
resample_rate = 12000
|
|
||||||
else:
|
|
||||||
sample_rate = 24000
|
|
||||||
resample_rate = 24000
|
|
||||||
|
|
||||||
# Build the SDR command based on device type
|
|
||||||
if sdr_type == SDRType.RTL_SDR:
|
|
||||||
# Use rtl_fm for RTL-SDR devices
|
|
||||||
rtl_fm_path = find_rtl_fm()
|
|
||||||
if not rtl_fm_path:
|
|
||||||
logger.error("rtl_fm not found")
|
|
||||||
return
|
|
||||||
|
|
||||||
freq_hz = int(frequency * 1e6)
|
|
||||||
sdr_cmd = [
|
|
||||||
rtl_fm_path,
|
|
||||||
'-M', modulation,
|
|
||||||
'-f', str(freq_hz),
|
|
||||||
'-s', str(sample_rate),
|
|
||||||
'-r', str(resample_rate),
|
|
||||||
'-g', str(scanner_config['gain']),
|
|
||||||
'-d', str(scanner_config['device']),
|
|
||||||
'-l', str(scanner_config['squelch']),
|
|
||||||
]
|
|
||||||
if scanner_config.get('bias_t', False):
|
|
||||||
sdr_cmd.append('-T')
|
|
||||||
else:
|
|
||||||
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
|
|
||||||
rx_fm_path = find_rx_fm()
|
|
||||||
if not rx_fm_path:
|
|
||||||
logger.error(f"rx_fm not found - required for {sdr_type.value}. Install SoapySDR utilities.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Create device and get command builder
|
|
||||||
device = SDRFactory.create_default_device(sdr_type, index=scanner_config['device'])
|
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
|
||||||
|
|
||||||
# Build FM demod command
|
|
||||||
sdr_cmd = builder.build_fm_demod_command(
|
|
||||||
device=device,
|
|
||||||
frequency_mhz=frequency,
|
|
||||||
sample_rate=resample_rate,
|
|
||||||
gain=float(scanner_config['gain']),
|
|
||||||
modulation=modulation,
|
|
||||||
squelch=scanner_config['squelch'],
|
|
||||||
bias_t=scanner_config.get('bias_t', False)
|
|
||||||
)
|
|
||||||
# Ensure we use the found rx_fm path
|
|
||||||
sdr_cmd[0] = rx_fm_path
|
|
||||||
|
|
||||||
encoder_cmd = [
|
|
||||||
ffmpeg_path,
|
|
||||||
'-hide_banner',
|
|
||||||
'-loglevel', 'error',
|
|
||||||
'-f', 's16le',
|
|
||||||
'-ar', str(resample_rate),
|
|
||||||
'-ac', '1',
|
|
||||||
'-i', 'pipe:0',
|
|
||||||
'-acodec', 'libmp3lame',
|
|
||||||
'-b:a', '128k',
|
|
||||||
'-ar', '44100',
|
|
||||||
'-f', 'mp3',
|
|
||||||
'pipe:1'
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Use shell pipe for reliable streaming (Python subprocess piping can be unreliable)
|
|
||||||
shell_cmd = f"{' '.join(sdr_cmd)} 2>/dev/null | {' '.join(encoder_cmd)}"
|
|
||||||
logger.info(f"Starting audio pipeline: {shell_cmd}")
|
|
||||||
|
|
||||||
audio_rtl_process = None # Not used in shell mode
|
|
||||||
audio_process = subprocess.Popen(
|
|
||||||
shell_cmd,
|
|
||||||
shell=True,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
bufsize=0,
|
|
||||||
start_new_session=True # Create new process group for clean shutdown
|
|
||||||
)
|
|
||||||
|
|
||||||
# Brief delay to check if process started successfully
|
|
||||||
time.sleep(0.3)
|
|
||||||
|
|
||||||
if audio_process.poll() is not None:
|
|
||||||
stderr = audio_process.stderr.read().decode() if audio_process.stderr else ''
|
|
||||||
logger.error(f"Audio pipeline exited immediately: {stderr}")
|
|
||||||
return
|
|
||||||
|
|
||||||
audio_running = True
|
|
||||||
audio_frequency = frequency
|
|
||||||
audio_modulation = modulation
|
|
||||||
logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {sdr_type.value}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to start audio stream: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def _stop_audio_stream():
|
|
||||||
"""Stop audio streaming."""
|
|
||||||
with audio_lock:
|
|
||||||
_stop_audio_stream_internal()
|
|
||||||
|
|
||||||
|
|
||||||
def _stop_audio_stream_internal():
|
|
||||||
"""Internal stop (must hold lock)."""
|
|
||||||
global audio_process, audio_rtl_process, audio_running, audio_frequency
|
|
||||||
|
|
||||||
# Set flag first to stop any streaming
|
|
||||||
audio_running = False
|
|
||||||
audio_frequency = 0.0
|
|
||||||
|
|
||||||
# Kill the shell process and its children
|
|
||||||
if audio_process:
|
|
||||||
try:
|
|
||||||
# Kill entire process group (rtl_fm, ffmpeg, shell)
|
|
||||||
try:
|
|
||||||
os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL)
|
|
||||||
except (ProcessLookupError, PermissionError):
|
|
||||||
audio_process.kill()
|
|
||||||
audio_process.wait(timeout=0.5)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
audio_process = None
|
|
||||||
audio_rtl_process = None
|
|
||||||
|
|
||||||
# Kill any orphaned rtl_fm and ffmpeg processes
|
|
||||||
try:
|
|
||||||
subprocess.run(['pkill', '-9', 'rtl_fm'], capture_output=True, timeout=0.5)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
subprocess.run(['pkill', '-9', '-f', 'ffmpeg.*pipe:0'], capture_output=True, timeout=0.5)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Pause for SDR device to be released (important for frequency/modulation changes)
|
|
||||||
time.sleep(0.7)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# API ENDPOINTS
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
@listening_post_bp.route('/tools')
|
|
||||||
def check_tools() -> Response:
|
|
||||||
"""Check for required tools."""
|
|
||||||
rtl_fm = find_rtl_fm()
|
|
||||||
rx_fm = find_rx_fm()
|
|
||||||
ffmpeg = find_ffmpeg()
|
|
||||||
|
|
||||||
# Determine which SDR types are supported
|
|
||||||
supported_sdr_types = []
|
|
||||||
if rtl_fm:
|
|
||||||
supported_sdr_types.append('rtlsdr')
|
|
||||||
if rx_fm:
|
|
||||||
# rx_fm from SoapySDR supports these types
|
|
||||||
supported_sdr_types.extend(['hackrf', 'airspy', 'limesdr', 'sdrplay'])
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'rtl_fm': rtl_fm is not None,
|
|
||||||
'rx_fm': rx_fm is not None,
|
|
||||||
'ffmpeg': ffmpeg is not None,
|
|
||||||
'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
|
|
||||||
'supported_sdr_types': supported_sdr_types
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/start', methods=['POST'])
|
|
||||||
def start_scanner() -> Response:
|
|
||||||
"""Start the frequency scanner."""
|
|
||||||
global scanner_thread, scanner_running, scanner_config
|
|
||||||
|
|
||||||
with scanner_lock:
|
|
||||||
if scanner_running:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Scanner already running'
|
|
||||||
}), 409
|
|
||||||
|
|
||||||
data = request.json or {}
|
|
||||||
|
|
||||||
# Update scanner config
|
|
||||||
try:
|
|
||||||
scanner_config['start_freq'] = float(data.get('start_freq', 88.0))
|
|
||||||
scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
|
|
||||||
scanner_config['step'] = float(data.get('step', 0.1))
|
|
||||||
scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower()
|
|
||||||
scanner_config['squelch'] = int(data.get('squelch', 20))
|
|
||||||
scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
|
|
||||||
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
|
|
||||||
scanner_config['device'] = int(data.get('device', 0))
|
|
||||||
scanner_config['gain'] = int(data.get('gain', 40))
|
|
||||||
scanner_config['bias_t'] = bool(data.get('bias_t', False))
|
|
||||||
scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower()
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'Invalid parameter: {e}'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Validate
|
|
||||||
if scanner_config['start_freq'] >= scanner_config['end_freq']:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'start_freq must be less than end_freq'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Check tools based on SDR type
|
|
||||||
sdr_type = scanner_config['sdr_type']
|
|
||||||
if sdr_type == 'rtlsdr':
|
|
||||||
if not find_rtl_fm():
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'rtl_fm not found. Install rtl-sdr tools.'
|
|
||||||
}), 503
|
|
||||||
else:
|
|
||||||
if not find_rx_fm():
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
|
||||||
}), 503
|
|
||||||
|
|
||||||
# Start scanner thread
|
|
||||||
scanner_running = True
|
|
||||||
scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
|
|
||||||
scanner_thread.start()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'started',
|
|
||||||
'config': scanner_config
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/stop', methods=['POST'])
|
|
||||||
def stop_scanner() -> Response:
|
|
||||||
"""Stop the frequency scanner."""
|
|
||||||
global scanner_running
|
|
||||||
|
|
||||||
scanner_running = False
|
|
||||||
_stop_audio_stream()
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/pause', methods=['POST'])
|
|
||||||
def pause_scanner() -> Response:
|
|
||||||
"""Pause/resume the scanner."""
|
|
||||||
global scanner_paused
|
|
||||||
|
|
||||||
scanner_paused = not scanner_paused
|
|
||||||
|
|
||||||
if scanner_paused:
|
|
||||||
add_activity_log('scanner_pause', scanner_current_freq, 'Scanner paused')
|
|
||||||
else:
|
|
||||||
add_activity_log('scanner_resume', scanner_current_freq, 'Scanner resumed')
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'paused' if scanner_paused else 'resumed',
|
|
||||||
'paused': scanner_paused
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# Flag to trigger skip from API
|
|
||||||
scanner_skip_signal = False
|
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/skip', methods=['POST'])
|
|
||||||
def skip_signal() -> Response:
|
|
||||||
"""Skip current signal and continue scanning."""
|
|
||||||
global scanner_skip_signal
|
|
||||||
|
|
||||||
if not scanner_running:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Scanner not running'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
scanner_skip_signal = True
|
|
||||||
add_activity_log('signal_skip', scanner_current_freq, f'Skipped signal at {scanner_current_freq:.3f} MHz')
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'skipped',
|
|
||||||
'frequency': scanner_current_freq
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/config', methods=['POST'])
|
|
||||||
def update_scanner_config() -> Response:
|
|
||||||
"""Update scanner config while running (step, squelch, gain, dwell)."""
|
|
||||||
data = request.json or {}
|
|
||||||
|
|
||||||
updated = []
|
|
||||||
|
|
||||||
if 'step' in data:
|
|
||||||
scanner_config['step'] = float(data['step'])
|
|
||||||
updated.append(f"step={data['step']}kHz")
|
|
||||||
|
|
||||||
if 'squelch' in data:
|
|
||||||
scanner_config['squelch'] = int(data['squelch'])
|
|
||||||
updated.append(f"squelch={data['squelch']}")
|
|
||||||
|
|
||||||
if 'gain' in data:
|
|
||||||
scanner_config['gain'] = int(data['gain'])
|
|
||||||
updated.append(f"gain={data['gain']}")
|
|
||||||
|
|
||||||
if 'dwell_time' in data:
|
|
||||||
scanner_config['dwell_time'] = int(data['dwell_time'])
|
|
||||||
updated.append(f"dwell={data['dwell_time']}s")
|
|
||||||
|
|
||||||
if 'modulation' in data:
|
|
||||||
scanner_config['modulation'] = str(data['modulation']).lower()
|
|
||||||
updated.append(f"mod={data['modulation']}")
|
|
||||||
|
|
||||||
if updated:
|
|
||||||
logger.info(f"Scanner config updated: {', '.join(updated)}")
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'updated',
|
|
||||||
'config': scanner_config
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/status')
|
|
||||||
def scanner_status() -> Response:
|
|
||||||
"""Get scanner status."""
|
|
||||||
return jsonify({
|
|
||||||
'running': scanner_running,
|
|
||||||
'paused': scanner_paused,
|
|
||||||
'current_freq': scanner_current_freq,
|
|
||||||
'config': scanner_config,
|
|
||||||
'audio_streaming': audio_running,
|
|
||||||
'audio_frequency': audio_frequency
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/stream')
|
|
||||||
def stream_scanner_events() -> Response:
|
|
||||||
"""SSE stream for scanner events."""
|
|
||||||
def generate() -> Generator[str, None, None]:
|
|
||||||
last_keepalive = time.time()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
|
||||||
last_keepalive = time.time()
|
|
||||||
yield format_sse(msg)
|
|
||||||
except queue.Empty:
|
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/log')
|
|
||||||
def get_activity_log() -> Response:
|
|
||||||
"""Get activity log."""
|
|
||||||
limit = request.args.get('limit', 100, type=int)
|
|
||||||
with activity_log_lock:
|
|
||||||
return jsonify({
|
|
||||||
'log': activity_log[:limit],
|
|
||||||
'total': len(activity_log)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/log/clear', methods=['POST'])
|
|
||||||
def clear_activity_log() -> Response:
|
|
||||||
"""Clear activity log."""
|
|
||||||
with activity_log_lock:
|
|
||||||
activity_log.clear()
|
|
||||||
return jsonify({'status': 'cleared'})
|
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/presets')
|
|
||||||
def get_presets() -> Response:
|
|
||||||
"""Get scanner presets."""
|
|
||||||
presets = [
|
|
||||||
{'name': 'FM Broadcast', 'start': 88.0, 'end': 108.0, 'step': 0.2, 'mod': 'wfm'},
|
|
||||||
{'name': 'Air Band', 'start': 118.0, 'end': 137.0, 'step': 0.025, 'mod': 'am'},
|
|
||||||
{'name': 'Marine VHF', 'start': 156.0, 'end': 163.0, 'step': 0.025, 'mod': 'fm'},
|
|
||||||
{'name': 'Amateur 2m', 'start': 144.0, 'end': 148.0, 'step': 0.0125, 'mod': 'fm'},
|
|
||||||
{'name': 'Amateur 70cm', 'start': 430.0, 'end': 440.0, 'step': 0.025, 'mod': 'fm'},
|
|
||||||
{'name': 'PMR446', 'start': 446.0, 'end': 446.2, 'step': 0.0125, 'mod': 'fm'},
|
|
||||||
{'name': 'FRS/GMRS', 'start': 462.5, 'end': 467.7, 'step': 0.025, 'mod': 'fm'},
|
|
||||||
{'name': 'Weather Radio', 'start': 162.4, 'end': 162.55, 'step': 0.025, 'mod': 'fm'},
|
|
||||||
]
|
|
||||||
return jsonify({'presets': presets})
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# MANUAL AUDIO ENDPOINTS (for direct listening)
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/start', methods=['POST'])
|
|
||||||
def start_audio() -> Response:
|
|
||||||
"""Start audio at specific frequency (manual mode)."""
|
|
||||||
global scanner_running
|
|
||||||
|
|
||||||
logger.info("Audio start request received")
|
|
||||||
|
|
||||||
# Stop scanner if running
|
|
||||||
if scanner_running:
|
|
||||||
scanner_running = False
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
data = request.json or {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
frequency = float(data.get('frequency', 0))
|
|
||||||
modulation = str(data.get('modulation', 'wfm')).lower()
|
|
||||||
squelch = int(data.get('squelch', 0))
|
|
||||||
gain = int(data.get('gain', 40))
|
|
||||||
device = int(data.get('device', 0))
|
|
||||||
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'Invalid parameter: {e}'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
if frequency <= 0:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'frequency is required'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
valid_mods = ['fm', 'wfm', 'am', 'usb', 'lsb']
|
|
||||||
if modulation not in valid_mods:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'Invalid modulation. Use: {", ".join(valid_mods)}'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
|
|
||||||
if sdr_type not in valid_sdr_types:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Update config for audio
|
|
||||||
scanner_config['squelch'] = squelch
|
|
||||||
scanner_config['gain'] = gain
|
|
||||||
scanner_config['device'] = device
|
|
||||||
scanner_config['sdr_type'] = sdr_type
|
|
||||||
|
|
||||||
_start_audio_stream(frequency, modulation)
|
|
||||||
|
|
||||||
if audio_running:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'started',
|
|
||||||
'frequency': frequency,
|
|
||||||
'modulation': modulation
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Failed to start audio. Check SDR device.'
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/stop', methods=['POST'])
|
|
||||||
def stop_audio() -> Response:
|
|
||||||
"""Stop audio."""
|
|
||||||
_stop_audio_stream()
|
|
||||||
return jsonify({'status': 'stopped'})
|
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/status')
|
|
||||||
def audio_status() -> Response:
|
|
||||||
"""Get audio status."""
|
|
||||||
return jsonify({
|
|
||||||
'running': audio_running,
|
|
||||||
'frequency': audio_frequency,
|
|
||||||
'modulation': audio_modulation
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/stream')
|
|
||||||
def stream_audio() -> Response:
|
|
||||||
"""Stream MP3 audio."""
|
|
||||||
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
|
|
||||||
for _ in range(40):
|
|
||||||
if audio_running and audio_process:
|
|
||||||
break
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
if not audio_running or not audio_process:
|
|
||||||
return Response(b'', mimetype='audio/mpeg', status=204)
|
|
||||||
|
|
||||||
def generate():
|
|
||||||
try:
|
|
||||||
while audio_running and audio_process and audio_process.poll() is None:
|
|
||||||
# Use select to avoid blocking forever
|
|
||||||
ready, _, _ = select.select([audio_process.stdout], [], [], 2.0)
|
|
||||||
if ready:
|
|
||||||
chunk = audio_process.stdout.read(4096)
|
|
||||||
if chunk:
|
|
||||||
yield chunk
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
except GeneratorExit:
|
|
||||||
pass
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
generate(),
|
|
||||||
mimetype='audio/mpeg',
|
|
||||||
headers={
|
|
||||||
'Content-Type': 'audio/mpeg',
|
|
||||||
'Cache-Control': 'no-cache, no-store',
|
|
||||||
'X-Accel-Buffering': 'no',
|
|
||||||
'Transfer-Encoding': 'chunked',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
"""Receiver routes for radio monitoring and frequency scanning.
|
||||||
|
|
||||||
|
This package splits the listening post into sub-modules:
|
||||||
|
scanner - /scanner/*, /presets routes
|
||||||
|
audio - /audio/* routes
|
||||||
|
waterfall - /waterfall/* routes
|
||||||
|
tools - /tools, /signal/guess routes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
from utils.constants import (
|
||||||
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
)
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
|
||||||
|
logger = get_logger('intercept.receiver')
|
||||||
|
|
||||||
|
receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver')
|
||||||
|
|
||||||
|
# Deferred import to avoid circular import at module load time.
|
||||||
|
# app.py -> register_blueprints -> from .listening_post import receiver_bp
|
||||||
|
# must find receiver_bp already defined (above) before this import runs.
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
import app as app_module # noqa: E402
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# GLOBAL STATE
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Audio demodulation state
|
||||||
|
audio_process = None
|
||||||
|
audio_rtl_process = None
|
||||||
|
audio_lock = threading.Lock()
|
||||||
|
audio_start_lock = threading.Lock()
|
||||||
|
audio_running = False
|
||||||
|
audio_frequency = 0.0
|
||||||
|
audio_modulation = 'fm'
|
||||||
|
audio_source = 'process'
|
||||||
|
audio_start_token = 0
|
||||||
|
|
||||||
|
# Scanner state
|
||||||
|
scanner_thread: threading.Thread | None = None
|
||||||
|
scanner_running = False
|
||||||
|
scanner_lock = threading.Lock()
|
||||||
|
scanner_paused = False
|
||||||
|
scanner_current_freq = 0.0
|
||||||
|
scanner_active_device: int | None = None
|
||||||
|
scanner_active_sdr_type: str = 'rtlsdr'
|
||||||
|
receiver_active_device: int | None = None
|
||||||
|
receiver_active_sdr_type: str = 'rtlsdr'
|
||||||
|
scanner_power_process: subprocess.Popen | None = None
|
||||||
|
scanner_config = {
|
||||||
|
'start_freq': 88.0,
|
||||||
|
'end_freq': 108.0,
|
||||||
|
'step': 0.1,
|
||||||
|
'modulation': 'wfm',
|
||||||
|
'squelch': 0,
|
||||||
|
'dwell_time': 10.0, # Seconds to stay on active frequency
|
||||||
|
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
|
||||||
|
'device': 0,
|
||||||
|
'gain': 40,
|
||||||
|
'bias_t': False, # Bias-T power for external LNA
|
||||||
|
'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay
|
||||||
|
'scan_method': 'power', # power (rtl_power) or classic (rtl_fm hop)
|
||||||
|
'snr_threshold': 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Activity log
|
||||||
|
activity_log: list[dict] = []
|
||||||
|
activity_log_lock = threading.Lock()
|
||||||
|
MAX_LOG_ENTRIES = 500
|
||||||
|
|
||||||
|
# SSE queue for scanner events
|
||||||
|
scanner_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
|
# Flag to trigger skip from API
|
||||||
|
scanner_skip_signal = False
|
||||||
|
|
||||||
|
# Waterfall / spectrogram state
|
||||||
|
waterfall_process: subprocess.Popen | None = None
|
||||||
|
waterfall_thread: threading.Thread | None = None
|
||||||
|
waterfall_running = False
|
||||||
|
waterfall_lock = threading.Lock()
|
||||||
|
waterfall_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||||
|
waterfall_active_device: int | None = None
|
||||||
|
waterfall_active_sdr_type: str = 'rtlsdr'
|
||||||
|
waterfall_config = {
|
||||||
|
'start_freq': 88.0,
|
||||||
|
'end_freq': 108.0,
|
||||||
|
'bin_size': 10000,
|
||||||
|
'gain': 40,
|
||||||
|
'device': 0,
|
||||||
|
'max_bins': 1024,
|
||||||
|
'interval': 0.4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# HELPER FUNCTIONS (shared across sub-modules)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb']
|
||||||
|
|
||||||
|
|
||||||
|
def find_rtl_fm() -> str | None:
|
||||||
|
"""Find rtl_fm binary."""
|
||||||
|
return shutil.which('rtl_fm')
|
||||||
|
|
||||||
|
|
||||||
|
def find_rtl_power() -> str | None:
|
||||||
|
"""Find rtl_power binary."""
|
||||||
|
return shutil.which('rtl_power')
|
||||||
|
|
||||||
|
|
||||||
|
def find_rx_fm() -> str | None:
|
||||||
|
"""Find rx_fm binary (SoapySDR FM demodulator for HackRF/Airspy/LimeSDR)."""
|
||||||
|
return shutil.which('rx_fm')
|
||||||
|
|
||||||
|
|
||||||
|
def find_ffmpeg() -> str | None:
|
||||||
|
"""Find ffmpeg for audio encoding."""
|
||||||
|
return shutil.which('ffmpeg')
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_modulation(value: str) -> str:
|
||||||
|
"""Normalize and validate modulation string."""
|
||||||
|
mod = str(value or '').lower().strip()
|
||||||
|
if mod not in VALID_MODULATIONS:
|
||||||
|
raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}')
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def _rtl_fm_demod_mode(modulation: str) -> str:
|
||||||
|
"""Map UI modulation names to rtl_fm demod tokens."""
|
||||||
|
mod = str(modulation or '').lower().strip()
|
||||||
|
return 'wbfm' if mod == 'wfm' else mod
|
||||||
|
|
||||||
|
|
||||||
|
def _wav_header(sample_rate: int = 48000, bits_per_sample: int = 16, channels: int = 1) -> bytes:
|
||||||
|
"""Create a streaming WAV header with unknown data length."""
|
||||||
|
bytes_per_sample = bits_per_sample // 8
|
||||||
|
byte_rate = sample_rate * channels * bytes_per_sample
|
||||||
|
block_align = channels * bytes_per_sample
|
||||||
|
return (
|
||||||
|
b'RIFF'
|
||||||
|
+ struct.pack('<I', 0xFFFFFFFF)
|
||||||
|
+ b'WAVE'
|
||||||
|
+ b'fmt '
|
||||||
|
+ struct.pack('<IHHIIHH', 16, 1, channels, sample_rate, byte_rate, block_align, bits_per_sample)
|
||||||
|
+ b'data'
|
||||||
|
+ struct.pack('<I', 0xFFFFFFFF)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_activity_log(event_type: str, frequency: float, details: str = ''):
|
||||||
|
"""Add entry to activity log."""
|
||||||
|
with activity_log_lock:
|
||||||
|
entry = {
|
||||||
|
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
||||||
|
'type': event_type,
|
||||||
|
'frequency': frequency,
|
||||||
|
'details': details,
|
||||||
|
}
|
||||||
|
activity_log.insert(0, entry)
|
||||||
|
# Trim log
|
||||||
|
while len(activity_log) > MAX_LOG_ENTRIES:
|
||||||
|
activity_log.pop()
|
||||||
|
|
||||||
|
# Also push to SSE queue
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'log',
|
||||||
|
'entry': entry
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _start_audio_stream(
|
||||||
|
frequency: float,
|
||||||
|
modulation: str,
|
||||||
|
*,
|
||||||
|
device: int | None = None,
|
||||||
|
sdr_type: str | None = None,
|
||||||
|
gain: int | None = None,
|
||||||
|
squelch: int | None = None,
|
||||||
|
bias_t: bool | None = None,
|
||||||
|
):
|
||||||
|
"""Start audio streaming at given frequency."""
|
||||||
|
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
|
||||||
|
|
||||||
|
# Stop existing stream and snapshot config under lock
|
||||||
|
with audio_lock:
|
||||||
|
_stop_audio_stream_internal()
|
||||||
|
|
||||||
|
ffmpeg_path = find_ffmpeg()
|
||||||
|
if not ffmpeg_path:
|
||||||
|
logger.error("ffmpeg not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Snapshot runtime tuning config so the spawned demod command cannot
|
||||||
|
# drift if shared scanner_config changes while startup is in-flight.
|
||||||
|
device_index = int(device if device is not None else scanner_config.get('device', 0))
|
||||||
|
gain_value = int(gain if gain is not None else scanner_config.get('gain', 40))
|
||||||
|
squelch_value = int(squelch if squelch is not None else scanner_config.get('squelch', 0))
|
||||||
|
bias_t_enabled = bool(scanner_config.get('bias_t', False) if bias_t is None else bias_t)
|
||||||
|
sdr_type_str = str(sdr_type if sdr_type is not None else scanner_config.get('sdr_type', 'rtlsdr')).lower()
|
||||||
|
|
||||||
|
# Build commands outside lock (no blocking I/O, just command construction)
|
||||||
|
try:
|
||||||
|
resolved_sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
resolved_sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
|
# Set sample rates based on modulation
|
||||||
|
if modulation == 'wfm':
|
||||||
|
sample_rate = 170000
|
||||||
|
resample_rate = 32000
|
||||||
|
elif modulation in ['usb', 'lsb']:
|
||||||
|
sample_rate = 12000
|
||||||
|
resample_rate = 12000
|
||||||
|
else:
|
||||||
|
sample_rate = 24000
|
||||||
|
resample_rate = 24000
|
||||||
|
|
||||||
|
# Build the SDR command based on device type
|
||||||
|
if resolved_sdr_type == SDRType.RTL_SDR:
|
||||||
|
rtl_fm_path = find_rtl_fm()
|
||||||
|
if not rtl_fm_path:
|
||||||
|
logger.error("rtl_fm not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
freq_hz = int(frequency * 1e6)
|
||||||
|
sdr_cmd = [
|
||||||
|
rtl_fm_path,
|
||||||
|
'-M', _rtl_fm_demod_mode(modulation),
|
||||||
|
'-f', str(freq_hz),
|
||||||
|
'-s', str(sample_rate),
|
||||||
|
'-r', str(resample_rate),
|
||||||
|
'-g', str(gain_value),
|
||||||
|
'-d', str(device_index),
|
||||||
|
'-l', str(squelch_value),
|
||||||
|
]
|
||||||
|
if bias_t_enabled:
|
||||||
|
sdr_cmd.append('-T')
|
||||||
|
else:
|
||||||
|
rx_fm_path = find_rx_fm()
|
||||||
|
if not rx_fm_path:
|
||||||
|
logger.error(f"rx_fm not found - required for {resolved_sdr_type.value}. Install SoapySDR utilities.")
|
||||||
|
return
|
||||||
|
|
||||||
|
sdr_device = SDRFactory.create_default_device(resolved_sdr_type, index=device_index)
|
||||||
|
builder = SDRFactory.get_builder(resolved_sdr_type)
|
||||||
|
sdr_cmd = builder.build_fm_demod_command(
|
||||||
|
device=sdr_device,
|
||||||
|
frequency_mhz=frequency,
|
||||||
|
sample_rate=resample_rate,
|
||||||
|
gain=float(gain_value),
|
||||||
|
modulation=modulation,
|
||||||
|
squelch=squelch_value,
|
||||||
|
bias_t=bias_t_enabled,
|
||||||
|
)
|
||||||
|
sdr_cmd[0] = rx_fm_path
|
||||||
|
|
||||||
|
encoder_cmd = [
|
||||||
|
ffmpeg_path,
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel', 'error',
|
||||||
|
'-fflags', 'nobuffer',
|
||||||
|
'-flags', 'low_delay',
|
||||||
|
'-probesize', '32',
|
||||||
|
'-analyzeduration', '0',
|
||||||
|
'-f', 's16le',
|
||||||
|
'-ar', str(resample_rate),
|
||||||
|
'-ac', '1',
|
||||||
|
'-i', 'pipe:0',
|
||||||
|
'-acodec', 'pcm_s16le',
|
||||||
|
'-ar', '44100',
|
||||||
|
'-f', 'wav',
|
||||||
|
'pipe:1'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Retry loop outside lock — spawning + health check sleeps don't block
|
||||||
|
# other operations. audio_start_lock already serializes callers.
|
||||||
|
try:
|
||||||
|
rtl_stderr_log = '/tmp/rtl_fm_stderr.log'
|
||||||
|
ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log'
|
||||||
|
logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={device_index}")
|
||||||
|
|
||||||
|
new_rtl_proc = None
|
||||||
|
new_audio_proc = None
|
||||||
|
max_attempts = 3
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
new_rtl_proc = None
|
||||||
|
new_audio_proc = None
|
||||||
|
rtl_err_handle = None
|
||||||
|
ffmpeg_err_handle = None
|
||||||
|
try:
|
||||||
|
rtl_err_handle = open(rtl_stderr_log, 'w')
|
||||||
|
ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w')
|
||||||
|
new_rtl_proc = subprocess.Popen(
|
||||||
|
sdr_cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=rtl_err_handle,
|
||||||
|
bufsize=0,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
new_audio_proc = subprocess.Popen(
|
||||||
|
encoder_cmd,
|
||||||
|
stdin=new_rtl_proc.stdout,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=ffmpeg_err_handle,
|
||||||
|
bufsize=0,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
if new_rtl_proc.stdout:
|
||||||
|
new_rtl_proc.stdout.close()
|
||||||
|
finally:
|
||||||
|
if rtl_err_handle:
|
||||||
|
rtl_err_handle.close()
|
||||||
|
if ffmpeg_err_handle:
|
||||||
|
ffmpeg_err_handle.close()
|
||||||
|
|
||||||
|
# Brief delay to check if process started successfully
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
if (new_rtl_proc and new_rtl_proc.poll() is not None) or (
|
||||||
|
new_audio_proc and new_audio_proc.poll() is not None
|
||||||
|
):
|
||||||
|
rtl_stderr = ''
|
||||||
|
ffmpeg_stderr = ''
|
||||||
|
try:
|
||||||
|
with open(rtl_stderr_log) as f:
|
||||||
|
rtl_stderr = f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
with open(ffmpeg_stderr_log) as f:
|
||||||
|
ffmpeg_stderr = f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1:
|
||||||
|
logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...")
|
||||||
|
if new_audio_proc:
|
||||||
|
try:
|
||||||
|
new_audio_proc.terminate()
|
||||||
|
new_audio_proc.wait(timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if new_rtl_proc:
|
||||||
|
try:
|
||||||
|
new_rtl_proc.terminate()
|
||||||
|
new_rtl_proc.wait(timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(1.0)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if new_audio_proc and new_audio_proc.poll() is None:
|
||||||
|
try:
|
||||||
|
new_audio_proc.terminate()
|
||||||
|
new_audio_proc.wait(timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if new_rtl_proc and new_rtl_proc.poll() is None:
|
||||||
|
try:
|
||||||
|
new_rtl_proc.terminate()
|
||||||
|
new_rtl_proc.wait(timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
new_audio_proc = None
|
||||||
|
new_rtl_proc = None
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pipeline started successfully
|
||||||
|
break
|
||||||
|
|
||||||
|
# Verify pipeline is still alive, then install under lock
|
||||||
|
if (
|
||||||
|
not new_audio_proc
|
||||||
|
or not new_rtl_proc
|
||||||
|
or new_audio_proc.poll() is not None
|
||||||
|
or new_rtl_proc.poll() is not None
|
||||||
|
):
|
||||||
|
logger.warning("Audio pipeline did not remain alive after startup")
|
||||||
|
# Clean up failed processes
|
||||||
|
if new_audio_proc:
|
||||||
|
try:
|
||||||
|
new_audio_proc.terminate()
|
||||||
|
new_audio_proc.wait(timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if new_rtl_proc:
|
||||||
|
try:
|
||||||
|
new_rtl_proc.terminate()
|
||||||
|
new_rtl_proc.wait(timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
# Install processes under lock
|
||||||
|
with audio_lock:
|
||||||
|
audio_rtl_process = new_rtl_proc
|
||||||
|
audio_process = new_audio_proc
|
||||||
|
audio_running = True
|
||||||
|
audio_frequency = frequency
|
||||||
|
audio_modulation = modulation
|
||||||
|
logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {resolved_sdr_type.value}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start audio stream: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_audio_stream():
|
||||||
|
"""Stop audio streaming."""
|
||||||
|
with audio_lock:
|
||||||
|
_stop_audio_stream_internal()
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_audio_stream_internal():
|
||||||
|
"""Internal stop (must hold lock)."""
|
||||||
|
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_source
|
||||||
|
|
||||||
|
# Set flag first to stop any streaming
|
||||||
|
audio_running = False
|
||||||
|
audio_frequency = 0.0
|
||||||
|
previous_source = audio_source
|
||||||
|
audio_source = 'process'
|
||||||
|
|
||||||
|
if previous_source == 'waterfall':
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import stop_shared_monitor_from_capture
|
||||||
|
|
||||||
|
stop_shared_monitor_from_capture()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
had_processes = audio_process is not None or audio_rtl_process is not None
|
||||||
|
|
||||||
|
# Kill the pipeline processes and their groups
|
||||||
|
if audio_process:
|
||||||
|
try:
|
||||||
|
# Kill entire process group (SDR demod + ffmpeg)
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL)
|
||||||
|
except (ProcessLookupError, PermissionError):
|
||||||
|
audio_process.kill()
|
||||||
|
audio_process.wait(timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if audio_rtl_process:
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(audio_rtl_process.pid), signal.SIGKILL)
|
||||||
|
except (ProcessLookupError, PermissionError):
|
||||||
|
audio_rtl_process.kill()
|
||||||
|
audio_rtl_process.wait(timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
audio_process = None
|
||||||
|
audio_rtl_process = None
|
||||||
|
|
||||||
|
# Brief pause for SDR device USB interface to be released by kernel.
|
||||||
|
# The _start_audio_stream retry loop handles longer contention windows
|
||||||
|
# so only a minimal delay is needed here.
|
||||||
|
if had_processes:
|
||||||
|
time.sleep(0.15)
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_waterfall_internal() -> None:
|
||||||
|
"""Stop the waterfall display and release resources."""
|
||||||
|
global waterfall_running, waterfall_process, waterfall_active_device, waterfall_active_sdr_type
|
||||||
|
|
||||||
|
waterfall_running = False
|
||||||
|
if waterfall_process and waterfall_process.poll() is None:
|
||||||
|
try:
|
||||||
|
waterfall_process.terminate()
|
||||||
|
waterfall_process.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
waterfall_process.kill()
|
||||||
|
waterfall_process = None
|
||||||
|
|
||||||
|
if waterfall_active_device is not None:
|
||||||
|
app_module.release_sdr_device(waterfall_active_device, waterfall_active_sdr_type)
|
||||||
|
waterfall_active_device = None
|
||||||
|
waterfall_active_sdr_type = 'rtlsdr'
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Import sub-modules to register routes on receiver_bp
|
||||||
|
# ============================================
|
||||||
|
from . import (
|
||||||
|
audio, # noqa: E402, F401
|
||||||
|
scanner, # noqa: E402, F401
|
||||||
|
tools, # noqa: E402, F401
|
||||||
|
waterfall, # noqa: E402, F401
|
||||||
|
)
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
"""Audio routes for manual listening and audio streaming."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import Response, jsonify, request
|
||||||
|
|
||||||
|
import routes.listening_post as _state
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
_start_audio_stream,
|
||||||
|
_stop_audio_stream,
|
||||||
|
_stop_waterfall_internal,
|
||||||
|
_wav_header,
|
||||||
|
app_module,
|
||||||
|
logger,
|
||||||
|
normalize_modulation,
|
||||||
|
receiver_bp,
|
||||||
|
scanner_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# MANUAL AUDIO ENDPOINTS (for direct listening)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@receiver_bp.route('/audio/start', methods=['POST'])
|
||||||
|
def start_audio() -> Response:
|
||||||
|
"""Start audio at specific frequency (manual mode)."""
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
frequency = float(data.get('frequency', 0))
|
||||||
|
modulation = normalize_modulation(data.get('modulation', 'wfm'))
|
||||||
|
squelch = int(data['squelch']) if data.get('squelch') is not None else 0
|
||||||
|
gain = int(data['gain']) if data.get('gain') is not None else 40
|
||||||
|
device = int(data['device']) if data.get('device') is not None else 0
|
||||||
|
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||||
|
request_token_raw = data.get('request_token')
|
||||||
|
request_token = int(request_token_raw) if request_token_raw is not None else None
|
||||||
|
bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False))
|
||||||
|
if isinstance(bias_t_raw, str):
|
||||||
|
bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||||
|
else:
|
||||||
|
bias_t = bool(bias_t_raw)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid parameter: {e}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if frequency <= 0:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'frequency is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
|
||||||
|
if sdr_type not in valid_sdr_types:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
with _state.audio_start_lock:
|
||||||
|
if request_token is not None:
|
||||||
|
if request_token < _state.audio_start_token:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'stale',
|
||||||
|
'message': 'Superseded audio start request',
|
||||||
|
'source': _state.audio_source,
|
||||||
|
'superseded': True,
|
||||||
|
'current_token': _state.audio_start_token,
|
||||||
|
}), 409
|
||||||
|
_state.audio_start_token = request_token
|
||||||
|
else:
|
||||||
|
_state.audio_start_token += 1
|
||||||
|
request_token = _state.audio_start_token
|
||||||
|
|
||||||
|
# Grab scanner refs inside lock, signal stop, clear state
|
||||||
|
need_scanner_teardown = False
|
||||||
|
scanner_thread_ref = None
|
||||||
|
scanner_proc_ref = None
|
||||||
|
if _state.scanner_running:
|
||||||
|
_state.scanner_running = False
|
||||||
|
if _state.scanner_active_device is not None:
|
||||||
|
app_module.release_sdr_device(_state.scanner_active_device, _state.scanner_active_sdr_type)
|
||||||
|
_state.scanner_active_device = None
|
||||||
|
_state.scanner_active_sdr_type = 'rtlsdr'
|
||||||
|
scanner_thread_ref = _state.scanner_thread
|
||||||
|
scanner_proc_ref = _state.scanner_power_process
|
||||||
|
_state.scanner_power_process = None
|
||||||
|
need_scanner_teardown = True
|
||||||
|
|
||||||
|
# Update config for audio
|
||||||
|
scanner_config['squelch'] = squelch
|
||||||
|
scanner_config['gain'] = gain
|
||||||
|
scanner_config['device'] = device
|
||||||
|
scanner_config['sdr_type'] = sdr_type
|
||||||
|
scanner_config['bias_t'] = bias_t
|
||||||
|
|
||||||
|
# Scanner teardown outside lock (blocking: thread join, process wait, pkill, sleep)
|
||||||
|
if need_scanner_teardown:
|
||||||
|
if scanner_thread_ref and scanner_thread_ref.is_alive():
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
scanner_thread_ref.join(timeout=2.0)
|
||||||
|
if scanner_proc_ref and scanner_proc_ref.poll() is None:
|
||||||
|
try:
|
||||||
|
scanner_proc_ref.terminate()
|
||||||
|
scanner_proc_ref.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
scanner_proc_ref.kill()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Re-acquire lock for waterfall check and device claim
|
||||||
|
with _state.audio_start_lock:
|
||||||
|
|
||||||
|
# Preferred path: when waterfall WebSocket is active on the same SDR,
|
||||||
|
# derive monitor audio from that IQ stream instead of spawning rtl_fm.
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import (
|
||||||
|
get_shared_capture_status,
|
||||||
|
start_shared_monitor_from_capture,
|
||||||
|
)
|
||||||
|
|
||||||
|
shared = get_shared_capture_status()
|
||||||
|
if shared.get('running') and shared.get('device') == device:
|
||||||
|
_stop_audio_stream()
|
||||||
|
ok, msg = start_shared_monitor_from_capture(
|
||||||
|
device=device,
|
||||||
|
frequency_mhz=frequency,
|
||||||
|
modulation=modulation,
|
||||||
|
squelch=squelch,
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
_state.audio_running = True
|
||||||
|
_state.audio_frequency = frequency
|
||||||
|
_state.audio_modulation = modulation
|
||||||
|
_state.audio_source = 'waterfall'
|
||||||
|
# Shared monitor uses the waterfall's existing SDR claim.
|
||||||
|
if _state.receiver_active_device is not None:
|
||||||
|
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
|
||||||
|
_state.receiver_active_device = None
|
||||||
|
_state.receiver_active_sdr_type = 'rtlsdr'
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'frequency': frequency,
|
||||||
|
'modulation': modulation,
|
||||||
|
'source': 'waterfall',
|
||||||
|
'request_token': request_token,
|
||||||
|
})
|
||||||
|
logger.warning(f"Shared waterfall monitor unavailable: {msg}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Shared waterfall monitor probe failed: {e}")
|
||||||
|
|
||||||
|
# Stop waterfall if it's using the same SDR (SSE path)
|
||||||
|
if _state.waterfall_running and _state.waterfall_active_device == device:
|
||||||
|
_stop_waterfall_internal()
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
# Claim device for listening audio. The WebSocket waterfall handler
|
||||||
|
# may still be tearing down its IQ capture process (thread join +
|
||||||
|
# safe_terminate can take several seconds), so we retry with back-off
|
||||||
|
# to give the USB device time to be fully released.
|
||||||
|
if _state.receiver_active_device is None or _state.receiver_active_device != device:
|
||||||
|
if _state.receiver_active_device is not None:
|
||||||
|
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
|
||||||
|
_state.receiver_active_device = None
|
||||||
|
_state.receiver_active_sdr_type = 'rtlsdr'
|
||||||
|
|
||||||
|
error = None
|
||||||
|
max_claim_attempts = 6
|
||||||
|
for attempt in range(max_claim_attempts):
|
||||||
|
error = app_module.claim_sdr_device(device, 'receiver', sdr_type)
|
||||||
|
if not error:
|
||||||
|
break
|
||||||
|
if attempt < max_claim_attempts - 1:
|
||||||
|
logger.debug(
|
||||||
|
f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
|
||||||
|
f"failed, retrying in 0.5s: {error}"
|
||||||
|
)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
_state.receiver_active_device = device
|
||||||
|
_state.receiver_active_sdr_type = sdr_type
|
||||||
|
|
||||||
|
_start_audio_stream(
|
||||||
|
frequency,
|
||||||
|
modulation,
|
||||||
|
device=device,
|
||||||
|
sdr_type=sdr_type,
|
||||||
|
gain=gain,
|
||||||
|
squelch=squelch,
|
||||||
|
bias_t=bias_t,
|
||||||
|
)
|
||||||
|
|
||||||
|
if _state.audio_running:
|
||||||
|
_state.audio_source = 'process'
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'frequency': _state.audio_frequency,
|
||||||
|
'modulation': _state.audio_modulation,
|
||||||
|
'source': 'process',
|
||||||
|
'request_token': request_token,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Avoid leaving a stale device claim after startup failure.
|
||||||
|
if _state.receiver_active_device is not None:
|
||||||
|
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
|
||||||
|
_state.receiver_active_device = None
|
||||||
|
_state.receiver_active_sdr_type = 'rtlsdr'
|
||||||
|
|
||||||
|
start_error = ''
|
||||||
|
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
|
||||||
|
try:
|
||||||
|
with open(log_path) as handle:
|
||||||
|
content = handle.read().strip()
|
||||||
|
if content:
|
||||||
|
start_error = content.splitlines()[-1]
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
message = 'Failed to start audio. Check SDR device.'
|
||||||
|
if start_error:
|
||||||
|
message = f'Failed to start audio: {start_error}'
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': message
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@receiver_bp.route('/audio/stop', methods=['POST'])
|
||||||
|
def stop_audio() -> Response:
|
||||||
|
"""Stop audio."""
|
||||||
|
_stop_audio_stream()
|
||||||
|
if _state.receiver_active_device is not None:
|
||||||
|
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
|
||||||
|
_state.receiver_active_device = None
|
||||||
|
_state.receiver_active_sdr_type = 'rtlsdr'
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@receiver_bp.route('/audio/status')
|
||||||
|
def audio_status() -> Response:
|
||||||
|
"""Get audio status."""
|
||||||
|
running = _state.audio_running
|
||||||
|
if _state.audio_source == 'waterfall':
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import get_shared_capture_status
|
||||||
|
|
||||||
|
shared = get_shared_capture_status()
|
||||||
|
running = bool(shared.get('running') and shared.get('monitor_enabled'))
|
||||||
|
except Exception:
|
||||||
|
running = False
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'running': running,
|
||||||
|
'frequency': _state.audio_frequency,
|
||||||
|
'modulation': _state.audio_modulation,
|
||||||
|
'source': _state.audio_source,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@receiver_bp.route('/audio/debug')
|
||||||
|
def audio_debug() -> Response:
|
||||||
|
"""Get audio debug status and recent stderr logs."""
|
||||||
|
rtl_log_path = '/tmp/rtl_fm_stderr.log'
|
||||||
|
ffmpeg_log_path = '/tmp/ffmpeg_stderr.log'
|
||||||
|
sample_path = '/tmp/audio_probe.bin'
|
||||||
|
|
||||||
|
def _read_log(path: str) -> str:
|
||||||
|
try:
|
||||||
|
with open(path) as handle:
|
||||||
|
return handle.read().strip()
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
shared = {}
|
||||||
|
if _state.audio_source == 'waterfall':
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import get_shared_capture_status
|
||||||
|
|
||||||
|
shared = get_shared_capture_status()
|
||||||
|
except Exception:
|
||||||
|
shared = {}
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'running': _state.audio_running,
|
||||||
|
'frequency': _state.audio_frequency,
|
||||||
|
'modulation': _state.audio_modulation,
|
||||||
|
'source': _state.audio_source,
|
||||||
|
'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
|
||||||
|
'device': scanner_config.get('device', 0),
|
||||||
|
'gain': scanner_config.get('gain', 0),
|
||||||
|
'squelch': scanner_config.get('squelch', 0),
|
||||||
|
'audio_process_alive': bool(_state.audio_process and _state.audio_process.poll() is None),
|
||||||
|
'shared_capture': shared,
|
||||||
|
'rtl_fm_stderr': _read_log(rtl_log_path),
|
||||||
|
'ffmpeg_stderr': _read_log(ffmpeg_log_path),
|
||||||
|
'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@receiver_bp.route('/audio/probe')
|
||||||
|
def audio_probe() -> Response:
|
||||||
|
"""Grab a small chunk of audio bytes from the pipeline for debugging."""
|
||||||
|
if _state.audio_source == 'waterfall':
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import read_shared_monitor_audio_chunk
|
||||||
|
|
||||||
|
data = read_shared_monitor_audio_chunk(timeout=2.0)
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'no shared audio data available'}), 504
|
||||||
|
sample_path = '/tmp/audio_probe.bin'
|
||||||
|
with open(sample_path, 'wb') as handle:
|
||||||
|
handle.write(data)
|
||||||
|
return jsonify({'status': 'ok', 'bytes': len(data), 'source': 'waterfall'})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
if not _state.audio_process or not _state.audio_process.stdout:
|
||||||
|
return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
|
||||||
|
|
||||||
|
sample_path = '/tmp/audio_probe.bin'
|
||||||
|
size = 0
|
||||||
|
try:
|
||||||
|
ready, _, _ = select.select([_state.audio_process.stdout], [], [], 2.0)
|
||||||
|
if not ready:
|
||||||
|
return jsonify({'status': 'error', 'message': 'no data available'}), 504
|
||||||
|
data = _state.audio_process.stdout.read(4096)
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'no data read'}), 504
|
||||||
|
with open(sample_path, 'wb') as handle:
|
||||||
|
handle.write(data)
|
||||||
|
size = len(data)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
return jsonify({'status': 'ok', 'bytes': size})
|
||||||
|
|
||||||
|
|
||||||
|
@receiver_bp.route('/audio/stream')
|
||||||
|
def stream_audio() -> Response:
|
||||||
|
"""Stream WAV audio."""
|
||||||
|
request_token_raw = request.args.get('request_token')
|
||||||
|
request_token = None
|
||||||
|
if request_token_raw is not None:
|
||||||
|
try:
|
||||||
|
request_token = int(request_token_raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
request_token = None
|
||||||
|
|
||||||
|
if request_token is not None and request_token < _state.audio_start_token:
|
||||||
|
return Response(b'', mimetype='audio/wav', status=204)
|
||||||
|
|
||||||
|
if _state.audio_source == 'waterfall':
|
||||||
|
for _ in range(40):
|
||||||
|
if _state.audio_running:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
if not _state.audio_running:
|
||||||
|
return Response(b'', mimetype='audio/wav', status=204)
|
||||||
|
|
||||||
|
def generate_shared():
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import (
|
||||||
|
get_shared_capture_status,
|
||||||
|
read_shared_monitor_audio_chunk,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Browser expects an immediate WAV header.
|
||||||
|
yield _wav_header(sample_rate=48000)
|
||||||
|
inactive_since: float | None = None
|
||||||
|
|
||||||
|
while _state.audio_running and _state.audio_source == 'waterfall':
|
||||||
|
if request_token is not None and request_token < _state.audio_start_token:
|
||||||
|
break
|
||||||
|
chunk = read_shared_monitor_audio_chunk(timeout=1.0)
|
||||||
|
if chunk:
|
||||||
|
inactive_since = None
|
||||||
|
yield chunk
|
||||||
|
continue
|
||||||
|
shared = get_shared_capture_status()
|
||||||
|
if shared.get('running') and shared.get('monitor_enabled'):
|
||||||
|
inactive_since = None
|
||||||
|
continue
|
||||||
|
if inactive_since is None:
|
||||||
|
inactive_since = time.monotonic()
|
||||||
|
continue
|
||||||
|
if (time.monotonic() - inactive_since) < 4.0:
|
||||||
|
continue
|
||||||
|
if not shared.get('running') or not shared.get('monitor_enabled'):
|
||||||
|
_state.audio_running = False
|
||||||
|
_state.audio_source = 'process'
|
||||||
|
break
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
generate_shared(),
|
||||||
|
mimetype='audio/wav',
|
||||||
|
headers={
|
||||||
|
'Content-Type': 'audio/wav',
|
||||||
|
'Cache-Control': 'no-cache, no-store',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
'Transfer-Encoding': 'chunked',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for audio process to be ready (up to 2 seconds).
|
||||||
|
for _ in range(40):
|
||||||
|
if _state.audio_running and _state.audio_process:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
if not _state.audio_running or not _state.audio_process:
|
||||||
|
return Response(b'', mimetype='audio/wav', status=204)
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
# Capture local reference to avoid race condition with stop
|
||||||
|
proc = _state.audio_process
|
||||||
|
if not proc or not proc.stdout:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# Drain stale audio that accumulated in the pipe buffer
|
||||||
|
# between pipeline start and stream connection. Keep the
|
||||||
|
# first chunk (contains WAV header) and discard the rest
|
||||||
|
# so the browser starts close to real-time.
|
||||||
|
header_chunk = None
|
||||||
|
while True:
|
||||||
|
ready, _, _ = select.select([proc.stdout], [], [], 0)
|
||||||
|
if not ready:
|
||||||
|
break
|
||||||
|
chunk = proc.stdout.read(8192)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
if header_chunk is None:
|
||||||
|
header_chunk = chunk
|
||||||
|
if header_chunk:
|
||||||
|
yield header_chunk
|
||||||
|
|
||||||
|
# Stream real-time audio
|
||||||
|
first_chunk_deadline = time.time() + 20.0
|
||||||
|
warned_wait = False
|
||||||
|
while _state.audio_running and proc.poll() is None:
|
||||||
|
if request_token is not None and request_token < _state.audio_start_token:
|
||||||
|
break
|
||||||
|
# Use select to avoid blocking forever
|
||||||
|
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
||||||
|
if ready:
|
||||||
|
chunk = proc.stdout.read(8192)
|
||||||
|
if chunk:
|
||||||
|
warned_wait = False
|
||||||
|
yield chunk
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Keep connection open while demodulator settles.
|
||||||
|
if time.time() > first_chunk_deadline:
|
||||||
|
if not warned_wait:
|
||||||
|
logger.warning("Audio stream still waiting for first chunk")
|
||||||
|
warned_wait = True
|
||||||
|
continue
|
||||||
|
# Timeout - check if process died
|
||||||
|
if proc.poll() is not None:
|
||||||
|
break
|
||||||
|
except GeneratorExit:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Audio stream error: {e}")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
generate(),
|
||||||
|
mimetype='audio/wav',
|
||||||
|
headers={
|
||||||
|
'Content-Type': 'audio/wav',
|
||||||
|
'Cache-Control': 'no-cache, no-store',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
'Transfer-Encoding': 'chunked',
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,804 @@
|
|||||||
|
"""Scanner routes and implementation for frequency scanning."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import math
|
||||||
|
import queue
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Response, jsonify, request
|
||||||
|
|
||||||
|
import routes.listening_post as _state
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
_rtl_fm_demod_mode,
|
||||||
|
_start_audio_stream,
|
||||||
|
_stop_audio_stream,
|
||||||
|
activity_log,
|
||||||
|
activity_log_lock,
|
||||||
|
add_activity_log,
|
||||||
|
app_module,
|
||||||
|
find_rtl_fm,
|
||||||
|
find_rtl_power,
|
||||||
|
find_rx_fm,
|
||||||
|
logger,
|
||||||
|
normalize_modulation,
|
||||||
|
process_event,
|
||||||
|
receiver_bp,
|
||||||
|
scanner_config,
|
||||||
|
scanner_lock,
|
||||||
|
scanner_queue,
|
||||||
|
sse_stream_fanout,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# SCANNER IMPLEMENTATION
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def scanner_loop():
|
||||||
|
"""Main scanner loop - scans frequencies looking for signals."""
|
||||||
|
logger.info("Scanner thread started")
|
||||||
|
add_activity_log('scanner_start', scanner_config['start_freq'],
|
||||||
|
f"Scanning {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
|
||||||
|
|
||||||
|
rtl_fm_path = find_rtl_fm()
|
||||||
|
|
||||||
|
if not rtl_fm_path:
|
||||||
|
logger.error("rtl_fm not found")
|
||||||
|
add_activity_log('error', 0, 'rtl_fm not found')
|
||||||
|
_state.scanner_running = False
|
||||||
|
return
|
||||||
|
|
||||||
|
current_freq = scanner_config['start_freq']
|
||||||
|
last_signal_time = 0
|
||||||
|
signal_detected = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
while _state.scanner_running:
|
||||||
|
# Check if paused
|
||||||
|
if _state.scanner_paused:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Read config values on each iteration (allows live updates)
|
||||||
|
step_mhz = scanner_config['step'] / 1000.0
|
||||||
|
squelch = scanner_config['squelch']
|
||||||
|
mod = scanner_config['modulation']
|
||||||
|
gain = scanner_config['gain']
|
||||||
|
device = scanner_config['device']
|
||||||
|
|
||||||
|
_state.scanner_current_freq = current_freq
|
||||||
|
|
||||||
|
# Notify clients of frequency change
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'freq_change',
|
||||||
|
'frequency': current_freq,
|
||||||
|
'scanning': not signal_detected,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
|
||||||
|
# Start rtl_fm at this frequency
|
||||||
|
freq_hz = int(current_freq * 1e6)
|
||||||
|
|
||||||
|
# Sample rates
|
||||||
|
if mod == 'wfm':
|
||||||
|
sample_rate = 170000
|
||||||
|
resample_rate = 32000
|
||||||
|
elif mod in ['usb', 'lsb']:
|
||||||
|
sample_rate = 12000
|
||||||
|
resample_rate = 12000
|
||||||
|
else:
|
||||||
|
sample_rate = 24000
|
||||||
|
resample_rate = 24000
|
||||||
|
|
||||||
|
# Don't use squelch in rtl_fm - we want to analyze raw audio
|
||||||
|
rtl_cmd = [
|
||||||
|
rtl_fm_path,
|
||||||
|
'-M', _rtl_fm_demod_mode(mod),
|
||||||
|
'-f', str(freq_hz),
|
||||||
|
'-s', str(sample_rate),
|
||||||
|
'-r', str(resample_rate),
|
||||||
|
'-g', str(gain),
|
||||||
|
'-d', str(device),
|
||||||
|
]
|
||||||
|
# Add bias-t flag if enabled (for external LNA power)
|
||||||
|
if scanner_config.get('bias_t', False):
|
||||||
|
rtl_cmd.append('-T')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start rtl_fm
|
||||||
|
rtl_proc = subprocess.Popen(
|
||||||
|
rtl_cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read audio data for analysis
|
||||||
|
audio_data = b''
|
||||||
|
|
||||||
|
# Read audio samples for a short period
|
||||||
|
sample_duration = 0.25 # 250ms - balance between speed and detection
|
||||||
|
bytes_needed = int(resample_rate * 2 * sample_duration) # 16-bit mono
|
||||||
|
|
||||||
|
while len(audio_data) < bytes_needed and _state.scanner_running:
|
||||||
|
chunk = rtl_proc.stdout.read(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
audio_data += chunk
|
||||||
|
|
||||||
|
# Clean up rtl_fm
|
||||||
|
rtl_proc.terminate()
|
||||||
|
try:
|
||||||
|
rtl_proc.wait(timeout=1)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
rtl_proc.kill()
|
||||||
|
|
||||||
|
# Analyze audio level
|
||||||
|
audio_detected = False
|
||||||
|
rms = 0
|
||||||
|
threshold = 500
|
||||||
|
if len(audio_data) > 100:
|
||||||
|
samples = struct.unpack(f'{len(audio_data)//2}h', audio_data)
|
||||||
|
# Calculate RMS level (root mean square)
|
||||||
|
rms = (sum(s*s for s in samples) / len(samples)) ** 0.5
|
||||||
|
|
||||||
|
# Threshold based on squelch setting
|
||||||
|
# Lower squelch = more sensitive (lower threshold)
|
||||||
|
# squelch 0 = very sensitive, squelch 100 = only strong signals
|
||||||
|
if mod == 'wfm':
|
||||||
|
# WFM: threshold 500-10000 based on squelch
|
||||||
|
threshold = 500 + (squelch * 95)
|
||||||
|
min_threshold = 1500
|
||||||
|
else:
|
||||||
|
# AM/NFM: threshold 300-6500 based on squelch
|
||||||
|
threshold = 300 + (squelch * 62)
|
||||||
|
min_threshold = 900
|
||||||
|
|
||||||
|
effective_threshold = max(threshold, min_threshold)
|
||||||
|
audio_detected = rms > effective_threshold
|
||||||
|
|
||||||
|
# Send level info to clients
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'scan_update',
|
||||||
|
'frequency': current_freq,
|
||||||
|
'level': int(rms),
|
||||||
|
'threshold': int(effective_threshold) if 'effective_threshold' in dir() else 0,
|
||||||
|
'detected': audio_detected,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
|
||||||
|
if audio_detected and _state.scanner_running:
|
||||||
|
if not signal_detected:
|
||||||
|
# New signal found!
|
||||||
|
signal_detected = True
|
||||||
|
last_signal_time = time.time()
|
||||||
|
add_activity_log('signal_found', current_freq,
|
||||||
|
f'Signal detected on {current_freq:.3f} MHz ({mod.upper()})')
|
||||||
|
logger.info(f"Signal found at {current_freq} MHz")
|
||||||
|
|
||||||
|
# Start audio streaming for user
|
||||||
|
_start_audio_stream(current_freq, mod)
|
||||||
|
|
||||||
|
try:
|
||||||
|
snr_db = round(10 * math.log10(rms / effective_threshold), 1) if rms > 0 and effective_threshold > 0 else 0.0
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'signal_found',
|
||||||
|
'frequency': current_freq,
|
||||||
|
'modulation': mod,
|
||||||
|
'audio_streaming': True,
|
||||||
|
'level': int(rms),
|
||||||
|
'threshold': int(effective_threshold),
|
||||||
|
'snr': snr_db,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check for skip signal
|
||||||
|
if _state.scanner_skip_signal:
|
||||||
|
_state.scanner_skip_signal = False
|
||||||
|
signal_detected = False
|
||||||
|
_stop_audio_stream()
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'signal_skipped',
|
||||||
|
'frequency': current_freq
|
||||||
|
})
|
||||||
|
# Move to next frequency (step is in kHz, convert to MHz)
|
||||||
|
current_freq += step_mhz
|
||||||
|
if current_freq > scanner_config['end_freq']:
|
||||||
|
current_freq = scanner_config['start_freq']
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Stay on this frequency (dwell) but check periodically
|
||||||
|
dwell_start = time.time()
|
||||||
|
while (time.time() - dwell_start) < scanner_config['dwell_time'] and _state.scanner_running:
|
||||||
|
if _state.scanner_skip_signal:
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
last_signal_time = time.time()
|
||||||
|
|
||||||
|
# After dwell, move on to keep scanning
|
||||||
|
if _state.scanner_running and not _state.scanner_skip_signal:
|
||||||
|
signal_detected = False
|
||||||
|
_stop_audio_stream()
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'signal_lost',
|
||||||
|
'frequency': current_freq,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
|
||||||
|
current_freq += step_mhz
|
||||||
|
if current_freq > scanner_config['end_freq']:
|
||||||
|
current_freq = scanner_config['start_freq']
|
||||||
|
add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
|
||||||
|
time.sleep(scanner_config['scan_delay'])
|
||||||
|
|
||||||
|
else:
|
||||||
|
# No signal at this frequency
|
||||||
|
if signal_detected:
|
||||||
|
# Signal lost
|
||||||
|
duration = time.time() - last_signal_time + scanner_config['dwell_time']
|
||||||
|
add_activity_log('signal_lost', current_freq,
|
||||||
|
f'Signal lost after {duration:.1f}s')
|
||||||
|
signal_detected = False
|
||||||
|
|
||||||
|
# Stop audio
|
||||||
|
_stop_audio_stream()
|
||||||
|
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'signal_lost',
|
||||||
|
'frequency': current_freq
|
||||||
|
})
|
||||||
|
|
||||||
|
# Move to next frequency (step is in kHz, convert to MHz)
|
||||||
|
current_freq += step_mhz
|
||||||
|
if current_freq > scanner_config['end_freq']:
|
||||||
|
current_freq = scanner_config['start_freq']
|
||||||
|
add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
|
||||||
|
|
||||||
|
time.sleep(scanner_config['scan_delay'])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Scanner error at {current_freq} MHz: {e}")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Scanner loop error: {e}")
|
||||||
|
finally:
|
||||||
|
_state.scanner_running = False
|
||||||
|
_stop_audio_stream()
|
||||||
|
add_activity_log('scanner_stop', _state.scanner_current_freq, 'Scanner stopped')
|
||||||
|
logger.info("Scanner thread stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def scanner_loop_power():
|
||||||
|
"""Power sweep scanner using rtl_power to detect peaks."""
|
||||||
|
logger.info("Power sweep scanner thread started")
|
||||||
|
add_activity_log('scanner_start', scanner_config['start_freq'],
|
||||||
|
f"Power sweep {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
|
||||||
|
|
||||||
|
rtl_power_path = find_rtl_power()
|
||||||
|
if not rtl_power_path:
|
||||||
|
logger.error("rtl_power not found")
|
||||||
|
add_activity_log('error', 0, 'rtl_power not found')
|
||||||
|
_state.scanner_running = False
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
while _state.scanner_running:
|
||||||
|
if _state.scanner_paused:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_mhz = scanner_config['start_freq']
|
||||||
|
end_mhz = scanner_config['end_freq']
|
||||||
|
step_khz = scanner_config['step']
|
||||||
|
gain = scanner_config['gain']
|
||||||
|
device = scanner_config['device']
|
||||||
|
scanner_config['squelch']
|
||||||
|
mod = scanner_config['modulation']
|
||||||
|
|
||||||
|
# Configure sweep
|
||||||
|
bin_hz = max(1000, int(step_khz * 1000))
|
||||||
|
start_hz = int(start_mhz * 1e6)
|
||||||
|
end_hz = int(end_mhz * 1e6)
|
||||||
|
# Integration time per sweep (seconds)
|
||||||
|
integration = max(0.3, min(1.0, scanner_config.get('scan_delay', 0.5)))
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
rtl_power_path,
|
||||||
|
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
|
||||||
|
'-i', f'{integration}',
|
||||||
|
'-1',
|
||||||
|
'-g', str(gain),
|
||||||
|
'-d', str(device),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||||
|
_state.scanner_power_process = proc
|
||||||
|
stdout, _ = proc.communicate(timeout=15)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
stdout = b''
|
||||||
|
finally:
|
||||||
|
_state.scanner_power_process = None
|
||||||
|
|
||||||
|
if not _state.scanner_running:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not stdout:
|
||||||
|
add_activity_log('error', start_mhz, 'Power sweep produced no data')
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'scan_update',
|
||||||
|
'frequency': end_mhz,
|
||||||
|
'level': 0,
|
||||||
|
'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100),
|
||||||
|
'detected': False,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
time.sleep(0.2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines = stdout.decode(errors='ignore').splitlines()
|
||||||
|
segments = []
|
||||||
|
for line in lines:
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = [p.strip() for p in line.split(',')]
|
||||||
|
# Find start_hz token
|
||||||
|
start_idx = None
|
||||||
|
for i, tok in enumerate(parts):
|
||||||
|
try:
|
||||||
|
val = float(tok)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if val > 1e5:
|
||||||
|
start_idx = i
|
||||||
|
break
|
||||||
|
if start_idx is None or len(parts) < start_idx + 6:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
sweep_start = float(parts[start_idx])
|
||||||
|
sweep_end = float(parts[start_idx + 1])
|
||||||
|
sweep_bin = float(parts[start_idx + 2])
|
||||||
|
raw_values = []
|
||||||
|
for v in parts[start_idx + 3:]:
|
||||||
|
try:
|
||||||
|
raw_values.append(float(v))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
# rtl_power may include a samples field before the power list
|
||||||
|
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
|
||||||
|
raw_values = raw_values[1:]
|
||||||
|
bin_values = raw_values
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not bin_values:
|
||||||
|
continue
|
||||||
|
|
||||||
|
segments.append((sweep_start, sweep_end, sweep_bin, bin_values))
|
||||||
|
|
||||||
|
if not segments:
|
||||||
|
add_activity_log('error', start_mhz, 'Power sweep bins missing')
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'scan_update',
|
||||||
|
'frequency': end_mhz,
|
||||||
|
'level': 0,
|
||||||
|
'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100),
|
||||||
|
'detected': False,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
time.sleep(0.2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Process segments in ascending frequency order to avoid backtracking in UI
|
||||||
|
segments.sort(key=lambda s: s[0])
|
||||||
|
total_bins = sum(len(seg[3]) for seg in segments)
|
||||||
|
if total_bins <= 0:
|
||||||
|
time.sleep(0.2)
|
||||||
|
continue
|
||||||
|
segment_offset = 0
|
||||||
|
|
||||||
|
for sweep_start, sweep_end, sweep_bin, bin_values in segments:
|
||||||
|
# Noise floor (median)
|
||||||
|
sorted_vals = sorted(bin_values)
|
||||||
|
mid = len(sorted_vals) // 2
|
||||||
|
noise_floor = sorted_vals[mid]
|
||||||
|
|
||||||
|
# SNR threshold (dB)
|
||||||
|
snr_threshold = float(scanner_config.get('snr_threshold', 12))
|
||||||
|
|
||||||
|
# Emit progress updates (throttled)
|
||||||
|
emit_stride = max(1, len(bin_values) // 60)
|
||||||
|
for idx, val in enumerate(bin_values):
|
||||||
|
if idx % emit_stride != 0 and idx != len(bin_values) - 1:
|
||||||
|
continue
|
||||||
|
freq_hz = sweep_start + sweep_bin * idx
|
||||||
|
_state.scanner_current_freq = freq_hz / 1e6
|
||||||
|
snr = val - noise_floor
|
||||||
|
level = int(max(0, snr) * 100)
|
||||||
|
threshold = int(snr_threshold * 100)
|
||||||
|
progress = min(1.0, (segment_offset + idx) / max(1, total_bins - 1))
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'scan_update',
|
||||||
|
'frequency': _state.scanner_current_freq,
|
||||||
|
'level': level,
|
||||||
|
'threshold': threshold,
|
||||||
|
'detected': snr >= snr_threshold,
|
||||||
|
'progress': progress,
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
segment_offset += len(bin_values)
|
||||||
|
|
||||||
|
# Detect peaks (clusters above threshold)
|
||||||
|
peaks = []
|
||||||
|
in_cluster = False
|
||||||
|
peak_idx = None
|
||||||
|
peak_val = None
|
||||||
|
for idx, val in enumerate(bin_values):
|
||||||
|
snr = val - noise_floor
|
||||||
|
if snr >= snr_threshold:
|
||||||
|
if not in_cluster:
|
||||||
|
in_cluster = True
|
||||||
|
peak_idx = idx
|
||||||
|
peak_val = val
|
||||||
|
else:
|
||||||
|
if val > peak_val:
|
||||||
|
peak_val = val
|
||||||
|
peak_idx = idx
|
||||||
|
else:
|
||||||
|
if in_cluster and peak_idx is not None:
|
||||||
|
peaks.append((peak_idx, peak_val))
|
||||||
|
in_cluster = False
|
||||||
|
peak_idx = None
|
||||||
|
peak_val = None
|
||||||
|
if in_cluster and peak_idx is not None:
|
||||||
|
peaks.append((peak_idx, peak_val))
|
||||||
|
|
||||||
|
for idx, val in peaks:
|
||||||
|
freq_hz = sweep_start + sweep_bin * (idx + 0.5)
|
||||||
|
freq_mhz = freq_hz / 1e6
|
||||||
|
snr = val - noise_floor
|
||||||
|
level = int(max(0, snr) * 100)
|
||||||
|
threshold = int(snr_threshold * 100)
|
||||||
|
add_activity_log('signal_found', freq_mhz,
|
||||||
|
f'Peak detected at {freq_mhz:.3f} MHz ({mod.upper()})')
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'signal_found',
|
||||||
|
'frequency': freq_mhz,
|
||||||
|
'modulation': mod,
|
||||||
|
'audio_streaming': False,
|
||||||
|
'level': level,
|
||||||
|
'threshold': threshold,
|
||||||
|
'snr': round(snr, 1),
|
||||||
|
'range_start': scanner_config['start_freq'],
|
||||||
|
'range_end': scanner_config['end_freq']
|
||||||
|
})
|
||||||
|
|
||||||
|
add_activity_log('scan_cycle', start_mhz, 'Power sweep complete')
|
||||||
|
time.sleep(max(0.1, scanner_config.get('scan_delay', 0.5)))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Power sweep scanner error: {e}")
|
||||||
|
finally:
|
||||||
|
_state.scanner_running = False
|
||||||
|
add_activity_log('scanner_stop', _state.scanner_current_freq, 'Scanner stopped')
|
||||||
|
logger.info("Power sweep scanner thread stopped")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# SCANNER API ENDPOINTS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@receiver_bp.route('/scanner/start', methods=['POST'])
|
||||||
|
def start_scanner() -> Response:
|
||||||
|
"""Start the frequency scanner."""
|
||||||
|
with scanner_lock:
|
||||||
|
if _state.scanner_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Scanner already running'
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Clear stale queue entries so UI updates immediately
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
scanner_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Update scanner config
|
||||||
|
try:
|
||||||
|
scanner_config['start_freq'] = float(data.get('start_freq', 88.0))
|
||||||
|
scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
|
||||||
|
scanner_config['step'] = float(data.get('step', 0.1))
|
||||||
|
scanner_config['modulation'] = normalize_modulation(data.get('modulation', 'wfm'))
|
||||||
|
scanner_config['squelch'] = int(data.get('squelch', 0))
|
||||||
|
scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
|
||||||
|
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
|
||||||
|
scanner_config['device'] = int(data.get('device', 0))
|
||||||
|
scanner_config['gain'] = int(data.get('gain', 40))
|
||||||
|
scanner_config['bias_t'] = bool(data.get('bias_t', False))
|
||||||
|
scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||||
|
scanner_config['scan_method'] = str(data.get('scan_method', '')).lower().strip()
|
||||||
|
if data.get('snr_threshold') is not None:
|
||||||
|
scanner_config['snr_threshold'] = float(data.get('snr_threshold'))
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid parameter: {e}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
if scanner_config['start_freq'] >= scanner_config['end_freq']:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'start_freq must be less than end_freq'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Decide scan method
|
||||||
|
if not scanner_config['scan_method']:
|
||||||
|
scanner_config['scan_method'] = 'power' if find_rtl_power() else 'classic'
|
||||||
|
|
||||||
|
sdr_type = scanner_config['sdr_type']
|
||||||
|
|
||||||
|
# Power scan only supports RTL-SDR for now
|
||||||
|
if scanner_config['scan_method'] == 'power' and (sdr_type != 'rtlsdr' or not find_rtl_power()):
|
||||||
|
scanner_config['scan_method'] = 'classic'
|
||||||
|
|
||||||
|
# Check tools based on chosen method
|
||||||
|
if scanner_config['scan_method'] == 'power':
|
||||||
|
if not find_rtl_power():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'rtl_power not found. Install rtl-sdr tools.'
|
||||||
|
}), 503
|
||||||
|
# Release listening device if active
|
||||||
|
if _state.receiver_active_device is not None:
|
||||||
|
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
|
||||||
|
_state.receiver_active_device = None
|
||||||
|
_state.receiver_active_sdr_type = 'rtlsdr'
|
||||||
|
# Claim device for scanner
|
||||||
|
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
_state.scanner_active_device = scanner_config['device']
|
||||||
|
_state.scanner_active_sdr_type = scanner_config['sdr_type']
|
||||||
|
_state.scanner_running = True
|
||||||
|
_state.scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True)
|
||||||
|
_state.scanner_thread.start()
|
||||||
|
else:
|
||||||
|
if sdr_type == 'rtlsdr':
|
||||||
|
if not find_rtl_fm():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'rtl_fm not found. Install rtl-sdr tools.'
|
||||||
|
}), 503
|
||||||
|
else:
|
||||||
|
if not find_rx_fm():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
||||||
|
}), 503
|
||||||
|
if _state.receiver_active_device is not None:
|
||||||
|
app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type)
|
||||||
|
_state.receiver_active_device = None
|
||||||
|
_state.receiver_active_sdr_type = 'rtlsdr'
|
||||||
|
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type'])
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
_state.scanner_active_device = scanner_config['device']
|
||||||
|
_state.scanner_active_sdr_type = scanner_config['sdr_type']
|
||||||
|
|
||||||
|
_state.scanner_running = True
|
||||||
|
_state.scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
|
||||||
|
_state.scanner_thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'config': scanner_config
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@receiver_bp.route('/scanner/stop', methods=['POST'])
|
||||||
|
def stop_scanner() -> Response:
|
||||||
|
"""Stop the frequency scanner."""
|
||||||
|
_state.scanner_running = False
|
||||||
|
_stop_audio_stream()
|
||||||
|
if _state.scanner_power_process and _state.scanner_power_process.poll() is None:
|
||||||
|
try:
|
||||||
|
_state.scanner_power_process.terminate()
|
||||||
|
_state.scanner_power_process.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
_state.scanner_power_process.kill()
|
||||||
|
_state.scanner_power_process = None
|
||||||
|
if _state.scanner_active_device is not None:
|
||||||
|
app_module.release_sdr_device(_state.scanner_active_device, _state.scanner_active_sdr_type)
|
||||||
|
_state.scanner_active_device = None
|
||||||
|
_state.scanner_active_sdr_type = 'rtlsdr'
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@receiver_bp.route('/scanner/pause', methods=['POST'])
|
||||||
|
def pause_scanner() -> Response:
|
||||||
|
"""Pause/resume the scanner."""
|
||||||
|
_state.scanner_paused = not _state.scanner_paused
|
||||||
|
|
||||||
|
if _state.scanner_paused:
|
||||||
|
add_activity_log('scanner_pause', _state.scanner_current_freq, 'Scanner paused')
|
||||||
|
else:
|
||||||
|
add_activity_log('scanner_resume', _state.scanner_current_freq, 'Scanner resumed')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'paused' if _state.scanner_paused else 'resumed',
|
||||||
|
'paused': _state.scanner_paused
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@receiver_bp.route('/scanner/skip', methods=['POST'])
|
||||||
|
def skip_signal() -> Response:
|
||||||
|
"""Skip current signal and continue scanning."""
|
||||||
|
if not _state.scanner_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Scanner not running'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
_state.scanner_skip_signal = True
|
||||||
|
add_activity_log('signal_skip', _state.scanner_current_freq, f'Skipped signal at {_state.scanner_current_freq:.3f} MHz')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'skipped',
|
||||||
|
'frequency': _state.scanner_current_freq
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@receiver_bp.route('/scanner/config', methods=['POST'])
|
||||||
|
def update_scanner_config() -> Response:
|
||||||
|
"""Update scanner config while running (step, squelch, gain, dwell)."""
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
updated = []
|
||||||
|
|
||||||
|
if 'step' in data:
|
||||||
|
scanner_config['step'] = float(data['step'])
|
||||||
|
updated.append(f"step={data['step']}kHz")
|
||||||
|
|
||||||
|
if 'squelch' in data:
|
||||||
|
scanner_config['squelch'] = int(data['squelch'])
|
||||||
|
updated.append(f"squelch={data['squelch']}")
|
||||||
|
|
||||||
|
if 'gain' in data:
|
||||||
|
scanner_config['gain'] = int(data['gain'])
|
||||||
|
updated.append(f"gain={data['gain']}")
|
||||||
|
|
||||||
|
if 'dwell_time' in data:
|
||||||
|
scanner_config['dwell_time'] = int(data['dwell_time'])
|
||||||
|
updated.append(f"dwell={data['dwell_time']}s")
|
||||||
|
|
||||||
|
if 'modulation' in data:
|
||||||
|
try:
|
||||||
|
scanner_config['modulation'] = normalize_modulation(data['modulation'])
|
||||||
|
updated.append(f"mod={data['modulation']}")
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
logger.info(f"Scanner config updated: {', '.join(updated)}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'updated',
|
||||||
|
'config': scanner_config
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@receiver_bp.route('/scanner/status')
|
||||||
|
def scanner_status() -> Response:
|
||||||
|
"""Get scanner status."""
|
||||||
|
return jsonify({
|
||||||
|
'running': _state.scanner_running,
|
||||||
|
'paused': _state.scanner_paused,
|
||||||
|
'current_freq': _state.scanner_current_freq,
|
||||||
|
'config': scanner_config,
|
||||||
|
'audio_streaming': _state.audio_running,
|
||||||
|
'audio_frequency': _state.audio_frequency
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@receiver_bp.route('/scanner/stream')
|
||||||
|
def stream_scanner_events() -> Response:
|
||||||
|
"""SSE stream for scanner events."""
|
||||||
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
|
process_event('receiver_scanner', msg, msg.get('type'))
|
||||||
|
|
||||||
|
response = Response(
|
||||||
|
sse_stream_fanout(
|
||||||
|
source_queue=scanner_queue,
|
||||||
|
channel_key='receiver_scanner',
|
||||||
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
|
on_message=_on_msg,
|
||||||
|
),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
)
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@receiver_bp.route('/scanner/log')
|
||||||
|
def get_activity_log() -> Response:
|
||||||
|
"""Get activity log."""
|
||||||
|
limit = request.args.get('limit', 100, type=int)
|
||||||
|
with activity_log_lock:
|
||||||
|
return jsonify({
|
||||||
|
'log': activity_log[:limit],
|
||||||
|
'total': len(activity_log)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@receiver_bp.route('/scanner/log/clear', methods=['POST'])
|
||||||
|
def clear_activity_log() -> Response:
|
||||||
|
"""Clear activity log."""
|
||||||
|
with activity_log_lock:
|
||||||
|
activity_log.clear()
|
||||||
|
return jsonify({'status': 'cleared'})
|
||||||
|
|
||||||
|
|
||||||
|
@receiver_bp.route('/presets')
|
||||||
|
def get_presets() -> Response:
|
||||||
|
"""Get scanner presets."""
|
||||||
|
presets = [
|
||||||
|
{'name': 'FM Broadcast', 'start': 88.0, 'end': 108.0, 'step': 0.2, 'mod': 'wfm'},
|
||||||
|
{'name': 'Air Band', 'start': 118.0, 'end': 137.0, 'step': 0.025, 'mod': 'am'},
|
||||||
|
{'name': 'Marine VHF', 'start': 156.0, 'end': 163.0, 'step': 0.025, 'mod': 'fm'},
|
||||||
|
{'name': 'Amateur 2m', 'start': 144.0, 'end': 148.0, 'step': 0.0125, 'mod': 'fm'},
|
||||||
|
{'name': 'Amateur 70cm', 'start': 430.0, 'end': 440.0, 'step': 0.025, 'mod': 'fm'},
|
||||||
|
{'name': 'PMR446', 'start': 446.0, 'end': 446.2, 'step': 0.0125, 'mod': 'fm'},
|
||||||
|
{'name': 'FRS/GMRS', 'start': 462.5, 'end': 467.7, 'step': 0.025, 'mod': 'fm'},
|
||||||
|
{'name': 'Weather Radio', 'start': 162.4, 'end': 162.55, 'step': 0.025, 'mod': 'fm'},
|
||||||
|
]
|
||||||
|
return jsonify({'presets': presets})
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Tool check and signal identification routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Response, jsonify, request
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
find_ffmpeg,
|
||||||
|
find_rtl_fm,
|
||||||
|
find_rtl_power,
|
||||||
|
find_rx_fm,
|
||||||
|
logger,
|
||||||
|
receiver_bp,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# TOOL CHECK ENDPOINT
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@receiver_bp.route('/tools')
|
||||||
|
def check_tools() -> Response:
|
||||||
|
"""Check for required tools."""
|
||||||
|
rtl_fm = find_rtl_fm()
|
||||||
|
rtl_power = find_rtl_power()
|
||||||
|
rx_fm = find_rx_fm()
|
||||||
|
ffmpeg = find_ffmpeg()
|
||||||
|
|
||||||
|
# Determine which SDR types are supported
|
||||||
|
supported_sdr_types = []
|
||||||
|
if rtl_fm:
|
||||||
|
supported_sdr_types.append('rtlsdr')
|
||||||
|
if rx_fm:
|
||||||
|
# rx_fm from SoapySDR supports these types
|
||||||
|
supported_sdr_types.extend(['hackrf', 'airspy', 'limesdr', 'sdrplay'])
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'rtl_fm': rtl_fm is not None,
|
||||||
|
'rtl_power': rtl_power is not None,
|
||||||
|
'rx_fm': rx_fm is not None,
|
||||||
|
'ffmpeg': ffmpeg is not None,
|
||||||
|
'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
|
||||||
|
'supported_sdr_types': supported_sdr_types
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# SIGNAL IDENTIFICATION ENDPOINT
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@receiver_bp.route('/signal/guess', methods=['POST'])
|
||||||
|
def guess_signal() -> Response:
|
||||||
|
"""Identify a signal based on frequency, modulation, and other parameters."""
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
freq_mhz = data.get('frequency_mhz')
|
||||||
|
if freq_mhz is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
freq_mhz = float(freq_mhz)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400
|
||||||
|
|
||||||
|
if freq_mhz <= 0:
|
||||||
|
return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400
|
||||||
|
|
||||||
|
frequency_hz = int(freq_mhz * 1e6)
|
||||||
|
|
||||||
|
modulation = data.get('modulation')
|
||||||
|
bandwidth_hz = data.get('bandwidth_hz')
|
||||||
|
if bandwidth_hz is not None:
|
||||||
|
try:
|
||||||
|
bandwidth_hz = int(bandwidth_hz)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
bandwidth_hz = None
|
||||||
|
|
||||||
|
region = data.get('region', 'UK/EU')
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.signal_guess import guess_signal_type_dict
|
||||||
|
result = guess_signal_type_dict(
|
||||||
|
frequency_hz=frequency_hz,
|
||||||
|
modulation=modulation,
|
||||||
|
bandwidth_hz=bandwidth_hz,
|
||||||
|
region=region,
|
||||||
|
)
|
||||||
|
return jsonify({'status': 'ok', **result})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Signal guess error: {e}")
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
"""Waterfall / spectrogram routes and implementation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import math
|
||||||
|
import queue
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Response, jsonify, request
|
||||||
|
|
||||||
|
import routes.listening_post as _state
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
SDRFactory,
|
||||||
|
SDRType,
|
||||||
|
_stop_waterfall_internal,
|
||||||
|
app_module,
|
||||||
|
find_rtl_power,
|
||||||
|
logger,
|
||||||
|
process_event,
|
||||||
|
receiver_bp,
|
||||||
|
sse_stream_fanout,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# WATERFALL HELPER FUNCTIONS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | None, list[float]]:
|
||||||
|
"""Parse a single rtl_power CSV line into bins."""
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
return None, None, None, []
|
||||||
|
|
||||||
|
parts = [p.strip() for p in line.split(',')]
|
||||||
|
if len(parts) < 6:
|
||||||
|
return None, None, None, []
|
||||||
|
|
||||||
|
# Timestamp in first two fields (YYYY-MM-DD, HH:MM:SS)
|
||||||
|
timestamp = f"{parts[0]} {parts[1]}" if len(parts) >= 2 else parts[0]
|
||||||
|
|
||||||
|
start_idx = None
|
||||||
|
for i, tok in enumerate(parts):
|
||||||
|
try:
|
||||||
|
val = float(tok)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if val > 1e5:
|
||||||
|
start_idx = i
|
||||||
|
break
|
||||||
|
if start_idx is None or len(parts) < start_idx + 4:
|
||||||
|
return timestamp, None, None, []
|
||||||
|
|
||||||
|
try:
|
||||||
|
seg_start = float(parts[start_idx])
|
||||||
|
seg_end = float(parts[start_idx + 1])
|
||||||
|
raw_values = []
|
||||||
|
for v in parts[start_idx + 3:]:
|
||||||
|
try:
|
||||||
|
raw_values.append(float(v))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
|
||||||
|
raw_values = raw_values[1:]
|
||||||
|
return timestamp, seg_start, seg_end, raw_values
|
||||||
|
except ValueError:
|
||||||
|
return timestamp, None, None, []
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_waterfall_error(message: str) -> None:
|
||||||
|
"""Push an error message onto the waterfall SSE queue."""
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
_state.waterfall_queue.put_nowait({
|
||||||
|
'type': 'waterfall_error',
|
||||||
|
'message': message,
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _downsample_bins(values: list[float], target: int) -> list[float]:
|
||||||
|
"""Downsample bins to a target length using simple averaging."""
|
||||||
|
if target <= 0 or len(values) <= target:
|
||||||
|
return values
|
||||||
|
|
||||||
|
out: list[float] = []
|
||||||
|
step = len(values) / target
|
||||||
|
for i in range(target):
|
||||||
|
start = int(i * step)
|
||||||
|
end = int((i + 1) * step)
|
||||||
|
if end <= start:
|
||||||
|
end = min(start + 1, len(values))
|
||||||
|
chunk = values[start:end]
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
out.append(sum(chunk) / len(chunk))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# WATERFALL LOOP IMPLEMENTATIONS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def _waterfall_loop():
|
||||||
|
"""Continuous waterfall sweep loop emitting FFT data."""
|
||||||
|
sdr_type_str = _state.waterfall_config.get('sdr_type', 'rtlsdr')
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
|
if sdr_type == SDRType.RTL_SDR:
|
||||||
|
_waterfall_loop_rtl_power()
|
||||||
|
else:
|
||||||
|
_waterfall_loop_iq(sdr_type)
|
||||||
|
|
||||||
|
|
||||||
|
def _waterfall_loop_iq(sdr_type: SDRType):
|
||||||
|
"""Waterfall loop using rx_sdr IQ capture + FFT for HackRF/SoapySDR devices."""
|
||||||
|
start_freq = _state.waterfall_config['start_freq']
|
||||||
|
end_freq = _state.waterfall_config['end_freq']
|
||||||
|
gain = _state.waterfall_config['gain']
|
||||||
|
device = _state.waterfall_config['device']
|
||||||
|
interval = float(_state.waterfall_config.get('interval', 0.4))
|
||||||
|
|
||||||
|
# Use center frequency and sample rate to cover the requested span
|
||||||
|
center_mhz = (start_freq + end_freq) / 2.0
|
||||||
|
span_hz = (end_freq - start_freq) * 1e6
|
||||||
|
# Pick a sample rate that covers the span (minimum 2 MHz for HackRF)
|
||||||
|
sample_rate = max(2000000, int(span_hz))
|
||||||
|
# Cap to sensible maximum
|
||||||
|
sample_rate = min(sample_rate, 20000000)
|
||||||
|
|
||||||
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
|
||||||
|
cmd = builder.build_iq_capture_command(
|
||||||
|
device=sdr_device,
|
||||||
|
frequency_mhz=center_mhz,
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
gain=float(gain),
|
||||||
|
)
|
||||||
|
|
||||||
|
fft_size = min(int(_state.waterfall_config.get('max_bins') or 1024), 4096)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_state.waterfall_process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Detect immediate startup failures
|
||||||
|
time.sleep(0.35)
|
||||||
|
if _state.waterfall_process.poll() is not None:
|
||||||
|
stderr_text = ''
|
||||||
|
try:
|
||||||
|
if _state.waterfall_process.stderr:
|
||||||
|
stderr_text = _state.waterfall_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||||
|
except Exception:
|
||||||
|
stderr_text = ''
|
||||||
|
msg = stderr_text or f'IQ capture exited early (code {_state.waterfall_process.returncode})'
|
||||||
|
logger.error(f"Waterfall startup failed: {msg}")
|
||||||
|
_queue_waterfall_error(msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not _state.waterfall_process.stdout:
|
||||||
|
_queue_waterfall_error('IQ capture stdout unavailable')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Read IQ samples and compute FFT
|
||||||
|
# CU8 format: interleaved unsigned 8-bit I/Q pairs
|
||||||
|
bytes_per_sample = 2 # 1 byte I + 1 byte Q
|
||||||
|
chunk_bytes = fft_size * bytes_per_sample
|
||||||
|
received_any = False
|
||||||
|
|
||||||
|
while _state.waterfall_running:
|
||||||
|
raw = _state.waterfall_process.stdout.read(chunk_bytes)
|
||||||
|
if not raw or len(raw) < chunk_bytes:
|
||||||
|
if _state.waterfall_process.poll() is not None:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
received_any = True
|
||||||
|
|
||||||
|
# Convert CU8 to complex float: center at 127.5
|
||||||
|
iq = struct.unpack(f'{fft_size * 2}B', raw)
|
||||||
|
# Compute power spectrum via FFT
|
||||||
|
real_parts = [(iq[i * 2] - 127.5) / 127.5 for i in range(fft_size)]
|
||||||
|
imag_parts = [(iq[i * 2 + 1] - 127.5) / 127.5 for i in range(fft_size)]
|
||||||
|
|
||||||
|
bins: list[float] = []
|
||||||
|
try:
|
||||||
|
# Try numpy if available for efficient FFT
|
||||||
|
import numpy as np
|
||||||
|
samples = np.array(real_parts, dtype=np.float32) + 1j * np.array(imag_parts, dtype=np.float32)
|
||||||
|
# Apply Hann window
|
||||||
|
window = np.hanning(fft_size)
|
||||||
|
samples *= window
|
||||||
|
spectrum = np.fft.fftshift(np.fft.fft(samples))
|
||||||
|
power_db = 10.0 * np.log10(np.abs(spectrum) ** 2 + 1e-10)
|
||||||
|
bins = power_db.tolist()
|
||||||
|
except ImportError:
|
||||||
|
# Fallback: compute magnitude without full FFT
|
||||||
|
# Just report raw magnitudes per sample as approximate power
|
||||||
|
for i in range(fft_size):
|
||||||
|
mag = math.sqrt(real_parts[i] ** 2 + imag_parts[i] ** 2)
|
||||||
|
power = 10.0 * math.log10(mag ** 2 + 1e-10)
|
||||||
|
bins.append(power)
|
||||||
|
|
||||||
|
max_bins = int(_state.waterfall_config.get('max_bins') or 0)
|
||||||
|
if max_bins > 0 and len(bins) > max_bins:
|
||||||
|
bins = _downsample_bins(bins, max_bins)
|
||||||
|
|
||||||
|
msg = {
|
||||||
|
'type': 'waterfall_sweep',
|
||||||
|
'start_freq': start_freq,
|
||||||
|
'end_freq': end_freq,
|
||||||
|
'bins': bins,
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
_state.waterfall_queue.put_nowait(msg)
|
||||||
|
except queue.Full:
|
||||||
|
with contextlib.suppress(queue.Empty):
|
||||||
|
_state.waterfall_queue.get_nowait()
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
_state.waterfall_queue.put_nowait(msg)
|
||||||
|
|
||||||
|
# Throttle to respect interval
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
if _state.waterfall_running and not received_any:
|
||||||
|
_queue_waterfall_error(f'No IQ data received from {sdr_type.value}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Waterfall IQ loop error: {e}")
|
||||||
|
_queue_waterfall_error(f"Waterfall loop error: {e}")
|
||||||
|
finally:
|
||||||
|
_state.waterfall_running = False
|
||||||
|
if _state.waterfall_process and _state.waterfall_process.poll() is None:
|
||||||
|
try:
|
||||||
|
_state.waterfall_process.terminate()
|
||||||
|
_state.waterfall_process.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
_state.waterfall_process.kill()
|
||||||
|
_state.waterfall_process = None
|
||||||
|
logger.info("Waterfall IQ loop stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def _waterfall_loop_rtl_power():
|
||||||
|
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
||||||
|
rtl_power_path = find_rtl_power()
|
||||||
|
if not rtl_power_path:
|
||||||
|
logger.error("rtl_power not found for waterfall")
|
||||||
|
_queue_waterfall_error('rtl_power not found')
|
||||||
|
_state.waterfall_running = False
|
||||||
|
return
|
||||||
|
|
||||||
|
start_hz = int(_state.waterfall_config['start_freq'] * 1e6)
|
||||||
|
end_hz = int(_state.waterfall_config['end_freq'] * 1e6)
|
||||||
|
bin_hz = int(_state.waterfall_config['bin_size'])
|
||||||
|
gain = _state.waterfall_config['gain']
|
||||||
|
device = _state.waterfall_config['device']
|
||||||
|
interval = float(_state.waterfall_config.get('interval', 0.4))
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
rtl_power_path,
|
||||||
|
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
|
||||||
|
'-i', str(interval),
|
||||||
|
'-g', str(gain),
|
||||||
|
'-d', str(device),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
_state.waterfall_process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
bufsize=1,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Detect immediate startup failures (e.g. device busy / no device).
|
||||||
|
time.sleep(0.35)
|
||||||
|
if _state.waterfall_process.poll() is not None:
|
||||||
|
stderr_text = ''
|
||||||
|
try:
|
||||||
|
if _state.waterfall_process.stderr:
|
||||||
|
stderr_text = _state.waterfall_process.stderr.read().strip()
|
||||||
|
except Exception:
|
||||||
|
stderr_text = ''
|
||||||
|
msg = stderr_text or f'rtl_power exited early (code {_state.waterfall_process.returncode})'
|
||||||
|
logger.error(f"Waterfall startup failed: {msg}")
|
||||||
|
_queue_waterfall_error(msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
current_ts = None
|
||||||
|
all_bins: list[float] = []
|
||||||
|
sweep_start_hz = start_hz
|
||||||
|
sweep_end_hz = end_hz
|
||||||
|
received_any = False
|
||||||
|
|
||||||
|
if not _state.waterfall_process.stdout:
|
||||||
|
_queue_waterfall_error('rtl_power stdout unavailable')
|
||||||
|
return
|
||||||
|
|
||||||
|
for line in _state.waterfall_process.stdout:
|
||||||
|
if not _state.waterfall_running:
|
||||||
|
break
|
||||||
|
|
||||||
|
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
|
||||||
|
if ts is None or not bins:
|
||||||
|
continue
|
||||||
|
received_any = True
|
||||||
|
|
||||||
|
if current_ts is None:
|
||||||
|
current_ts = ts
|
||||||
|
|
||||||
|
if ts != current_ts and all_bins:
|
||||||
|
max_bins = int(_state.waterfall_config.get('max_bins') or 0)
|
||||||
|
bins_to_send = all_bins
|
||||||
|
if max_bins > 0 and len(bins_to_send) > max_bins:
|
||||||
|
bins_to_send = _downsample_bins(bins_to_send, max_bins)
|
||||||
|
msg = {
|
||||||
|
'type': 'waterfall_sweep',
|
||||||
|
'start_freq': sweep_start_hz / 1e6,
|
||||||
|
'end_freq': sweep_end_hz / 1e6,
|
||||||
|
'bins': bins_to_send,
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
_state.waterfall_queue.put_nowait(msg)
|
||||||
|
except queue.Full:
|
||||||
|
with contextlib.suppress(queue.Empty):
|
||||||
|
_state.waterfall_queue.get_nowait()
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
_state.waterfall_queue.put_nowait(msg)
|
||||||
|
|
||||||
|
all_bins = []
|
||||||
|
sweep_start_hz = start_hz
|
||||||
|
sweep_end_hz = end_hz
|
||||||
|
current_ts = ts
|
||||||
|
|
||||||
|
all_bins.extend(bins)
|
||||||
|
if seg_start is not None:
|
||||||
|
sweep_start_hz = min(sweep_start_hz, seg_start)
|
||||||
|
if seg_end is not None:
|
||||||
|
sweep_end_hz = max(sweep_end_hz, seg_end)
|
||||||
|
|
||||||
|
# Flush any remaining bins
|
||||||
|
if all_bins and _state.waterfall_running:
|
||||||
|
max_bins = int(_state.waterfall_config.get('max_bins') or 0)
|
||||||
|
bins_to_send = all_bins
|
||||||
|
if max_bins > 0 and len(bins_to_send) > max_bins:
|
||||||
|
bins_to_send = _downsample_bins(bins_to_send, max_bins)
|
||||||
|
msg = {
|
||||||
|
'type': 'waterfall_sweep',
|
||||||
|
'start_freq': sweep_start_hz / 1e6,
|
||||||
|
'end_freq': sweep_end_hz / 1e6,
|
||||||
|
'bins': bins_to_send,
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
with contextlib.suppress(queue.Full):
|
||||||
|
_state.waterfall_queue.put_nowait(msg)
|
||||||
|
|
||||||
|
if _state.waterfall_running and not received_any:
|
||||||
|
_queue_waterfall_error('No waterfall FFT data received from rtl_power')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Waterfall loop error: {e}")
|
||||||
|
_queue_waterfall_error(f"Waterfall loop error: {e}")
|
||||||
|
finally:
|
||||||
|
_state.waterfall_running = False
|
||||||
|
if _state.waterfall_process and _state.waterfall_process.poll() is None:
|
||||||
|
try:
|
||||||
|
_state.waterfall_process.terminate()
|
||||||
|
_state.waterfall_process.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
_state.waterfall_process.kill()
|
||||||
|
_state.waterfall_process = None
|
||||||
|
logger.info("Waterfall loop stopped")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# WATERFALL API ENDPOINTS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@receiver_bp.route('/waterfall/start', methods=['POST'])
|
||||||
|
def start_waterfall() -> Response:
|
||||||
|
"""Start the waterfall/spectrogram display."""
|
||||||
|
with _state.waterfall_lock:
|
||||||
|
if _state.waterfall_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'already_running': True,
|
||||||
|
'message': 'Waterfall already running',
|
||||||
|
'config': _state.waterfall_config,
|
||||||
|
})
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Determine SDR type
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
sdr_type_str = sdr_type.value
|
||||||
|
|
||||||
|
# RTL-SDR uses rtl_power; other types use rx_sdr via IQ capture
|
||||||
|
if sdr_type == SDRType.RTL_SDR and not find_rtl_power():
|
||||||
|
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
|
||||||
|
|
||||||
|
try:
|
||||||
|
_state.waterfall_config['start_freq'] = float(data.get('start_freq', 88.0))
|
||||||
|
_state.waterfall_config['end_freq'] = float(data.get('end_freq', 108.0))
|
||||||
|
_state.waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
|
||||||
|
_state.waterfall_config['gain'] = int(data.get('gain', 40))
|
||||||
|
_state.waterfall_config['device'] = int(data.get('device', 0))
|
||||||
|
_state.waterfall_config['sdr_type'] = sdr_type_str
|
||||||
|
if data.get('interval') is not None:
|
||||||
|
interval = float(data.get('interval', _state.waterfall_config['interval']))
|
||||||
|
if interval < 0.1 or interval > 5:
|
||||||
|
return jsonify({'status': 'error', 'message': 'interval must be between 0.1 and 5 seconds'}), 400
|
||||||
|
_state.waterfall_config['interval'] = interval
|
||||||
|
if data.get('max_bins') is not None:
|
||||||
|
max_bins = int(data.get('max_bins', _state.waterfall_config['max_bins']))
|
||||||
|
if max_bins < 64 or max_bins > 4096:
|
||||||
|
return jsonify({'status': 'error', 'message': 'max_bins must be between 64 and 4096'}), 400
|
||||||
|
_state.waterfall_config['max_bins'] = max_bins
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
||||||
|
|
||||||
|
if _state.waterfall_config['start_freq'] >= _state.waterfall_config['end_freq']:
|
||||||
|
return jsonify({'status': 'error', 'message': 'start_freq must be less than end_freq'}), 400
|
||||||
|
|
||||||
|
# Clear stale queue
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
_state.waterfall_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Claim SDR device
|
||||||
|
error = app_module.claim_sdr_device(_state.waterfall_config['device'], 'waterfall', sdr_type_str)
|
||||||
|
if error:
|
||||||
|
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
||||||
|
|
||||||
|
_state.waterfall_active_device = _state.waterfall_config['device']
|
||||||
|
_state.waterfall_active_sdr_type = sdr_type_str
|
||||||
|
_state.waterfall_running = True
|
||||||
|
_state.waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True)
|
||||||
|
_state.waterfall_thread.start()
|
||||||
|
|
||||||
|
return jsonify({'status': 'started', 'config': _state.waterfall_config})
|
||||||
|
|
||||||
|
|
||||||
|
@receiver_bp.route('/waterfall/stop', methods=['POST'])
|
||||||
|
def stop_waterfall() -> Response:
|
||||||
|
"""Stop the waterfall display."""
|
||||||
|
_stop_waterfall_internal()
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@receiver_bp.route('/waterfall/stream')
|
||||||
|
def stream_waterfall() -> Response:
|
||||||
|
"""SSE stream for waterfall data."""
|
||||||
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
|
process_event('waterfall', msg, msg.get('type'))
|
||||||
|
|
||||||
|
response = Response(
|
||||||
|
sse_stream_fanout(
|
||||||
|
source_queue=_state.waterfall_queue,
|
||||||
|
channel_key='receiver_waterfall',
|
||||||
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
|
on_message=_on_msg,
|
||||||
|
),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
)
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
return response
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""Meshcore device routes.
|
||||||
|
|
||||||
|
Endpoints for connecting to Meshcore devices (serial, TCP, BLE),
|
||||||
|
streaming live events, and managing messages, contacts, and nodes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.meshcore import (
|
||||||
|
BLEConfig,
|
||||||
|
MeshcoreContact,
|
||||||
|
SerialConfig,
|
||||||
|
TCPConfig,
|
||||||
|
get_meshcore_client,
|
||||||
|
is_meshcore_available,
|
||||||
|
list_serial_ports,
|
||||||
|
)
|
||||||
|
from utils.responses import api_error
|
||||||
|
|
||||||
|
logger = get_logger("intercept.meshcore")
|
||||||
|
|
||||||
|
meshcore_bp = Blueprint("meshcore", __name__, url_prefix="/meshcore")
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
return get_meshcore_client()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status & connection management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@meshcore_bp.route("/status")
|
||||||
|
def status():
|
||||||
|
if not is_meshcore_available():
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"available": False,
|
||||||
|
"state": "unavailable",
|
||||||
|
"message": "meshcore package not installed. Run: pip install meshcore",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
c = _client()
|
||||||
|
state, message = c.get_state()
|
||||||
|
payload = {"available": True, "state": state.value}
|
||||||
|
if message:
|
||||||
|
payload["message"] = message
|
||||||
|
return jsonify(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@meshcore_bp.route("/connect", methods=["POST"])
|
||||||
|
def connect():
|
||||||
|
if not is_meshcore_available():
|
||||||
|
return api_error("meshcore not installed", 503)
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
transport = data.get("transport", "serial")
|
||||||
|
|
||||||
|
if transport == "serial":
|
||||||
|
config = SerialConfig(port=data.get("port"), baud=int(data.get("baud", 115200)))
|
||||||
|
elif transport == "tcp":
|
||||||
|
host = data.get("host", "localhost")
|
||||||
|
port = int(data.get("port", 5000))
|
||||||
|
config = TCPConfig(host=host, port=port)
|
||||||
|
elif transport == "ble":
|
||||||
|
config = BLEConfig(device_address=data.get("address"))
|
||||||
|
else:
|
||||||
|
return api_error(f"Unknown transport: {transport}", 400)
|
||||||
|
|
||||||
|
_client().connect(config)
|
||||||
|
return jsonify({"status": "connecting", "transport": transport})
|
||||||
|
|
||||||
|
|
||||||
|
@meshcore_bp.route("/disconnect", methods=["POST"])
|
||||||
|
def disconnect():
|
||||||
|
_client().disconnect()
|
||||||
|
return jsonify({"status": "disconnected"})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Discovery
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@meshcore_bp.route("/ports")
|
||||||
|
def ports():
|
||||||
|
return jsonify({"ports": list_serial_ports()})
|
||||||
|
|
||||||
|
|
||||||
|
@meshcore_bp.route("/ble/scan")
|
||||||
|
def ble_scan():
|
||||||
|
if not is_meshcore_available():
|
||||||
|
return api_error("meshcore not installed", 503)
|
||||||
|
devices = _client().scan_ble()
|
||||||
|
return jsonify({"devices": devices})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SSE stream
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@meshcore_bp.route("/stream")
|
||||||
|
def stream():
|
||||||
|
def _gen():
|
||||||
|
q = _client().get_queue()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event = q.get(timeout=30)
|
||||||
|
yield f"data: {json.dumps(event)}\n\n"
|
||||||
|
except queue.Empty:
|
||||||
|
yield ": keepalive\n\n"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
_gen(),
|
||||||
|
mimetype="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Messages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@meshcore_bp.route("/messages")
|
||||||
|
def messages():
|
||||||
|
return jsonify({"messages": _client().get_messages()})
|
||||||
|
|
||||||
|
|
||||||
|
@meshcore_bp.route("/send", methods=["POST"])
|
||||||
|
def send():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
text = data.get("text", "").strip()
|
||||||
|
recipient_id = data.get("recipient_id", "BROADCAST")
|
||||||
|
if not text:
|
||||||
|
return api_error("text is required", 400)
|
||||||
|
if len(text) > 237:
|
||||||
|
return api_error("text exceeds 237-character Meshcore limit", 400)
|
||||||
|
_client().send_text(recipient_id, text)
|
||||||
|
return jsonify({"status": "queued"})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Nodes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@meshcore_bp.route("/nodes")
|
||||||
|
def nodes():
|
||||||
|
return jsonify({"nodes": _client().get_nodes()})
|
||||||
|
|
||||||
|
|
||||||
|
@meshcore_bp.route("/repeaters")
|
||||||
|
def repeaters():
|
||||||
|
return jsonify({"repeaters": _client().get_repeaters()})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Contacts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@meshcore_bp.route("/contacts", methods=["GET"])
|
||||||
|
def list_contacts():
|
||||||
|
return jsonify({"contacts": _client().get_contacts()})
|
||||||
|
|
||||||
|
|
||||||
|
@meshcore_bp.route("/contacts", methods=["POST"])
|
||||||
|
def add_contact():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
node_id = data.get("node_id", "").strip()
|
||||||
|
name = data.get("name", "").strip()
|
||||||
|
public_key = data.get("public_key", "").strip()
|
||||||
|
if not node_id or not name or not public_key:
|
||||||
|
return api_error("node_id, name, and public_key are required", 400)
|
||||||
|
contact = MeshcoreContact(node_id=node_id, name=name, public_key=public_key, last_msg=None)
|
||||||
|
_client().add_contact(contact)
|
||||||
|
return jsonify({"status": "added", "contact": contact.to_dict()})
|
||||||
|
|
||||||
|
|
||||||
|
@meshcore_bp.route("/contacts/<node_id>", methods=["DELETE"])
|
||||||
|
def delete_contact(node_id: str):
|
||||||
|
removed = _client().remove_contact(node_id)
|
||||||
|
if not removed:
|
||||||
|
return api_error("contact not found", 404)
|
||||||
|
return jsonify({"status": "removed"})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Telemetry & traceroute
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@meshcore_bp.route("/telemetry/<node_id>")
|
||||||
|
def telemetry(node_id: str):
|
||||||
|
return jsonify({"node_id": node_id, "telemetry": _client().get_telemetry(node_id)})
|
||||||
|
|
||||||
|
|
||||||
|
@meshcore_bp.route("/traceroute", methods=["POST"])
|
||||||
|
def traceroute():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
node_id = data.get("node_id", "").strip()
|
||||||
|
if not node_id:
|
||||||
|
return api_error("node_id is required", 400)
|
||||||
|
_client().request_traceroute(node_id)
|
||||||
|
return jsonify({"status": "requested", "node_id": node_id})
|
||||||