Compare commits
1064 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1,42 @@
|
||||
## Workflow Orchestration
|
||||
### 1. Plan Node Default
|
||||
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
|
||||
- If something goes sideways, STOP and re-plan immediately - don't keep pushing
|
||||
- Use plan mode for verification steps, not just building
|
||||
- Write detailed specs upfront to reduce ambiguity
|
||||
### 2. Subagent Strategy
|
||||
- Use subagents liberally to keep main context window clean
|
||||
- Offload research, exploration, and parallel analysis to subagents
|
||||
- For complex problems, throw more compute at it via subagents
|
||||
- One tack per subagent for focused execution
|
||||
### 3. Self-Improvement Loop
|
||||
- After ANY correction from the user: update 'tasks/lessons.md" with the pattern
|
||||
- Write rules for yourself that prevent the same mistake
|
||||
- Ruthlessly iterate on these lessons until mistake rate drops
|
||||
- Review lessons at session start for relevant project
|
||||
### 4. Verification Before Done
|
||||
- Never mark a task complete without proving it works
|
||||
- Diff behavior between main and your changes when relevant
|
||||
- Ask yourself: "Would a staff engineer approve this?"
|
||||
- Run tests, check logs, demonstrate correctness
|
||||
### 5. Demand Elegance (Balanced)
|
||||
- For non-trivial changes: pause and ask "is there a more elegant way?"
|
||||
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
|
||||
- Skip this for simple, obvious fixes - don't over-engineer
|
||||
-Challenge your own work before presenting it
|
||||
### 6. Autonomous Bug Fizing
|
||||
- When given a bug report: just fix it. Don't ask for hand-holding
|
||||
- Point at logs, errors, failing tests - then resolve them
|
||||
- Zero context switching required from the user
|
||||
- Go fix failing CI tests without being told how
|
||||
## Task Management
|
||||
1. **Plan First**: Write plan to "tasks/todo.md" with checkable items
|
||||
2. **Verify Plan**: Check in before starting implementation
|
||||
3. **Track Progress**: Mark items complete as you go
|
||||
4. **Explain Changes**: High-level summary at each step
|
||||
5. **Document Results**: Add review section to 'tasks/todo.md"
|
||||
6. **Capture Lessons**: Update 'tasks/lessons.md' after corrections
|
||||
## Core Principles
|
||||
- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
|
||||
- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
|
||||
- **Minimat Impact**: Changes should only touch what's necessary. Avoid introducing bugs.
|
||||
@@ -1,6 +1,8 @@
|
||||
# Git
|
||||
# Git & CI
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.claude
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
@@ -15,6 +17,7 @@ venv/
|
||||
.eggs/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
.uv
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -28,10 +31,31 @@ tests/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.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/*.json
|
||||
data/*.csv
|
||||
data/*.db
|
||||
|
||||
# Build scripts
|
||||
build-multiarch.sh
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Local Postgres data
|
||||
pgdata/
|
||||
pgdata.bak/
|
||||
|
||||
# Captured files (don't include in image)
|
||||
*.cap
|
||||
*.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,26 @@
|
||||
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
|
||||
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
|
||||
run: pytest --tb=short -q
|
||||
continue-on-error: true
|
||||
@@ -14,6 +14,10 @@ uv.lock
|
||||
*.log
|
||||
pager_messages.log
|
||||
|
||||
# Local data
|
||||
downloads/
|
||||
pgdata/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -30,5 +34,36 @@ dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Package manager lock files
|
||||
# Package manager lock files & DB files
|
||||
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.*
|
||||
|
||||
@@ -2,6 +2,509 @@
|
||||
|
||||
All notable changes to iNTERCEPT will be documented in this file.
|
||||
|
||||
## [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
|
||||
|
||||
### 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,197 @@
|
||||
# INTERCEPT - Signal Intelligence Platform
|
||||
# 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
|
||||
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 \
|
||||
# Copy SatDump install artifacts to staging
|
||||
&& cp -a /usr/local/bin/satdump /staging/usr/local/bin/ 2>/dev/null || true \
|
||||
&& cp -a /usr/local/lib/libsatdump* /staging/usr/local/lib/ 2>/dev/null || true \
|
||||
&& cp -a /usr/local/lib/satdump /staging/usr/local/lib/ 2>/dev/null || true \
|
||||
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null; mkdir -p /staging/usr/local/share \
|
||||
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null || true \
|
||||
&& rm -rf /tmp/SatDump
|
||||
|
||||
# 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
|
||||
|
||||
LABEL maintainer="INTERCEPT Project"
|
||||
@@ -9,18 +200,28 @@ LABEL description="Signal Intelligence Platform for SDR monitoring"
|
||||
# Set working directory
|
||||
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 \
|
||||
# RTL-SDR tools
|
||||
rtl-sdr \
|
||||
librtlsdr-dev \
|
||||
libusb-1.0-0-dev \
|
||||
# 433MHz decoder
|
||||
rtl-433 \
|
||||
# Pager decoder
|
||||
multimon-ng \
|
||||
# Audio tools for Listening Post
|
||||
ffmpeg \
|
||||
# SSTV decoder runtime libs
|
||||
libsndfile1 \
|
||||
# SatDump runtime libs (weather satellite decoding)
|
||||
libpng16-16 \
|
||||
libtiff6 \
|
||||
libjemalloc2 \
|
||||
libvolk-bin \
|
||||
libnng1 \
|
||||
libzstd1 \
|
||||
# WiFi tools (aircrack-ng suite)
|
||||
aircrack-ng \
|
||||
iw \
|
||||
@@ -29,19 +230,37 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
bluez \
|
||||
bluetooth \
|
||||
# GPS support
|
||||
gpsd \
|
||||
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
|
||||
curl \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dump1090 for ADS-B (package name varies by distribution)
|
||||
RUN apt-get update && \
|
||||
(apt-get install -y --no-install-recommends dump1090-mutability || \
|
||||
apt-get install -y --no-install-recommends dump1090-fa || \
|
||||
apt-get install -y --no-install-recommends dump1090 || \
|
||||
echo "Note: dump1090 not available in repos, ADS-B features limited") && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# Copy compiled binaries and libraries from builder stage
|
||||
COPY --from=builder /staging/usr/bin/ /usr/bin/
|
||||
COPY --from=builder /staging/usr/local/bin/ /usr/local/bin/
|
||||
COPY --from=builder /staging/usr/local/lib/ /usr/local/lib/
|
||||
COPY --from=builder /staging/opt/ /opt/
|
||||
|
||||
# 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.txt .
|
||||
@@ -50,11 +269,15 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Copy application code
|
||||
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
|
||||
RUN mkdir -p /app/data
|
||||
RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs
|
||||
|
||||
# Expose web interface port
|
||||
EXPOSE 5050
|
||||
EXPOSE 5443
|
||||
|
||||
# Environment variables with defaults
|
||||
ENV INTERCEPT_HOST=0.0.0.0 \
|
||||
@@ -67,4 +290,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -sf http://localhost:5050/health || exit 1
|
||||
|
||||
# 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
|
||||
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:
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
1. Definitions.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"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">
|
||||
<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">
|
||||
</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">
|
||||
<strong>Signal Intelligence Platform</strong><br>
|
||||
A web-based interface for software-defined radio tools.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="static/images/screenshots/logo-banner.png" alt="Screenshot">
|
||||
<img src="static/images/screenshots/intercept-main.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -21,39 +30,232 @@
|
||||
|
||||
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
||||
- **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
|
||||
- **Listening Post** - Frequency scanner with audio monitoring
|
||||
- **Satellite Tracking** - Pass prediction using TLE data
|
||||
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
||||
- **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
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
## 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:**
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo python3 intercept.py
|
||||
```
|
||||
Recommended baseline settings:
|
||||
- **Tone**: `700 Hz`
|
||||
- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals)
|
||||
- **Threshold Mode**: `Auto`
|
||||
- **WPM Mode**: `Auto`
|
||||
|
||||
### 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
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
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
|
||||
|
||||
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 +283,16 @@ Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
|
||||
## Discord Server
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
|
||||
<a href="https://discord.gg/EyeksEJmWE">Join our Discord</a>
|
||||
</p>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [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
|
||||
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
|
||||
- [Security](docs/SECURITY.md) - Network security and best practices
|
||||
@@ -109,7 +313,7 @@ This project was developed using AI as a coding partner, combining human directi
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE)
|
||||
Apache 2.0 License - see [LICENSE](LICENSE)
|
||||
|
||||
## Author
|
||||
|
||||
@@ -121,9 +325,22 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
||||
[multimon-ng](https://github.com/EliasOenal/multimon-ng) |
|
||||
[rtl_433](https://github.com/merbanan/rtl_433) |
|
||||
[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/) |
|
||||
[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 +1,4 @@
|
||||
{
|
||||
"version": "2026-01-04_e27bf619",
|
||||
"downloaded": "2026-01-07T14:55:20.680977Z"
|
||||
"version": "2026-02-22_17194a71",
|
||||
"downloaded": "2026-02-27T10:41:04.872620Z"
|
||||
}
|
||||
@@ -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,7 +7,330 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.9.0"
|
||||
VERSION = "2.26.7"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"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:
|
||||
@@ -52,6 +375,11 @@ PORT = _get_env_int('PORT', 5050)
|
||||
DEBUG = _get_env_bool('DEBUG', False)
|
||||
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_GAIN = _get_env('DEFAULT_GAIN', '40')
|
||||
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
|
||||
@@ -75,12 +403,73 @@ BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
|
||||
# ADS-B settings
|
||||
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
||||
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_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
||||
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
||||
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
||||
|
||||
# Weather satellite settings
|
||||
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.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:
|
||||
"""Configure application logging."""
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Data modules for INTERCEPT
|
||||
from .oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
from .satellites import TLE_SATELLITES
|
||||
from .oui import OUI_DATABASE, get_manufacturer, load_oui_database
|
||||
from .patterns import (
|
||||
AIRTAG_PREFIXES,
|
||||
TILE_PREFIXES,
|
||||
SAMSUNG_TRACKER,
|
||||
DRONE_SSID_PATTERNS,
|
||||
DRONE_OUI_PREFIXES,
|
||||
DRONE_SSID_PATTERNS,
|
||||
SAMSUNG_TRACKER,
|
||||
TILE_PREFIXES,
|
||||
)
|
||||
from .satellites import TLE_SATELLITES
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
|
||||
logger = logging.getLogger('intercept.oui')
|
||||
|
||||
@@ -12,7 +12,7 @@ def load_oui_database() -> dict[str, str] | None:
|
||||
oui_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'oui_database.json')
|
||||
try:
|
||||
if os.path.exists(oui_file):
|
||||
with open(oui_file, 'r') as f:
|
||||
with open(oui_file) as f:
|
||||
data = json.load(f)
|
||||
# Remove comment fields
|
||||
return {k: v for k, v in data.items() if not k.startswith('_')}
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
# TLE data for satellite tracking (updated periodically)
|
||||
# To update: click "Update TLE" in satellite dashboard or SSTV mode
|
||||
# Data source: CelesTrak (celestrak.org)
|
||||
TLE_SATELLITES = {
|
||||
'ISS': ('ISS (ZARYA)',
|
||||
'1 25544U 98067A 24001.00000000 .00000000 00000-0 00000-0 0 0000',
|
||||
'2 25544 51.6400 0.0000 0000000 0.0000 0.0000 15.50000000000000'),
|
||||
'1 25544U 98067A 25029.51432176 .00020818 00000+0 36919-3 0 9991',
|
||||
'2 25544 51.6400 157.5640 0002671 123.5041 236.6291 15.49988902492099'),
|
||||
'NOAA-15': ('NOAA 15',
|
||||
'1 25338U 98030A 25028.84157420 .00000535 00000+0 26168-3 0 9999',
|
||||
'2 25338 98.5676 356.1853 0009968 282.2567 77.7505 14.26225252390049'),
|
||||
'NOAA-18': ('NOAA 18',
|
||||
'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 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
||||
'2 43013 98.7400 0.0000 0001000 0.0000 0.0000 14.19000000000000'),
|
||||
'1 43013U 17073A 25028.83917428 .00000284 00000+0 15698-3 0 9995',
|
||||
'2 43013 98.7104 59.9558 0001165 102.5891 257.5432 14.19571458378899'),
|
||||
'NOAA-21': ('NOAA 21 (JPSS-2)',
|
||||
'1 54234U 22150A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
||||
'2 54234 98.7100 0.0000 0001000 0.0000 0.0000 14.19000000000000'),
|
||||
'1 54234U 22150A 25028.86292604 .00000268 00000+0 14911-3 0 9995',
|
||||
'2 54234 98.7064 59.6648 0001271 88.4689 271.6646 14.19545810114699'),
|
||||
'METEOR-M2': ('METEOR-M 2',
|
||||
'1 40069U 14037A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
||||
'2 40069 98.5400 0.0000 0005000 0.0000 0.0000 14.21000000000000'),
|
||||
'1 40069U 14037A 25028.47802083 .00000099 00000+0 69422-4 0 9990',
|
||||
'2 40069 98.4752 356.8632 0003942 251.7291 108.3489 14.20719440555299'),
|
||||
'METEOR-M2-3': ('METEOR-M2 3',
|
||||
'1 57166U 23091A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
||||
'2 57166 98.7700 0.0000 0002000 0.0000 0.0000 14.23000000000000'),
|
||||
'1 57166U 23091A 25028.81539352 .00000157 00000+0 94432-4 0 9993',
|
||||
'2 57166 98.7690 91.9652 0001790 107.4859 252.6519 14.23646028 77844'),
|
||||
'METEOR-M2-4': ('METEOR-M2 4',
|
||||
'1 59051U 24039A 26061.19281216 .00000032 00000+0 34037-4 0 9998',
|
||||
'2 59051 98.6892 21.9068 0008025 115.2158 244.9852 14.22415711104050'),
|
||||
}
|
||||
|
||||
@@ -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,60 @@
|
||||
# INTERCEPT - Signal Intelligence Platform
|
||||
# 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
|
||||
#
|
||||
# Basic usage (pre-built image from registry):
|
||||
# INTERCEPT_IMAGE=ghcr.io/user/intercept:latest docker compose --profile basic up -d
|
||||
#
|
||||
# With ADS-B history (Postgres):
|
||||
# docker compose --profile history up -d
|
||||
|
||||
services:
|
||||
intercept:
|
||||
# When INTERCEPT_IMAGE is set, use that pre-built image; otherwise build locally
|
||||
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
||||
build: .
|
||||
container_name: intercept
|
||||
ports:
|
||||
- "5050:5050"
|
||||
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
|
||||
# - "5443:5443"
|
||||
# Privileged mode required for USB SDR device access
|
||||
# Alternatively, use device mapping (see below)
|
||||
privileged: true
|
||||
# USB device mapping (alternative to privileged mode)
|
||||
# devices:
|
||||
# - /dev/bus/usb:/dev/bus/usb
|
||||
# USB device mapping for all USB devices
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
volumes:
|
||||
# Persist data directory
|
||||
# Persist decoded images and database across container rebuilds
|
||||
- ./data:/app/data
|
||||
# Optional: mount logs directory
|
||||
# - ./logs:/app/logs
|
||||
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
|
||||
# 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: host
|
||||
restart: unless-stopped
|
||||
@@ -32,6 +65,77 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# ADS-B history with Postgres persistence
|
||||
# Enable with: docker compose --profile history up -d
|
||||
intercept-history:
|
||||
# Same image/build fallback pattern as above
|
||||
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
||||
build: .
|
||||
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:/app/data
|
||||
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}
|
||||
# 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
|
||||
|
||||
# Optional: Add volume for persistent SQLite database
|
||||
# volumes:
|
||||
# intercept-data:
|
||||
|
||||
@@ -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**
|
||||
- **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
|
||||
|
||||
- **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
|
||||
- **Marker clustering** - group nearby aircraft at lower zoom levels
|
||||
- **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
|
||||
- **Audio alerts** - notifications for military and emergency aircraft
|
||||
- **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">
|
||||
</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
|
||||
|
||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||
@@ -75,17 +239,207 @@ Complete feature list for all modules.
|
||||
## Bluetooth 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
|
||||
- **Device classification** - phones, audio, wearables, computers
|
||||
- **Manufacturer lookup** via OUI database
|
||||
- **Manufacturer lookup** via OUI database and Bluetooth Company IDs
|
||||
- **Proximity radar** visualization
|
||||
- **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)
|
||||
|
||||
## 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
|
||||
|
||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||
- **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
|
||||
- **Collapsible sections** - click any header to collapse/expand
|
||||
- **Panel styling** - gradient backgrounds with indicator dots
|
||||
@@ -103,17 +457,58 @@ Complete feature list for all modules.
|
||||
| ? | Open help (when not typing) |
|
||||
| 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
|
||||
|
||||
- **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)
|
||||
- **Audio alerts** with mute toggle
|
||||
- **Message export** to CSV/JSON
|
||||
- **Signal activity meter** and waterfall display
|
||||
- **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
|
||||
- **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**
|
||||
- **Device intelligence** dashboard with tracking
|
||||
- **GPS dongle support** - USB GPS receivers for precise observer location
|
||||
|
||||
@@ -14,7 +14,39 @@ INTERCEPT automatically detects connected devices.
|
||||
|
||||
## 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
|
||||
# Install Homebrew if needed
|
||||
@@ -36,7 +68,7 @@ brew install soapysdr limesuite soapylms7
|
||||
brew install hackrf soapyhackrf
|
||||
```
|
||||
|
||||
### Debian / Ubuntu / Raspberry Pi OS
|
||||
### Manual Install: Debian / Ubuntu / Raspberry Pi OS
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
### Check dependencies
|
||||
@@ -119,11 +271,19 @@ SoapySDRUtil --find
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
This automatically:
|
||||
- Detects your OS
|
||||
- Creates a virtual environment if needed (for PEP 668 systems)
|
||||
- Installs Python dependencies
|
||||
- Checks for required tools
|
||||
The setup wizard automatically:
|
||||
- Detects your OS (macOS, Debian/Ubuntu, DragonOS)
|
||||
- Lets you choose install profiles (Core, Maritime, Weather, Security, Full, Custom)
|
||||
- Creates a virtual environment with system site-packages
|
||||
- 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
|
||||
```bash
|
||||
@@ -139,14 +299,13 @@ pip install -r requirements.txt
|
||||
After installation:
|
||||
|
||||
```bash
|
||||
# Standard
|
||||
sudo python3 intercept.py
|
||||
|
||||
# With virtual environment
|
||||
sudo venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
|
||||
# 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.
|
||||
@@ -183,6 +342,7 @@ Open **http://localhost:5050** in your browser.
|
||||
|---------|---------|
|
||||
| `flask` | Web server |
|
||||
| `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
|
||||
|
||||
- **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
|
||||
- **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 -
|
||||
```
|
||||
|
||||
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
|
||||
export INTERCEPT_HOST=127.0.0.1
|
||||
python intercept.py
|
||||
sudo ./start.sh -H 127.0.0.1
|
||||
```
|
||||
|
||||
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
|
||||
python3 -m venv --system-site-packages venv
|
||||
source venv/bin/activate
|
||||
sudo venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
### "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
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
sudo venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
### 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
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
./setup.sh # Interactive wizard (first run) or menu
|
||||
./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"
|
||||
|
||||
```bash
|
||||
@@ -336,9 +339,7 @@ rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
|
||||
|
||||
Run INTERCEPT with sudo:
|
||||
```bash
|
||||
sudo python3 intercept.py
|
||||
# Or with venv:
|
||||
sudo venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
### 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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
- 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)
|
||||
|
||||
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
|
||||
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
|
||||
- **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
|
||||
5. **View Map** - Aircraft appear on the interactive Leaflet map
|
||||
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
|
||||
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
|
||||
|
||||
The system highlights aircraft transmitting emergency squawks:
|
||||
@@ -79,6 +126,85 @@ The system highlights aircraft transmitting emergency squawks:
|
||||
- **7600** - Radio failure
|
||||
- **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
|
||||
|
||||
1. **Set Location** - Choose location source:
|
||||
@@ -98,6 +224,321 @@ The system highlights aircraft transmitting emergency squawks:
|
||||
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
|
||||
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
|
||||
|
||||
## 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).
|
||||
|
||||
## Configuration
|
||||
|
||||
INTERCEPT can be configured via environment variables:
|
||||
@@ -110,10 +551,28 @@ INTERCEPT can be configured via environment variables:
|
||||
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `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
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
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,824 @@
|
||||
<!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">34</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="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,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,210 @@
|
||||
DMSP 5D-3 F16 (USA 172)
|
||||
1 28054U 03048A 26037.66410905 .00000171 00000+0 11311-3 0 9991
|
||||
2 28054 99.0018 60.5544 0007736 150.6435 318.8272 14.14449870151032
|
||||
METEOSAT-9 (MSG-2)
|
||||
1 28912U 05049B 26037.20698824 .00000122 00000+0 00000+0 0 9990
|
||||
2 28912 9.0646 55.4438 0001292 220.3216 340.7358 1.00280364 5681
|
||||
DMSP 5D-3 F17 (USA 191)
|
||||
1 29522U 06050A 26037.63495522 .00000221 00000+0 13641-3 0 9997
|
||||
2 29522 98.7406 46.8646 0011088 71.3269 288.9107 14.14949568993957
|
||||
FENGYUN 3A
|
||||
1 32958U 08026A 26037.29889977 .00000162 00000+0 97205-4 0 9995
|
||||
2 32958 98.6761 340.6748 0009336 139.4536 220.7337 14.19536323916838
|
||||
GOES 14
|
||||
1 35491U 09033A 26037.59737599 .00000128 00000+0 00000+0 0 9998
|
||||
2 35491 1.3510 84.7861 0001663 279.3774 203.6871 1.00112472 5283
|
||||
DMSP 5D-3 F18 (USA 210)
|
||||
1 35951U 09057A 26037.59574243 .00000344 00000+0 20119-3 0 9997
|
||||
2 35951 98.8912 18.7405 0010014 262.2671 97.7365 14.14814612841124
|
||||
EWS-G2 (GOES 15)
|
||||
1 36411U 10008A 26037.42417604 .00000037 00000+0 00000+0 0 9998
|
||||
2 36411 0.9477 85.6904 0004764 200.6178 64.5237 1.00275731 58322
|
||||
COMS 1
|
||||
1 36744U 10032A 26037.66884865 -.00000343 00000+0 00000+0 0 9998
|
||||
2 36744 4.4730 77.2684 0001088 239.9858 188.4845 1.00274368 49786
|
||||
FENGYUN 3B
|
||||
1 37214U 10059A 26037.62488625 .00000510 00000+0 28715-3 0 9992
|
||||
2 37214 98.9821 82.9728 0021838 194.4193 280.6049 14.14810700788968
|
||||
SUOMI NPP
|
||||
1 37849U 11061A 26037.58885771 .00000151 00000+0 92735-4 0 9993
|
||||
2 37849 98.7835 339.4455 0001677 23.1332 336.9919 14.19534335739918
|
||||
METEOSAT-10 (MSG-3)
|
||||
1 38552U 12035B 26037.34062893 -.00000007 00000+0 00000+0 0 9993
|
||||
2 38552 4.3618 61.5789 0002324 286.1065 271.3938 1.00272839 49549
|
||||
METOP-B
|
||||
1 38771U 12049A 26037.61376690 .00000161 00000+0 93652-4 0 9994
|
||||
2 38771 98.6708 91.6029 0002456 28.4142 331.7169 14.21434029694718
|
||||
INSAT-3D
|
||||
1 39216U 13038B 26037.58021591 -.00000338 00000+0 00000+0 0 9998
|
||||
2 39216 1.5890 84.3012 0001719 220.0673 170.6954 1.00273812 45771
|
||||
FENGYUN 3C
|
||||
1 39260U 13052A 26037.57879946 .00000181 00000+0 11337-3 0 9991
|
||||
2 39260 98.4839 17.5531 0015475 42.6626 317.5748 14.15718213640089
|
||||
METEOR-M 2
|
||||
1 40069U 14037A 26037.57010537 .00000364 00000+0 18579-3 0 9995
|
||||
2 40069 98.4979 18.0359 0006835 60.5067 299.6792 14.21415164600761
|
||||
HIMAWARI-8
|
||||
1 40267U 14060A 26037.58238259 -.00000273 00000+0 00000+0 0 9991
|
||||
2 40267 0.0457 252.0286 0000958 31.3580 203.5957 1.00278490 41450
|
||||
FENGYUN 2G
|
||||
1 40367U 14090A 26037.64556289 -.00000299 00000+0 00000+0 0 9996
|
||||
2 40367 5.3089 74.4184 0001565 198.1345 195.9683 1.00263067 40698
|
||||
METEOSAT-11 (MSG-4)
|
||||
1 40732U 15034A 26037.62779616 .00000065 00000+0 00000+0 0 9990
|
||||
2 40732 2.8728 71.8294 0001180 241.7344 58.8290 1.00268087 5909
|
||||
ELEKTRO-L 2
|
||||
1 41105U 15074A 26037.40900929 -.00000118 00000+0 00000+0 0 9998
|
||||
2 41105 6.3653 72.1489 0003612 229.0998 328.0297 1.00272232 37198
|
||||
INSAT-3DR
|
||||
1 41752U 16054A 26037.65505200 -.00000075 00000+0 00000+0 0 9997
|
||||
2 41752 0.0554 93.8053 0013744 184.8269 167.9427 1.00271627 34504
|
||||
HIMAWARI-9
|
||||
1 41836U 16064A 26037.58238259 -.00000273 00000+0 00000+0 0 9990
|
||||
2 41836 0.0124 137.0088 0001068 210.1850 139.9064 1.00271322 33905
|
||||
GOES 16
|
||||
1 41866U 16071A 26037.60517604 -.00000089 00000+0 00000+0 0 9993
|
||||
2 41866 0.1490 94.1417 0002832 199.6896 316.0413 1.00271854 33798
|
||||
FENGYUN 4A
|
||||
1 41882U 16077A 26037.65041625 -.00000356 00000+0 00000+0 0 9994
|
||||
2 41882 1.9907 81.7886 0006284 132.9819 279.8453 1.00276098 33627
|
||||
CYGFM05
|
||||
1 41884U 16078A 26037.42561482 .00027408 00000+0 46309-3 0 9992
|
||||
2 41884 34.9596 42.6579 0007295 332.2973 27.7361 15.50585086508404
|
||||
CYGFM04
|
||||
1 41885U 16078B 26037.34428483 .00032519 00000+0 49575-3 0 9994
|
||||
2 41885 34.9348 16.2836 0005718 359.2189 0.8525 15.53424088508589
|
||||
CYGFM02
|
||||
1 41886U 16078C 26037.35007768 .00035591 00000+0 50564-3 0 9998
|
||||
2 41886 34.9436 13.7490 0006836 2.8379 357.2383 15.55324468508720
|
||||
CYGFM01
|
||||
1 41887U 16078D 26037.39685921 .00028560 00000+0 47572-3 0 9999
|
||||
2 41887 34.9425 44.8029 0007415 323.1915 36.8298 15.50976884508344
|
||||
CYGFM08
|
||||
1 41888U 16078E 26037.34185185 .00031327 00000+0 49606-3 0 9997
|
||||
2 41888 34.9457 27.4597 0008083 350.5361 9.5208 15.52364941508578
|
||||
CYGFM07
|
||||
1 41890U 16078G 26037.32199955 .00032204 00000+0 49829-3 0 9990
|
||||
2 41890 34.9475 16.2411 0005914 7.0804 353.0002 15.53017084508593
|
||||
CYGFM03
|
||||
1 41891U 16078H 26037.35550653 .00031487 00000+0 48940-3 0 9995
|
||||
2 41891 34.9430 17.9804 0005939 349.1458 10.9136 15.52895386508574
|
||||
FENGYUN 3D
|
||||
1 43010U 17072A 26037.62659924 .00000092 00000+0 65298-4 0 9990
|
||||
2 43010 98.9980 9.7978 0002479 69.6779 290.4663 14.19704535426460
|
||||
NOAA 20 (JPSS-1)
|
||||
1 43013U 17073A 26037.60336371 .00000124 00000+0 79520-4 0 9999
|
||||
2 43013 98.7658 338.3064 0000377 14.6433 345.4754 14.19527655425942
|
||||
GOES 17
|
||||
1 43226U 18022A 26037.60794939 -.00000180 00000+0 00000+0 0 9993
|
||||
2 43226 0.6016 88.1527 0002754 213.0089 324.8756 1.00269924 29115
|
||||
FENGYUN 2H
|
||||
1 43491U 18050A 26037.66161282 -.00000125 00000+0 00000+0 0 9992
|
||||
2 43491 2.6948 80.6967 0002145 171.8276 201.3055 1.00274855 28134
|
||||
METOP-C
|
||||
1 43689U 18087A 26037.63948662 .00000167 00000+0 96262-4 0 9998
|
||||
2 43689 98.6834 99.5280 0001629 143.8933 216.2355 14.21510040376280
|
||||
GEO-KOMPSAT-2A
|
||||
1 43823U 18100A 26037.57995591 .00000000 00000+0 00000+0 0 9996
|
||||
2 43823 0.0152 95.1913 0001141 313.4173 65.1318 1.00271011 26327
|
||||
METEOR-M2 2
|
||||
1 44387U 19038A 26037.58492015 .00000244 00000+0 12531-3 0 9993
|
||||
2 44387 98.9044 23.0180 0002141 55.2566 304.8814 14.24320728342700
|
||||
ARKTIKA-M 1
|
||||
1 47719U 21016A 26035.90384421 -.00000136 00000+0 00000+0 0 9994
|
||||
2 47719 63.1930 76.4940 7230705 269.3476 15.2984 2.00623094 36131
|
||||
FENGYUN 3E
|
||||
1 49008U 21062A 26037.62586080 .00000245 00000+0 13631-3 0 9992
|
||||
2 49008 98.7499 42.4910 0002627 96.2819 263.8657 14.19890127238058
|
||||
GOES 18
|
||||
1 51850U 22021A 26037.59876267 .00000098 00000+0 00000+0 0 9999
|
||||
2 51850 0.0198 91.3546 0000843 290.2366 193.6737 1.00273310 5288
|
||||
NOAA 21 (JPSS-2)
|
||||
1 54234U 22150A 26037.56792604 .00000152 00000+0 92800-4 0 9995
|
||||
2 54234 98.7521 338.1972 0001388 169.8161 190.3044 14.19543641168012
|
||||
METEOSAT-12 (MTG-I1)
|
||||
1 54743U 22170C 26037.62580281 -.00000006 00000+0 00000+0 0 9990
|
||||
2 54743 0.7119 25.1556 0002027 273.4388 63.0828 1.00270670 11667
|
||||
TIANMU-1 03
|
||||
1 55973U 23039A 26037.63298084 .00025307 00000+0 57478-3 0 9994
|
||||
2 55973 97.5143 206.9374 0002852 198.5193 161.5950 15.43014921160671
|
||||
TIANMU-1 04
|
||||
1 55974U 23039B 26037.59957323 .00027172 00000+0 60888-3 0 9999
|
||||
2 55974 97.5075 206.0729 0003605 196.0743 164.0390 15.43399931160675
|
||||
TIANMU-1 05
|
||||
1 55975U 23039C 26037.60840428 .00024975 00000+0 56836-3 0 9995
|
||||
2 55975 97.5122 206.5750 0002421 224.3240 135.7814 15.42959696160653
|
||||
TIANMU-1 06
|
||||
1 55976U 23039D 26037.60004198 .00024821 00000+0 55598-3 0 9996
|
||||
2 55976 97.5133 207.0788 0002810 218.0193 142.0857 15.43432906160673
|
||||
FENGYUN 3G
|
||||
1 56232U 23055A 26037.30935013 .00046475 00000+0 74423-3 0 9993
|
||||
2 56232 49.9940 300.8928 0009962 237.3703 122.6303 15.52544991159665
|
||||
METEOR-M2 3
|
||||
1 57166U 23091A 26037.62090481 .00000022 00000+0 28455-4 0 9999
|
||||
2 57166 98.6282 95.1607 0004003 174.5474 185.5750 14.24034408135931
|
||||
TIANMU-1 07
|
||||
1 57399U 23101A 26037.63242936 .00011510 00000+0 41012-3 0 9991
|
||||
2 57399 97.2786 91.2606 0002747 218.4597 141.6448 15.29074661141694
|
||||
TIANMU-1 08
|
||||
1 57400U 23101B 26037.66743594 .00011474 00000+0 41016-3 0 9996
|
||||
2 57400 97.2774 91.0783 0004440 227.8102 132.2762 15.28966110141699
|
||||
TIANMU-1 09
|
||||
1 57401U 23101C 26037.65072558 .00011360 00000+0 40433-3 0 9997
|
||||
2 57401 97.2732 90.5514 0003773 229.5297 130.5615 15.29113177141698
|
||||
TIANMU-1 10
|
||||
1 57402U 23101D 26037.61974057 .00011836 00000+0 42113-3 0 9994
|
||||
2 57402 97.2810 91.4302 0005461 233.7620 126.3116 15.29106286141685
|
||||
FENGYUN 3F
|
||||
1 57490U 23111A 26037.61228373 .00000135 00000+0 84019-4 0 9997
|
||||
2 57490 98.6988 109.9815 0001494 99.6638 260.4707 14.19912110130332
|
||||
ARKTIKA-M 2
|
||||
1 58584U 23198A 26037.15964049 .00000160 00000+0 00000+0 0 9994
|
||||
2 58584 63.2225 168.8508 6872222 267.8808 18.8364 2.00612776 15698
|
||||
TIANMU-1 11
|
||||
1 58645U 23205A 26037.58628093 .00009545 00000+0 37951-3 0 9999
|
||||
2 58645 97.3574 61.2485 0010997 103.8713 256.3749 15.25445149117601
|
||||
TIANMU-1 12
|
||||
1 58646U 23205B 26037.61705312 .00010066 00000+0 40129-3 0 9995
|
||||
2 58646 97.3561 61.0663 0009308 89.8253 270.4052 15.25355570117590
|
||||
TIANMU-1 13
|
||||
1 58647U 23205C 26037.64894829 .00010029 00000+0 39925-3 0 9992
|
||||
2 58647 97.3589 61.3229 0009456 74.8265 285.4018 15.25403883117592
|
||||
TIANMU-1 14
|
||||
1 58648U 23205D 26037.63305929 .00009719 00000+0 38718-3 0 9993
|
||||
2 58648 97.3523 60.6045 0010314 77.9995 282.2399 15.25381326117592
|
||||
TIANMU-1 19
|
||||
1 58660U 23208A 26037.58812600 .00016491 00000+0 58449-3 0 9991
|
||||
2 58660 97.4377 153.5627 0006125 66.0574 294.1307 15.29155961117352
|
||||
TIANMU-1 20
|
||||
1 58661U 23208B 26037.59661536 .00016638 00000+0 56823-3 0 9990
|
||||
2 58661 97.4315 154.0738 0008420 72.4906 287.7255 15.30347593117439
|
||||
TIANMU-1 21
|
||||
1 58662U 23208C 26037.56944589 .00017161 00000+0 55253-3 0 9998
|
||||
2 58662 97.4367 156.2063 0008160 67.8039 292.4068 15.32247056117540
|
||||
TIANMU-1 22
|
||||
1 58663U 23208D 26037.59847459 .00015396 00000+0 55169-3 0 9994
|
||||
2 58663 97.4371 153.6033 0005010 87.2275 272.9538 15.28818503117364
|
||||
TIANMU-1 15
|
||||
1 58700U 24004A 26037.63062994 .00009739 00000+0 38850-3 0 9991
|
||||
2 58700 97.4651 223.9243 0008449 88.7599 271.4607 15.25356935115862
|
||||
TIANMU-1 16
|
||||
1 58701U 24004B 26037.61474986 .00010691 00000+0 42590-3 0 9993
|
||||
2 58701 97.4590 223.2544 0006831 91.0928 269.1093 15.25387104115863
|
||||
TIANMU-1 17
|
||||
1 58702U 24004C 26037.59783649 .00011079 00000+0 44078-3 0 9994
|
||||
2 58702 97.4624 223.6760 0006020 92.0871 268.1056 15.25425175115852
|
||||
TIANMU-1 18
|
||||
1 58703U 24004D 26037.64767373 .00010786 00000+0 42976-3 0 9996
|
||||
2 58703 97.4642 223.9320 0005432 91.0134 269.1726 15.25387870115860
|
||||
INSAT-3DS
|
||||
1 58990U 24033A 26037.64159978 -.00000153 00000+0 00000+0 0 9998
|
||||
2 58990 0.0277 242.2492 0001855 99.2205 108.3003 1.00271452 45758
|
||||
METEOR-M2 4
|
||||
1 59051U 24039A 26037.62796654 .00000070 00000+0 51194-4 0 9991
|
||||
2 59051 98.6849 358.6843 0006923 178.9165 181.2029 14.22412185100701
|
||||
GOES 19
|
||||
1 60133U 24119A 26037.61098274 -.00000246 00000+0 00000+0 0 9996
|
||||
2 60133 0.0027 288.6290 0001204 74.2636 278.5881 1.00270967 5651
|
||||
FENGYUN 3H
|
||||
1 65815U 25219A 26037.60879211 .00000151 00000+0 91464-4 0 9990
|
||||
2 65815 98.6649 341.0050 0001596 86.5100 273.6260 14.19924132 18857
|
||||
@@ -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
|
||||
|
||||
# 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
|
||||
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]
|
||||
name = "intercept"
|
||||
version = "2.0.0"
|
||||
version = "2.26.7"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
license = {text = "MIT"}
|
||||
license = {text = "Apache-2.0"}
|
||||
authors = [
|
||||
{name = "Intercept Contributors"}
|
||||
]
|
||||
@@ -14,7 +14,7 @@ classifiers = [
|
||||
"Environment :: Web Environment",
|
||||
"Framework :: Flask",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Operating System :: MacOS",
|
||||
"Programming Language :: Python :: 3",
|
||||
@@ -26,9 +26,15 @@ classifiers = [
|
||||
"Topic :: System :: Networking :: Monitoring",
|
||||
]
|
||||
dependencies = [
|
||||
"flask>=2.0.0",
|
||||
"flask>=3.0.0",
|
||||
"skyfield>=1.45",
|
||||
"pyserial>=3.5",
|
||||
"Werkzeug>=3.1.5",
|
||||
"flask-limiter>=2.5.4",
|
||||
"bleak>=0.21.0",
|
||||
"flask-sock",
|
||||
"websocket-client>=1.6.0",
|
||||
"requests>=2.28.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -47,6 +53,16 @@ dev = [
|
||||
"types-flask>=1.1.0",
|
||||
]
|
||||
|
||||
optionals = [
|
||||
"scipy>=1.10.0",
|
||||
"qrcode[pil]>=7.4",
|
||||
"numpy>=1.24.0",
|
||||
"Pillow>=9.0.0",
|
||||
"meshtastic>=2.0.0",
|
||||
"psycopg2-binary>=2.9.9",
|
||||
"scapy>=2.4.5",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
intercept = "intercept:main"
|
||||
|
||||
@@ -77,8 +93,32 @@ ignore = [
|
||||
"B008", # do not perform function calls in argument defaults
|
||||
"B905", # zip without explicit strict
|
||||
"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]
|
||||
known-first-party = ["app", "config", "routes", "utils", "data"]
|
||||
|
||||
|
||||
@@ -1,17 +1,55 @@
|
||||
# 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
|
||||
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)
|
||||
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)
|
||||
pyserial>=3.5
|
||||
|
||||
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
|
||||
meshtastic>=2.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)
|
||||
# pytest>=7.0.0
|
||||
# pytest-cov>=4.0.0
|
||||
# ruff>=0.1.0
|
||||
# black>=23.0.0
|
||||
# mypy>=1.0.0
|
||||
flask-sock
|
||||
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
||||
flask-sock
|
||||
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,101 @@
|
||||
# Routes package - registers all blueprints with the Flask app
|
||||
|
||||
|
||||
def register_blueprints(app):
|
||||
"""Register all route blueprints with the Flask app."""
|
||||
from .pager import pager_bp
|
||||
from .sensor import sensor_bp
|
||||
from .wifi import wifi_bp
|
||||
from .bluetooth import bluetooth_bp
|
||||
# Import CSRF to exempt API blueprints (they use JSON, not form tokens)
|
||||
try:
|
||||
from app import csrf as _csrf
|
||||
except ImportError:
|
||||
_csrf = None
|
||||
from .acars import acars_bp
|
||||
from .adsb import adsb_bp
|
||||
from .satellite import satellite_bp
|
||||
from .gps import gps_bp
|
||||
from .settings import settings_bp
|
||||
from .ais import ais_bp
|
||||
from .alerts import alerts_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 .listening_post import listening_post_bp
|
||||
from .dsc import dsc_bp
|
||||
from .gps import gps_bp
|
||||
from .listening_post import receiver_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(sensor_bp)
|
||||
app.register_blueprint(rtlamr_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_v2_bp) # New unified Bluetooth API
|
||||
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(gps_bp)
|
||||
app.register_blueprint(settings_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(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
|
||||
|
||||
# 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,546 @@
|
||||
"""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 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
|
||||
|
||||
cmd = builder.build_ais_command(
|
||||
device=sdr_device,
|
||||
gain=float(gain),
|
||||
bias_t=bias_t,
|
||||
tcp_port=tcp_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('/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,
|
||||
embedded=embedded,
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Alerting API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
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 format_sse
|
||||
|
||||
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()
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
for event in manager.stream_events(timeout=1.0):
|
||||
yield format_sse(event)
|
||||
|
||||
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
|
||||
@@ -1,10 +1,12 @@
|
||||
"""WebSocket-based audio streaming for SDR."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import shutil
|
||||
import json
|
||||
|
||||
from flask import Flask
|
||||
|
||||
# Try to import flask-sock
|
||||
@@ -15,6 +17,8 @@ except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
Sock = None
|
||||
|
||||
import contextlib
|
||||
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.audio_ws')
|
||||
@@ -36,11 +40,17 @@ def find_rtl_fm():
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_ffmpeg():
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
def kill_audio_processes():
|
||||
def find_ffmpeg():
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
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."""
|
||||
global audio_process, rtl_process
|
||||
|
||||
@@ -49,10 +59,8 @@ def kill_audio_processes():
|
||||
audio_process.terminate()
|
||||
audio_process.wait(timeout=0.5)
|
||||
except:
|
||||
try:
|
||||
with contextlib.suppress(BaseException):
|
||||
audio_process.kill()
|
||||
except:
|
||||
pass
|
||||
audio_process = None
|
||||
|
||||
if rtl_process:
|
||||
@@ -60,18 +68,10 @@ def kill_audio_processes():
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=0.5)
|
||||
except:
|
||||
try:
|
||||
with contextlib.suppress(BaseException):
|
||||
rtl_process.kill()
|
||||
except:
|
||||
pass
|
||||
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)
|
||||
|
||||
|
||||
@@ -109,14 +109,14 @@ def start_audio_stream(config):
|
||||
|
||||
freq_hz = int(freq * 1e6)
|
||||
|
||||
rtl_cmd = [
|
||||
rtl_fm,
|
||||
'-M', mod,
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
rtl_cmd = [
|
||||
rtl_fm,
|
||||
'-M', _rtl_fm_demod_mode(mod),
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
'-l', str(squelch),
|
||||
]
|
||||
|
||||
@@ -229,7 +229,11 @@ def init_audio_websocket(app: Flask):
|
||||
except TimeoutError:
|
||||
pass
|
||||
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}")
|
||||
|
||||
# Stream audio data if active
|
||||
@@ -253,4 +257,13 @@ def init_audio_websocket(app: Flask):
|
||||
finally:
|
||||
with process_lock:
|
||||
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")
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import contextlib
|
||||
import os
|
||||
import platform
|
||||
import pty
|
||||
@@ -13,31 +12,42 @@ import select
|
||||
import subprocess
|
||||
import threading
|
||||
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
|
||||
from utils.dependencies import check_tool
|
||||
from utils.logging import bluetooth_logger as logger
|
||||
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 data.oui import OUI_DATABASE, get_manufacturer, load_oui_database
|
||||
from data.patterns import AIRTAG_PREFIXES, SAMSUNG_TRACKER, TILE_PREFIXES
|
||||
from utils.constants import (
|
||||
BT_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
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')
|
||||
|
||||
# --- 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):
|
||||
"""Classify Bluetooth device type based on available info."""
|
||||
@@ -309,10 +319,8 @@ def stream_bt_scan(process, scan_mode):
|
||||
except OSError:
|
||||
break
|
||||
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
app_module.bt_queue.put({'type': 'error', 'text': str(e)})
|
||||
@@ -330,8 +338,8 @@ def reload_oui_database_route():
|
||||
if new_db:
|
||||
OUI_DATABASE.clear()
|
||||
OUI_DATABASE.update(new_db)
|
||||
return jsonify({'status': 'success', 'entries': len(OUI_DATABASE)})
|
||||
return jsonify({'status': 'error', 'message': 'Could not load oui_database.json'})
|
||||
return api_success(data={'entries': len(OUI_DATABASE)})
|
||||
return api_error('Could not load oui_database.json')
|
||||
|
||||
|
||||
@bluetooth_bp.route('/interfaces')
|
||||
@@ -358,7 +366,7 @@ def start_bt_scan():
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
if app_module.bt_process.poll() is None:
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
||||
return api_error('Scan already running')
|
||||
else:
|
||||
app_module.bt_process = None
|
||||
|
||||
@@ -370,7 +378,7 @@ def start_bt_scan():
|
||||
try:
|
||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||
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_devices = {}
|
||||
@@ -412,14 +420,14 @@ def start_bt_scan():
|
||||
os.write(master_fd, b'scan on\n')
|
||||
|
||||
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)
|
||||
|
||||
if app_module.bt_process.poll() is not None:
|
||||
stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip()
|
||||
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.daemon = True
|
||||
@@ -429,9 +437,9 @@ def start_bt_scan():
|
||||
return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface})
|
||||
|
||||
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:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@bluetooth_bp.route('/scan/stop', methods=['POST'])
|
||||
@@ -458,7 +466,7 @@ def reset_bt_adapter():
|
||||
try:
|
||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
@@ -466,10 +474,8 @@ def reset_bt_adapter():
|
||||
app_module.bt_process.terminate()
|
||||
app_module.bt_process.wait(timeout=2)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
app_module.bt_process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
app_module.bt_process = None
|
||||
|
||||
try:
|
||||
@@ -488,12 +494,12 @@ def reset_bt_adapter():
|
||||
|
||||
return jsonify({
|
||||
'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
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@bluetooth_bp.route('/enum', methods=['POST'])
|
||||
@@ -503,7 +509,7 @@ def enum_bt_services():
|
||||
target_mac = data.get('mac')
|
||||
|
||||
if not target_mac:
|
||||
return jsonify({'status': 'error', 'message': 'Target MAC required'})
|
||||
return api_error('Target MAC required')
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -528,18 +534,17 @@ def enum_bt_services():
|
||||
|
||||
app_module.bt_services[target_mac] = services
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'mac': target_mac,
|
||||
'services': services
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'error', 'message': 'Connection timed out'})
|
||||
return api_error('Connection timed out')
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'sdptool not found'})
|
||||
return api_error('sdptool not found')
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@bluetooth_bp.route('/devices')
|
||||
@@ -555,22 +560,19 @@ def get_bt_devices():
|
||||
@bluetooth_bp.route('/stream')
|
||||
def stream_bt():
|
||||
"""SSE stream for Bluetooth events."""
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('bluetooth', msg, msg.get('type'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.bt_queue.get(timeout=1)
|
||||
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
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.bt_queue,
|
||||
channel_key='bluetooth',
|
||||
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'
|
||||
|
||||
@@ -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,884 @@
|
||||
"""
|
||||
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')
|
||||
|
||||
# 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 = create_client_from_agent(agent)
|
||||
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 = create_client_from_agent(agent)
|
||||
|
||||
# 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.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
|
||||
|
||||
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
|
||||
return render_template('network_monitor.html')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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 flask import Blueprint, jsonify, request, Response
|
||||
from flask import Blueprint, Response, request
|
||||
|
||||
import app as app_module
|
||||
from utils.correlation import get_correlations
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error, api_success
|
||||
|
||||
logger = get_logger('intercept.correlation')
|
||||
|
||||
@@ -39,18 +40,14 @@ def get_device_correlations() -> Response:
|
||||
include_historical=include_historical
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
return api_success(data={
|
||||
'correlations': correlations,
|
||||
'wifi_count': len(wifi_devices),
|
||||
'bt_count': len(bt_devices)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating correlations: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@correlation_bp.route('/analyze', methods=['POST'])
|
||||
@@ -67,10 +64,7 @@ def analyze_correlation() -> Response:
|
||||
bt_mac = data.get('bt_mac')
|
||||
|
||||
if not wifi_mac or not bt_mac:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'wifi_mac and bt_mac are required'
|
||||
}), 400
|
||||
return api_error('wifi_mac and bt_mac are required', 400)
|
||||
|
||||
try:
|
||||
# Get device data
|
||||
@@ -81,16 +75,10 @@ def analyze_correlation() -> Response:
|
||||
bt_device = app_module.bt_devices.get(bt_mac)
|
||||
|
||||
if not wifi_device:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'WiFi device {wifi_mac} not found'
|
||||
}), 404
|
||||
return api_error(f'WiFi device {wifi_mac} not found', 404)
|
||||
|
||||
if not bt_device:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Bluetooth device {bt_mac} not found'
|
||||
}), 404
|
||||
return api_error(f'Bluetooth device {bt_mac} not found', 404)
|
||||
|
||||
# Calculate correlation for this specific pair
|
||||
correlations = get_correlations(
|
||||
@@ -101,19 +89,9 @@ def analyze_correlation() -> Response:
|
||||
)
|
||||
|
||||
if correlations:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'correlation': correlations[0]
|
||||
})
|
||||
return api_success(data={'correlation': correlations[0]})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'correlation': None,
|
||||
'message': 'No correlation detected between these devices'
|
||||
})
|
||||
return api_success(data={'correlation': None}, message='No correlation detected between these devices')
|
||||
except Exception as e:
|
||||
logger.error(f"Error analyzing correlation: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
@@ -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
|
||||
|
||||
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 (
|
||||
get_gps_reader,
|
||||
start_gpsd,
|
||||
stop_gps,
|
||||
get_current_position,
|
||||
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')
|
||||
|
||||
@@ -29,12 +32,24 @@ _gps_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
def _position_callback(position: GPSPosition) -> None:
|
||||
"""Callback to queue position updates for SSE stream."""
|
||||
try:
|
||||
_gps_queue.put_nowait(position.to_dict())
|
||||
_gps_queue.put_nowait({'type': 'position', **position.to_dict()})
|
||||
except queue.Full:
|
||||
# Discard oldest if queue is full
|
||||
try:
|
||||
_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:
|
||||
pass
|
||||
|
||||
@@ -45,36 +60,47 @@ def auto_connect_gps():
|
||||
Automatically connect to gpsd if available.
|
||||
|
||||
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.
|
||||
"""
|
||||
import socket
|
||||
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
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
|
||||
sky = reader.sky
|
||||
return jsonify({
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
'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'
|
||||
port = 2947
|
||||
|
||||
# First check if gpsd is reachable
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(1.0)
|
||||
sock.connect((host, port))
|
||||
sock.close()
|
||||
except Exception:
|
||||
return jsonify({
|
||||
'status': 'unavailable',
|
||||
'message': 'gpsd not running'
|
||||
})
|
||||
# If gpsd isn't running, try to detect a device and start it
|
||||
if not is_gpsd_running(host, port):
|
||||
devices = detect_gps_devices()
|
||||
if not devices:
|
||||
return jsonify({
|
||||
'status': 'unavailable',
|
||||
'message': 'No GPS device detected'
|
||||
})
|
||||
|
||||
# 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
|
||||
while not _gps_queue.empty():
|
||||
@@ -84,14 +110,17 @@ def auto_connect_gps():
|
||||
break
|
||||
|
||||
# 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:
|
||||
return jsonify({
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
'has_fix': False,
|
||||
'position': None
|
||||
'position': None,
|
||||
'sky': None,
|
||||
})
|
||||
else:
|
||||
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'])
|
||||
def stop_gps_reader():
|
||||
"""Stop GPS client."""
|
||||
"""Stop GPS client and gpsd daemon if we started it."""
|
||||
reader = get_gps_reader()
|
||||
if reader:
|
||||
reader.remove_callback(_position_callback)
|
||||
reader.remove_sky_callback(_sky_callback)
|
||||
|
||||
stop_gps()
|
||||
stop_gpsd_daemon()
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
@@ -122,15 +163,18 @@ def get_gps_status():
|
||||
'running': False,
|
||||
'device': None,
|
||||
'position': None,
|
||||
'sky': None,
|
||||
'error': None,
|
||||
'message': 'GPS client not started'
|
||||
})
|
||||
|
||||
position = reader.position
|
||||
sky = reader.sky
|
||||
return jsonify({
|
||||
'running': reader.is_running,
|
||||
'device': reader.device_path,
|
||||
'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,
|
||||
'error': reader.error,
|
||||
'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')
|
||||
def debug_gps():
|
||||
"""Debug endpoint showing GPS client state."""
|
||||
@gps_bp.route('/satellites')
|
||||
def get_satellites():
|
||||
"""Get current satellite sky view data."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader:
|
||||
if not reader or not reader.is_running:
|
||||
return jsonify({
|
||||
'reader': None,
|
||||
'message': 'No GPS client initialized'
|
||||
'status': 'waiting',
|
||||
'running': False,
|
||||
'message': 'GPS client not running'
|
||||
})
|
||||
|
||||
position = reader.position
|
||||
return jsonify({
|
||||
'running': reader.is_running,
|
||||
'source': 'gpsd',
|
||||
'device': reader.device_path,
|
||||
'host': reader.host,
|
||||
'port': reader.port,
|
||||
'has_position': position is not None,
|
||||
'position': position.to_dict() if position else None,
|
||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
||||
'error': reader.error,
|
||||
'callbacks_registered': len(reader._callbacks),
|
||||
})
|
||||
sky = reader.sky
|
||||
if sky:
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'sky': sky.to_dict()
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'waiting',
|
||||
'message': 'Waiting for satellite data'
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/stream')
|
||||
def stream_gps():
|
||||
"""SSE stream of GPS position updates."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
position = _gps_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
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')
|
||||
"""SSE stream of GPS position and sky updates."""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_gps_queue,
|
||||
channel_key='gps',
|
||||
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'
|
||||
|
||||
@@ -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,599 @@
|
||||
"""WebSocket-based meteor scatter monitoring with waterfall display and ping detection.
|
||||
|
||||
Provides:
|
||||
- WebSocket at /ws/meteor for binary waterfall frames (reuses waterfall_fft pipeline)
|
||||
- SSE at /meteor/stream for detection events and stats
|
||||
- REST endpoints for status, events, and export
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Flask, Response, jsonify, request
|
||||
|
||||
from utils.responses import api_error
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
Sock = None
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.meteor_detector import MeteorDetector
|
||||
from utils.process import register_process, safe_terminate, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sdr.base import SDRCapabilities, SDRDevice
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.validation import validate_device_index, validate_frequency, validate_gain
|
||||
from utils.waterfall_fft import (
|
||||
build_binary_frame,
|
||||
compute_power_spectrum,
|
||||
cu8_to_complex,
|
||||
quantize_to_uint8,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.meteor')
|
||||
|
||||
# Module-level shared state
|
||||
_state_lock = threading.Lock()
|
||||
_state: dict[str, Any] = {
|
||||
'running': False,
|
||||
'device': None,
|
||||
'frequency_mhz': 0.0,
|
||||
'sample_rate': 0,
|
||||
}
|
||||
_detector: MeteorDetector | None = None
|
||||
_sse_queue: queue.Queue = queue.Queue(maxsize=500)
|
||||
|
||||
# Maximum bandwidth per SDR type (Hz)
|
||||
MAX_BANDWIDTH = {
|
||||
SDRType.RTL_SDR: 2400000,
|
||||
SDRType.HACKRF: 20000000,
|
||||
SDRType.LIME_SDR: 20000000,
|
||||
SDRType.AIRSPY: 10000000,
|
||||
SDRType.SDRPLAY: 2000000,
|
||||
}
|
||||
|
||||
|
||||
def _push_sse(data: dict[str, Any]) -> None:
|
||||
"""Push a message to the SSE queue, dropping oldest if full."""
|
||||
try:
|
||||
_sse_queue.put_nowait(data)
|
||||
except queue.Full:
|
||||
try:
|
||||
_sse_queue.get_nowait()
|
||||
_sse_queue.put_nowait(data)
|
||||
except (queue.Empty, queue.Full):
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
||||
mapping = {
|
||||
'rtlsdr': SDRType.RTL_SDR,
|
||||
'rtl_sdr': SDRType.RTL_SDR,
|
||||
'hackrf': SDRType.HACKRF,
|
||||
'limesdr': SDRType.LIME_SDR,
|
||||
'airspy': SDRType.AIRSPY,
|
||||
'sdrplay': SDRType.SDRPLAY,
|
||||
}
|
||||
return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR)
|
||||
|
||||
|
||||
def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice:
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
caps = builder.get_capabilities()
|
||||
return SDRDevice(
|
||||
sdr_type=sdr_type,
|
||||
index=device_index,
|
||||
name=f'{sdr_type.value}-{device_index}',
|
||||
serial='N/A',
|
||||
driver=sdr_type.value,
|
||||
capabilities=caps,
|
||||
)
|
||||
|
||||
|
||||
def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) -> int:
|
||||
valid_rates = sorted({int(r) for r in caps.sample_rates if int(r) > 0})
|
||||
if valid_rates:
|
||||
return min(valid_rates, key=lambda rate: abs(rate - span_hz))
|
||||
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
|
||||
return max(62500, min(span_hz, max_bw))
|
||||
|
||||
|
||||
# ── Blueprint for REST/SSE endpoints ──
|
||||
|
||||
meteor_bp = Blueprint('meteor', __name__, url_prefix='/meteor')
|
||||
|
||||
|
||||
@meteor_bp.route('/status')
|
||||
def meteor_status():
|
||||
"""Return current meteor monitoring status."""
|
||||
with _state_lock:
|
||||
running = _state['running']
|
||||
freq = _state['frequency_mhz']
|
||||
device = _state['device']
|
||||
sr = _state['sample_rate']
|
||||
|
||||
detector = _detector
|
||||
stats = None
|
||||
if detector:
|
||||
stats = detector._build_stats(time.time())
|
||||
|
||||
return jsonify({
|
||||
'running': running,
|
||||
'frequency_mhz': freq,
|
||||
'device': device,
|
||||
'sample_rate': sr,
|
||||
'stats': stats,
|
||||
})
|
||||
|
||||
|
||||
@meteor_bp.route('/stream')
|
||||
def meteor_stream():
|
||||
"""SSE endpoint for meteor detection events and stats."""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_sse_queue,
|
||||
channel_key='meteor',
|
||||
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
|
||||
|
||||
|
||||
@meteor_bp.route('/events')
|
||||
def meteor_events():
|
||||
"""Return detected events as JSON."""
|
||||
detector = _detector
|
||||
if not detector:
|
||||
return jsonify({'events': []})
|
||||
limit = request.args.get('limit', 500, type=int)
|
||||
return jsonify({'events': detector.get_events(limit=limit)})
|
||||
|
||||
|
||||
@meteor_bp.route('/events/export')
|
||||
def meteor_events_export():
|
||||
"""Export events as CSV or JSON."""
|
||||
detector = _detector
|
||||
if not detector:
|
||||
return api_error('No active session', 400)
|
||||
|
||||
fmt = request.args.get('format', 'json').lower()
|
||||
if fmt == 'csv':
|
||||
csv_data = detector.export_events_csv()
|
||||
return Response(
|
||||
csv_data,
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment; filename=meteor_events.csv'},
|
||||
)
|
||||
else:
|
||||
json_data = detector.export_events_json()
|
||||
return Response(
|
||||
json_data,
|
||||
mimetype='application/json',
|
||||
headers={'Content-Disposition': 'attachment; filename=meteor_events.json'},
|
||||
)
|
||||
|
||||
|
||||
@meteor_bp.route('/events/clear', methods=['POST'])
|
||||
def meteor_events_clear():
|
||||
"""Clear all detected events."""
|
||||
detector = _detector
|
||||
if not detector:
|
||||
return jsonify({'cleared': 0})
|
||||
count = detector.clear_events()
|
||||
return jsonify({'cleared': count})
|
||||
|
||||
|
||||
# ── WebSocket handler ──
|
||||
|
||||
def init_meteor_websocket(app: Flask):
|
||||
"""Initialize WebSocket meteor scatter streaming."""
|
||||
global _detector
|
||||
|
||||
if not WEBSOCKET_AVAILABLE:
|
||||
logger.warning("flask-sock not installed, WebSocket meteor disabled")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/meteor')
|
||||
def meteor_stream_ws(ws):
|
||||
"""WebSocket endpoint for meteor scatter waterfall + detection."""
|
||||
global _detector
|
||||
logger.info("WebSocket meteor client connected")
|
||||
|
||||
import app as app_module
|
||||
|
||||
iq_process = None
|
||||
reader_thread = None
|
||||
stop_event = threading.Event()
|
||||
claimed_device = None
|
||||
claimed_sdr_type = 'rtlsdr'
|
||||
send_queue: queue.Queue = queue.Queue(maxsize=120)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Drain send queue
|
||||
while True:
|
||||
try:
|
||||
outgoing = send_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
try:
|
||||
ws.send(outgoing)
|
||||
except Exception:
|
||||
stop_event.set()
|
||||
break
|
||||
|
||||
try:
|
||||
msg = ws.receive(timeout=0.01)
|
||||
except Exception as e:
|
||||
err = str(e).lower()
|
||||
if "closed" in err:
|
||||
break
|
||||
if "timed out" not in err:
|
||||
logger.error(f"WebSocket receive error: {e}")
|
||||
continue
|
||||
|
||||
if msg is None:
|
||||
if not ws.connected:
|
||||
break
|
||||
if stop_event.is_set():
|
||||
break
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
cmd = data.get('cmd')
|
||||
|
||||
if cmd == 'start':
|
||||
# Stop any existing capture
|
||||
was_restarting = iq_process is not None
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||
claimed_device = None
|
||||
with _state_lock:
|
||||
_state['running'] = False
|
||||
stop_event.clear()
|
||||
while not send_queue.empty():
|
||||
try:
|
||||
send_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
if was_restarting:
|
||||
time.sleep(0.5)
|
||||
|
||||
# Parse config
|
||||
try:
|
||||
frequency_mhz = float(data.get('frequency_mhz', 143.05))
|
||||
validate_frequency(frequency_mhz)
|
||||
gain_raw = data.get('gain')
|
||||
if gain_raw is None or str(gain_raw).lower() == 'auto':
|
||||
gain = None
|
||||
else:
|
||||
gain = validate_gain(float(gain_raw))
|
||||
device_index = validate_device_index(int(data.get('device', 0)))
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
sample_rate_req = int(data.get('sample_rate', 250000))
|
||||
fft_size = int(data.get('fft_size', 1024))
|
||||
fps = int(data.get('fps', 20))
|
||||
avg_count = int(data.get('avg_count', 4))
|
||||
ppm = data.get('ppm')
|
||||
if ppm is not None:
|
||||
ppm = int(ppm)
|
||||
bias_t = bool(data.get('bias_t', False))
|
||||
|
||||
# Detection settings
|
||||
snr_threshold = float(data.get('snr_threshold', 6.0))
|
||||
min_duration = float(data.get('min_duration_ms', 50.0))
|
||||
cooldown = float(data.get('cooldown_ms', 200.0))
|
||||
freq_drift = float(data.get('freq_drift_tolerance_hz', 500.0))
|
||||
except (TypeError, ValueError) as exc:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Invalid configuration: {exc}',
|
||||
}))
|
||||
continue
|
||||
|
||||
# Clamp values
|
||||
fft_size = max(256, min(4096, fft_size))
|
||||
fps = max(5, min(30, fps))
|
||||
avg_count = max(1, min(16, avg_count))
|
||||
|
||||
# Resolve SDR type and sample rate
|
||||
sdr_type = _resolve_sdr_type(sdr_type_str)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
caps = builder.get_capabilities()
|
||||
sample_rate = _pick_sample_rate(sample_rate_req, caps, sdr_type)
|
||||
|
||||
# Compute frequency range
|
||||
span_mhz = sample_rate / 1e6
|
||||
start_freq = frequency_mhz - span_mhz / 2
|
||||
end_freq = frequency_mhz + span_mhz / 2
|
||||
|
||||
# Claim SDR device
|
||||
max_claim_attempts = 4 if was_restarting else 1
|
||||
claim_err = None
|
||||
for _attempt in range(max_claim_attempts):
|
||||
claim_err = app_module.claim_sdr_device(device_index, 'meteor', sdr_type_str)
|
||||
if not claim_err:
|
||||
break
|
||||
if _attempt < max_claim_attempts - 1:
|
||||
time.sleep(0.4)
|
||||
if claim_err:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': claim_err,
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
}))
|
||||
continue
|
||||
claimed_device = device_index
|
||||
claimed_sdr_type = sdr_type_str
|
||||
|
||||
# Build I/Q capture command
|
||||
try:
|
||||
device = _build_dummy_device(device_index, sdr_type)
|
||||
iq_cmd = builder.build_iq_capture_command(
|
||||
device=device,
|
||||
frequency_mhz=frequency_mhz,
|
||||
sample_rate=sample_rate,
|
||||
gain=gain,
|
||||
ppm=ppm,
|
||||
bias_t=bias_t,
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({'status': 'error', 'message': str(e)}))
|
||||
continue
|
||||
|
||||
# Check binary exists
|
||||
if not shutil.which(iq_cmd[0]):
|
||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Required tool "{iq_cmd[0]}" not found.',
|
||||
}))
|
||||
continue
|
||||
|
||||
# Spawn I/Q capture
|
||||
max_attempts = 3 if was_restarting else 1
|
||||
try:
|
||||
for attempt in range(max_attempts):
|
||||
logger.info(
|
||||
f"Starting meteor I/Q capture: {frequency_mhz:.6f} MHz, "
|
||||
f"sr={sample_rate}, fft={fft_size}"
|
||||
)
|
||||
iq_process = subprocess.Popen(
|
||||
iq_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=0,
|
||||
)
|
||||
register_process(iq_process)
|
||||
|
||||
time.sleep(0.3)
|
||||
if iq_process.poll() is not None:
|
||||
stderr_out = ''
|
||||
if iq_process.stderr:
|
||||
with suppress(Exception):
|
||||
stderr_out = iq_process.stderr.read().decode('utf-8', errors='replace').strip()
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if attempt < max_attempts - 1:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
detail = f": {stderr_out}" if stderr_out else ""
|
||||
raise RuntimeError(f"I/Q process exited immediately{detail}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start meteor I/Q capture: {e}")
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
app_module.release_sdr_device(device_index, sdr_type_str)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Failed to start I/Q capture: {e}',
|
||||
}))
|
||||
continue
|
||||
|
||||
# Initialize detector
|
||||
_detector = MeteorDetector(
|
||||
snr_threshold_db=snr_threshold,
|
||||
min_duration_ms=min_duration,
|
||||
cooldown_ms=cooldown,
|
||||
freq_drift_tolerance_hz=freq_drift,
|
||||
)
|
||||
|
||||
with _state_lock:
|
||||
_state['running'] = True
|
||||
_state['device'] = device_index
|
||||
_state['frequency_mhz'] = frequency_mhz
|
||||
_state['sample_rate'] = sample_rate
|
||||
|
||||
# Send confirmation
|
||||
ws.send(json.dumps({
|
||||
'status': 'started',
|
||||
'frequency_mhz': frequency_mhz,
|
||||
'start_freq': start_freq,
|
||||
'end_freq': end_freq,
|
||||
'fft_size': fft_size,
|
||||
'sample_rate': sample_rate,
|
||||
'span_mhz': span_mhz,
|
||||
}))
|
||||
|
||||
# Start FFT reader + detection thread
|
||||
def fft_reader(
|
||||
proc, _send_q, stop_evt, detector,
|
||||
_fft_size, _avg_count, _fps, _sample_rate,
|
||||
_start_freq, _end_freq, _freq_mhz,
|
||||
):
|
||||
required_fft_samples = _fft_size * _avg_count
|
||||
timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps)))
|
||||
bytes_per_frame = timeslice_samples * 2
|
||||
frame_interval = 1.0 / _fps
|
||||
start_freq_hz = _start_freq * 1e6
|
||||
end_freq_hz = _end_freq * 1e6
|
||||
last_stats_push = 0.0
|
||||
|
||||
try:
|
||||
while not stop_evt.is_set():
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
|
||||
frame_start = time.monotonic()
|
||||
|
||||
# Read raw I/Q
|
||||
raw = b''
|
||||
remaining = bytes_per_frame
|
||||
while remaining > 0 and not stop_evt.is_set():
|
||||
chunk = proc.stdout.read(min(remaining, 65536))
|
||||
if not chunk:
|
||||
break
|
||||
raw += chunk
|
||||
remaining -= len(chunk)
|
||||
|
||||
if len(raw) < _fft_size * 2:
|
||||
break
|
||||
|
||||
# FFT pipeline
|
||||
samples = cu8_to_complex(raw)
|
||||
fft_samples = samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples
|
||||
power_db = compute_power_spectrum(
|
||||
fft_samples,
|
||||
fft_size=_fft_size,
|
||||
avg_count=_avg_count,
|
||||
)
|
||||
quantized = quantize_to_uint8(power_db)
|
||||
frame = build_binary_frame(_start_freq, _end_freq, quantized)
|
||||
|
||||
# Send waterfall frame via WS
|
||||
with suppress(queue.Full):
|
||||
_send_q.put_nowait(frame)
|
||||
|
||||
# Run detection on raw dB spectrum
|
||||
now = time.time()
|
||||
stats, event = detector.process_frame(
|
||||
power_db, start_freq_hz, end_freq_hz, now,
|
||||
)
|
||||
|
||||
# Push event immediately via SSE
|
||||
if event:
|
||||
_push_sse({
|
||||
'type': 'event',
|
||||
'event': event.to_dict(),
|
||||
})
|
||||
# Also send as JSON via WS for immediate UI update
|
||||
event_msg = json.dumps({
|
||||
'type': 'detection',
|
||||
'event': event.to_dict(),
|
||||
})
|
||||
with suppress(queue.Full):
|
||||
_send_q.put_nowait(event_msg)
|
||||
|
||||
# Push stats every ~1s via SSE
|
||||
if now - last_stats_push >= 1.0:
|
||||
_push_sse(stats)
|
||||
last_stats_push = now
|
||||
|
||||
# Pace to target FPS
|
||||
elapsed = time.monotonic() - frame_start
|
||||
sleep_time = frame_interval - elapsed
|
||||
if sleep_time > 0:
|
||||
stop_evt.wait(sleep_time)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Meteor FFT reader stopped: {e}")
|
||||
|
||||
reader_thread = threading.Thread(
|
||||
target=fft_reader,
|
||||
args=(
|
||||
iq_process, send_queue, stop_event, _detector,
|
||||
fft_size, avg_count, fps, sample_rate,
|
||||
start_freq, end_freq, frequency_mhz,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
reader_thread.start()
|
||||
|
||||
elif cmd == 'update_threshold':
|
||||
detector = _detector
|
||||
if detector:
|
||||
detector.update_settings(
|
||||
snr_threshold_db=data.get('snr_threshold'),
|
||||
min_duration_ms=data.get('min_duration_ms'),
|
||||
cooldown_ms=data.get('cooldown_ms'),
|
||||
freq_drift_tolerance_hz=data.get('freq_drift_tolerance_hz'),
|
||||
)
|
||||
ws.send(json.dumps({'status': 'threshold_updated'}))
|
||||
|
||||
elif cmd == 'stop':
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
reader_thread = None
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||
claimed_device = None
|
||||
with _state_lock:
|
||||
_state['running'] = False
|
||||
_state['device'] = None
|
||||
stop_event.clear()
|
||||
ws.send(json.dumps({'status': 'stopped'}))
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"WebSocket meteor closed: {e}")
|
||||
finally:
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device, claimed_sdr_type)
|
||||
with _state_lock:
|
||||
_state['running'] = False
|
||||
_state['device'] = None
|
||||
with suppress(Exception):
|
||||
ws.close()
|
||||
with suppress(Exception):
|
||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||
with suppress(Exception):
|
||||
ws.sock.close()
|
||||
logger.info("WebSocket meteor client disconnected")
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Offline mode routes - Asset management and settings for offline operation.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from utils.database import get_setting, set_setting
|
||||
from utils.responses import api_error, api_success
|
||||
|
||||
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
||||
|
||||
# Default offline settings
|
||||
OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
|
||||
'offline.assets_source': 'local',
|
||||
'offline.fonts_source': 'local',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
}
|
||||
|
||||
# Asset paths to check
|
||||
ASSET_PATHS = {
|
||||
'leaflet': [
|
||||
'static/vendor/leaflet/leaflet.js',
|
||||
'static/vendor/leaflet/leaflet.css'
|
||||
],
|
||||
'chartjs': [
|
||||
'static/vendor/chartjs/chart.umd.min.js'
|
||||
],
|
||||
'inter': [
|
||||
'static/vendor/fonts/Inter-Regular.woff2',
|
||||
'static/vendor/fonts/Inter-Medium.woff2',
|
||||
'static/vendor/fonts/Inter-SemiBold.woff2',
|
||||
'static/vendor/fonts/Inter-Bold.woff2'
|
||||
],
|
||||
'jetbrains': [
|
||||
'static/vendor/fonts/JetBrainsMono-Regular.woff2',
|
||||
'static/vendor/fonts/JetBrainsMono-Medium.woff2',
|
||||
'static/vendor/fonts/JetBrainsMono-SemiBold.woff2',
|
||||
'static/vendor/fonts/JetBrainsMono-Bold.woff2'
|
||||
],
|
||||
'leaflet_images': [
|
||||
'static/vendor/leaflet/images/marker-icon.png',
|
||||
'static/vendor/leaflet/images/marker-icon-2x.png',
|
||||
'static/vendor/leaflet/images/marker-shadow.png',
|
||||
'static/vendor/leaflet/images/layers.png',
|
||||
'static/vendor/leaflet/images/layers-2x.png'
|
||||
],
|
||||
'leaflet_heat': [
|
||||
'static/vendor/leaflet-heat/leaflet-heat.js'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def get_offline_settings():
|
||||
"""Get all offline settings with defaults."""
|
||||
settings = {}
|
||||
for key, default in OFFLINE_DEFAULTS.items():
|
||||
settings[key] = get_setting(key, default)
|
||||
return settings
|
||||
|
||||
|
||||
@offline_bp.route('/settings', methods=['GET'])
|
||||
def get_settings():
|
||||
"""Get current offline settings."""
|
||||
settings = get_offline_settings()
|
||||
return api_success(data={'settings': settings})
|
||||
|
||||
|
||||
@offline_bp.route('/settings', methods=['POST'])
|
||||
def save_setting():
|
||||
"""Save an offline setting."""
|
||||
data = request.get_json()
|
||||
if not data or 'key' not in data or 'value' not in data:
|
||||
return api_error('Missing key or value', 400)
|
||||
|
||||
key = data['key']
|
||||
value = data['value']
|
||||
|
||||
# Validate key is an allowed setting
|
||||
if key not in OFFLINE_DEFAULTS:
|
||||
return api_error(f'Unknown setting: {key}', 400)
|
||||
|
||||
# Validate value type matches default
|
||||
default_type = type(OFFLINE_DEFAULTS[key])
|
||||
if not isinstance(value, default_type):
|
||||
# Try to convert
|
||||
try:
|
||||
if default_type == bool:
|
||||
value = str(value).lower() in ('true', '1', 'yes')
|
||||
else:
|
||||
value = default_type(value)
|
||||
except (ValueError, TypeError):
|
||||
return api_error(f'Invalid value type for {key}', 400)
|
||||
|
||||
set_setting(key, value)
|
||||
|
||||
return api_success(data={'key': key, 'value': value})
|
||||
|
||||
|
||||
@offline_bp.route('/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""Check status of local assets."""
|
||||
# Get the app root directory
|
||||
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
results = {}
|
||||
all_available = True
|
||||
|
||||
for asset_name, paths in ASSET_PATHS.items():
|
||||
available = True
|
||||
missing = []
|
||||
for path in paths:
|
||||
full_path = os.path.join(app_root, path)
|
||||
if not os.path.exists(full_path):
|
||||
available = False
|
||||
missing.append(path)
|
||||
|
||||
results[asset_name] = {
|
||||
'available': available,
|
||||
'missing': missing if not available else []
|
||||
}
|
||||
|
||||
if not available:
|
||||
all_available = False
|
||||
|
||||
return api_success(data={
|
||||
'all_available': all_available,
|
||||
'assets': results,
|
||||
'offline_enabled': get_setting('offline.enabled', False)
|
||||
})
|
||||
|
||||
|
||||
@offline_bp.route('/check-asset', methods=['GET'])
|
||||
def check_asset():
|
||||
"""Check if a specific asset file exists."""
|
||||
path = request.args.get('path', '')
|
||||
if not path:
|
||||
return api_error('Missing path parameter', 400)
|
||||
|
||||
# Security: only allow checking within static/vendor
|
||||
if not path.startswith('/static/vendor/'):
|
||||
return api_error('Invalid path', 400)
|
||||
|
||||
# Remove leading slash and construct full path
|
||||
relative_path = path.lstrip('/')
|
||||
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
full_path = os.path.join(app_root, relative_path)
|
||||
|
||||
exists = os.path.exists(full_path)
|
||||
|
||||
return api_success(data={'path': path, 'exists': exists})
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Generic OOK signal decoder routes.
|
||||
|
||||
Captures raw OOK frames using rtl_433's flex decoder and streams decoded
|
||||
bit/hex data to the browser for live ASCII interpretation. Supports
|
||||
PWM, PPM, and Manchester modulation with fully configurable pulse timing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import queue
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.ook import ook_parser_thread
|
||||
from utils.process import register_process, safe_terminate, 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_frequency,
|
||||
validate_gain,
|
||||
validate_positive_int,
|
||||
validate_ppm,
|
||||
validate_rtl_tcp_host,
|
||||
validate_rtl_tcp_port,
|
||||
)
|
||||
|
||||
ook_bp = Blueprint('ook', __name__)
|
||||
|
||||
# Track which device / SDR type is being used
|
||||
ook_active_device: int | None = None
|
||||
ook_active_sdr_type: str | None = None
|
||||
|
||||
# Parser thread state (avoids monkey-patching subprocess.Popen)
|
||||
_ook_stop_event: threading.Event | None = None
|
||||
_ook_parser_thread: threading.Thread | None = None
|
||||
|
||||
# Supported modulation schemes → rtl_433 flex decoder modulation string
|
||||
_MODULATION_MAP = {
|
||||
'pwm': 'OOK_PWM',
|
||||
'ppm': 'OOK_PPM',
|
||||
'manchester': 'OOK_MC_ZEROBIT',
|
||||
}
|
||||
|
||||
|
||||
def _validate_encoding(value: Any) -> str:
|
||||
enc = str(value).lower().strip()
|
||||
if enc not in _MODULATION_MAP:
|
||||
raise ValueError(f"encoding must be one of: {', '.join(_MODULATION_MAP)}")
|
||||
return enc
|
||||
|
||||
|
||||
@ook_bp.route('/ook/start', methods=['POST'])
|
||||
def start_ook() -> Response:
|
||||
global ook_active_device, ook_active_sdr_type, _ook_stop_event, _ook_parser_thread
|
||||
|
||||
with app_module.ook_lock:
|
||||
if app_module.ook_process:
|
||||
# If the process exited/crashed, clean up stale state and allow restart
|
||||
if app_module.ook_process.poll() is not None:
|
||||
cleanup_ook(emit_status=False)
|
||||
else:
|
||||
return api_error('OOK decoder already running', 409)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '433.920'))
|
||||
gain = validate_gain(data.get('gain', '0'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
|
||||
try:
|
||||
encoding = _validate_encoding(data.get('encoding', 'pwm'))
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
|
||||
# OOK flex decoder timing parameters (server-side range validation)
|
||||
try:
|
||||
short_pulse = validate_positive_int(data.get('short_pulse', 300), 'short_pulse', max_val=100000)
|
||||
long_pulse = validate_positive_int(data.get('long_pulse', 600), 'long_pulse', max_val=100000)
|
||||
reset_limit = validate_positive_int(data.get('reset_limit', 8000), 'reset_limit', max_val=1000000)
|
||||
gap_limit = validate_positive_int(data.get('gap_limit', 5000), 'gap_limit', max_val=1000000)
|
||||
tolerance = validate_positive_int(data.get('tolerance', 150), 'tolerance', max_val=50000)
|
||||
min_bits = validate_positive_int(data.get('min_bits', 8), 'min_bits', max_val=4096)
|
||||
except ValueError as e:
|
||||
return api_error(f'Invalid timing parameter: {e}', 400)
|
||||
if min_bits < 1:
|
||||
return api_error('min_bits must be >= 1', 400)
|
||||
if short_pulse < 1 or long_pulse < 1:
|
||||
return api_error('Pulse widths must be >= 1', 400)
|
||||
deduplicate = bool(data.get('deduplicate', False))
|
||||
|
||||
# Parse SDR type early — needed for device claim
|
||||
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 = 'rtlsdr'
|
||||
|
||||
rtl_tcp_host = data.get('rtl_tcp_host') or None
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'ook', sdr_type_str)
|
||||
if error:
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
ook_active_device = device_int
|
||||
ook_active_sdr_type = sdr_type_str
|
||||
|
||||
while not app_module.ook_queue.empty():
|
||||
try:
|
||||
app_module.ook_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
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=device)
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
bias_t = data.get('bias_t', False)
|
||||
|
||||
# Build base ISM command then replace protocol flags with flex decoder
|
||||
cmd = builder.build_ism_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
gain=float(gain) if gain and gain != 0 else None,
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None,
|
||||
bias_t=bias_t,
|
||||
)
|
||||
|
||||
modulation = _MODULATION_MAP[encoding]
|
||||
flex_spec = (
|
||||
f'n=ook,m={modulation},'
|
||||
f's={short_pulse},l={long_pulse},'
|
||||
f'r={reset_limit},g={gap_limit},'
|
||||
f't={tolerance},bits>={min_bits}'
|
||||
)
|
||||
|
||||
# Strip any existing -R flags from the base command
|
||||
filtered_cmd: list[str] = []
|
||||
skip_next = False
|
||||
for arg in cmd:
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if arg == '-R':
|
||||
skip_next = True
|
||||
continue
|
||||
filtered_cmd.append(arg)
|
||||
|
||||
filtered_cmd.extend(['-M', 'level', '-R', '0', '-X', flex_spec])
|
||||
|
||||
full_cmd = ' '.join(filtered_cmd)
|
||||
logger.info(f'OOK decoder running: {full_cmd}')
|
||||
|
||||
try:
|
||||
rtl_process = subprocess.Popen(
|
||||
filtered_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
register_process(rtl_process)
|
||||
|
||||
_stderr_noise = ('bitbuffer_add_bit', 'row count limit')
|
||||
|
||||
def monitor_stderr() -> None:
|
||||
for line in rtl_process.stderr:
|
||||
err_text = line.decode('utf-8', errors='replace').strip()
|
||||
if err_text and not any(n in err_text for n in _stderr_noise):
|
||||
logger.debug(f'[rtl_433/ook] {err_text}')
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
stop_event = threading.Event()
|
||||
parser_thread = threading.Thread(
|
||||
target=ook_parser_thread,
|
||||
args=(
|
||||
rtl_process.stdout,
|
||||
app_module.ook_queue,
|
||||
stop_event,
|
||||
encoding,
|
||||
deduplicate,
|
||||
),
|
||||
)
|
||||
parser_thread.daemon = True
|
||||
parser_thread.start()
|
||||
|
||||
app_module.ook_process = rtl_process
|
||||
_ook_stop_event = stop_event
|
||||
_ook_parser_thread = parser_thread
|
||||
|
||||
try:
|
||||
app_module.ook_queue.put_nowait({'type': 'status', 'text': 'started'})
|
||||
except queue.Full:
|
||||
logger.warning("OOK 'started' status dropped — queue full")
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'command': full_cmd,
|
||||
'encoding': encoding,
|
||||
'modulation': modulation,
|
||||
'flex_spec': flex_spec,
|
||||
'deduplicate': deduplicate,
|
||||
})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
if ook_active_device is not None:
|
||||
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
|
||||
ook_active_device = None
|
||||
ook_active_sdr_type = None
|
||||
return api_error(f'Tool not found: {e.filename}', 400)
|
||||
|
||||
except Exception as e:
|
||||
try:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=2)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
rtl_process.kill()
|
||||
unregister_process(rtl_process)
|
||||
if ook_active_device is not None:
|
||||
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
|
||||
ook_active_device = None
|
||||
ook_active_sdr_type = None
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
def _close_pipe(pipe_obj) -> None:
|
||||
"""Close a subprocess pipe, suppressing errors."""
|
||||
if pipe_obj is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
pipe_obj.close()
|
||||
|
||||
|
||||
def cleanup_ook(*, emit_status: bool = True) -> None:
|
||||
"""Full OOK cleanup: stop parser, terminate process, release SDR device.
|
||||
|
||||
Safe to call from ``stop_ook()`` and ``kill_all()``. Caller must hold
|
||||
``app_module.ook_lock``.
|
||||
"""
|
||||
global ook_active_device, ook_active_sdr_type, _ook_stop_event, _ook_parser_thread
|
||||
|
||||
proc = app_module.ook_process
|
||||
if not proc:
|
||||
return
|
||||
|
||||
# Signal parser thread to stop
|
||||
if _ook_stop_event:
|
||||
_ook_stop_event.set()
|
||||
|
||||
# Close pipes so parser thread unblocks from readline()
|
||||
_close_pipe(getattr(proc, 'stdout', None))
|
||||
_close_pipe(getattr(proc, 'stderr', None))
|
||||
|
||||
# Kill the entire process group so child processes are cleaned up
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
proc.wait(timeout=3)
|
||||
except (ProcessLookupError, OSError):
|
||||
# Process already dead — fall back to normal terminate
|
||||
safe_terminate(proc)
|
||||
unregister_process(proc)
|
||||
app_module.ook_process = None
|
||||
|
||||
# Join parser thread with timeout
|
||||
if _ook_parser_thread:
|
||||
_ook_parser_thread.join(timeout=0.5)
|
||||
|
||||
_ook_stop_event = None
|
||||
_ook_parser_thread = None
|
||||
|
||||
if ook_active_device is not None:
|
||||
app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr')
|
||||
ook_active_device = None
|
||||
ook_active_sdr_type = None
|
||||
|
||||
if emit_status:
|
||||
try:
|
||||
app_module.ook_queue.put_nowait({'type': 'status', 'text': 'stopped'})
|
||||
except queue.Full:
|
||||
logger.warning("OOK 'stopped' status dropped — queue full")
|
||||
|
||||
|
||||
@ook_bp.route('/ook/stop', methods=['POST'])
|
||||
def stop_ook() -> Response:
|
||||
with app_module.ook_lock:
|
||||
if app_module.ook_process:
|
||||
cleanup_ook()
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@ook_bp.route('/ook/status')
|
||||
def ook_status() -> Response:
|
||||
with app_module.ook_lock:
|
||||
running = (
|
||||
app_module.ook_process is not None
|
||||
and app_module.ook_process.poll() is None
|
||||
)
|
||||
return jsonify({'running': running})
|
||||
|
||||
|
||||
@ook_bp.route('/ook/stream')
|
||||
def ook_stream() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('ook', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.ook_queue,
|
||||
channel_key='ook',
|
||||
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
|
||||
@@ -2,32 +2,46 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import pty
|
||||
import queue
|
||||
import re
|
||||
import select
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
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
|
||||
from utils.dependencies import get_tool_path
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.logging import pager_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_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
validate_device_index,
|
||||
validate_frequency,
|
||||
validate_gain,
|
||||
validate_ppm,
|
||||
validate_rtl_tcp_host,
|
||||
validate_rtl_tcp_port,
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.process import safe_terminate, register_process
|
||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||
|
||||
pager_bp = Blueprint('pager', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
pager_active_device: int | None = None
|
||||
pager_active_sdr_type: str | None = None
|
||||
|
||||
|
||||
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
"""Parse multimon-ng output line."""
|
||||
@@ -47,6 +61,20 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
'message': pocsag_match.group(5).strip() or '[No Message]'
|
||||
}
|
||||
|
||||
# POCSAG parsing - other content types (catch-all for non-Alpha/Numeric labels)
|
||||
pocsag_other_match = re.match(
|
||||
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(\w+):\s*(.*)',
|
||||
line
|
||||
)
|
||||
if pocsag_other_match:
|
||||
return {
|
||||
'protocol': pocsag_other_match.group(1),
|
||||
'address': pocsag_other_match.group(2),
|
||||
'function': pocsag_other_match.group(3),
|
||||
'msg_type': pocsag_other_match.group(4),
|
||||
'message': pocsag_other_match.group(5).strip() or '[No Message]'
|
||||
}
|
||||
|
||||
# POCSAG parsing - address only (no message content)
|
||||
pocsag_addr_match = re.match(
|
||||
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',
|
||||
@@ -101,6 +129,75 @@ def log_message(msg: dict[str, Any]) -> None:
|
||||
logger.error(f"Failed to log message: {e}")
|
||||
|
||||
|
||||
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
||||
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
||||
if not samples:
|
||||
return []
|
||||
|
||||
window = samples[-window_size:] if len(samples) > window_size else samples
|
||||
waveform: list[int] = []
|
||||
for sample in window:
|
||||
# Convert int16 PCM to int8 range for lightweight transport.
|
||||
packed = int(round(sample / 256))
|
||||
waveform.append(max(-127, min(127, packed)))
|
||||
return waveform
|
||||
|
||||
|
||||
def audio_relay_thread(
|
||||
rtl_stdout,
|
||||
multimon_stdin,
|
||||
output_queue: queue.Queue,
|
||||
stop_event: threading.Event,
|
||||
) -> None:
|
||||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
||||
|
||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||
event plus a compact waveform sample onto *output_queue*.
|
||||
"""
|
||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||
INTERVAL = 0.1 # seconds between scope updates
|
||||
last_scope = time.monotonic()
|
||||
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
data = rtl_stdout.read(CHUNK)
|
||||
if not data:
|
||||
break
|
||||
|
||||
# Forward audio untouched
|
||||
try:
|
||||
multimon_stdin.write(data)
|
||||
multimon_stdin.flush()
|
||||
except (BrokenPipeError, OSError):
|
||||
break
|
||||
|
||||
# Compute scope levels every ~100 ms
|
||||
now = time.monotonic()
|
||||
if now - last_scope >= INTERVAL:
|
||||
last_scope = now
|
||||
try:
|
||||
n_samples = len(data) // 2
|
||||
if n_samples == 0:
|
||||
continue
|
||||
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
||||
peak = max(abs(s) for s in samples)
|
||||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||||
output_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rms': rms,
|
||||
'peak': peak,
|
||||
'waveform': _encode_scope_waveform(samples),
|
||||
})
|
||||
except (struct.error, ValueError, queue.Full):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"Audio relay error: {e}")
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
multimon_stdin.close()
|
||||
|
||||
|
||||
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream decoder output to queue using PTY for unbuffered output."""
|
||||
try:
|
||||
@@ -142,21 +239,43 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
except Exception as e:
|
||||
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
try:
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
process.wait()
|
||||
# Signal relay thread to stop
|
||||
with app_module.process_lock:
|
||||
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
|
||||
if stop_relay:
|
||||
stop_relay.set()
|
||||
# Cleanup companion rtl_fm process and decoder
|
||||
with app_module.process_lock:
|
||||
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
|
||||
for proc in [rtl_proc, process]:
|
||||
if proc:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.kill()
|
||||
unregister_process(proc)
|
||||
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.process_lock:
|
||||
app_module.current_process = None
|
||||
# Release SDR device
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
|
||||
|
||||
@pager_bp.route('/start', methods=['POST'])
|
||||
def start_decoding() -> Response:
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||
return api_error('Already running', 409)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -167,7 +286,7 @@ def start_decoding() -> Response:
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
squelch = data.get('squelch', '0')
|
||||
try:
|
||||
@@ -175,13 +294,33 @@ def start_decoding() -> Response:
|
||||
if not 0 <= squelch <= 1000:
|
||||
raise ValueError("Squelch must be between 0 and 1000")
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
|
||||
return api_error('Invalid squelch value', 400)
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
# Get SDR type early so we can pass it to claim/release
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Claim local device if not using remote rtl_tcp
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str)
|
||||
if error:
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
pager_active_device = device_int
|
||||
pager_active_sdr_type = sdr_type_str
|
||||
|
||||
# Validate protocols
|
||||
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
||||
protocols = data.get('protocols', valid_protocols)
|
||||
if not isinstance(protocols, list):
|
||||
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
return api_error('Protocols must be a list', 400)
|
||||
protocols = [p for p in protocols if p in valid_protocols]
|
||||
if not protocols:
|
||||
protocols = valid_protocols
|
||||
@@ -205,24 +344,19 @@ def start_decoding() -> Response:
|
||||
elif proto == 'FLEX':
|
||||
decoders.extend(['-a', 'FLEX'])
|
||||
|
||||
# Get SDR type and build command via abstraction layer
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
# Build command via SDR abstraction layer
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
if rtl_tcp_host:
|
||||
# Validate and create network device
|
||||
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 jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
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}")
|
||||
@@ -245,7 +379,10 @@ def start_decoding() -> Response:
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
multimon_cmd = ['multimon-ng', '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
||||
multimon_path = get_tool_path('multimon-ng')
|
||||
if not multimon_path:
|
||||
return api_error('multimon-ng not found', 400)
|
||||
multimon_cmd = [multimon_path, '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
||||
|
||||
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
@@ -257,6 +394,7 @@ def start_decoding() -> Response:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(rtl_process)
|
||||
|
||||
# Start a thread to monitor rtl_fm stderr for errors
|
||||
def monitor_rtl_stderr():
|
||||
@@ -275,18 +413,30 @@ def start_decoding() -> Response:
|
||||
|
||||
multimon_process = subprocess.Popen(
|
||||
multimon_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
close_fds=True
|
||||
)
|
||||
register_process(multimon_process)
|
||||
|
||||
os.close(slave_fd)
|
||||
rtl_process.stdout.close()
|
||||
|
||||
# Spawn audio relay thread between rtl_fm and multimon-ng
|
||||
stop_relay = threading.Event()
|
||||
relay = threading.Thread(
|
||||
target=audio_relay_thread,
|
||||
args=(rtl_process.stdout, multimon_process.stdin,
|
||||
app_module.output_queue, stop_relay),
|
||||
)
|
||||
relay.daemon = True
|
||||
relay.start()
|
||||
|
||||
app_module.current_process = multimon_process
|
||||
app_module.current_process._rtl_process = rtl_process
|
||||
app_module.current_process._master_fd = master_fd
|
||||
app_module.current_process._stop_relay = stop_relay
|
||||
app_module.current_process._relay_thread = relay
|
||||
|
||||
# Start output thread with PTY master fd
|
||||
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
||||
@@ -298,32 +448,58 @@ def start_decoding() -> Response:
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||
# 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 pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
return api_error(f'Tool not found: {e.filename}')
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(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 pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@pager_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoding() -> Response:
|
||||
global pager_active_device, pager_active_sdr_type
|
||||
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
# Signal audio relay thread to stop
|
||||
if hasattr(app_module.current_process, '_stop_relay'):
|
||||
app_module.current_process._stop_relay.set()
|
||||
|
||||
# Kill rtl_fm process first
|
||||
if hasattr(app_module.current_process, '_rtl_process'):
|
||||
try:
|
||||
app_module.current_process._rtl_process.terminate()
|
||||
app_module.current_process._rtl_process.wait(timeout=2)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
app_module.current_process._rtl_process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Close PTY master fd
|
||||
if hasattr(app_module.current_process, '_master_fd'):
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(app_module.current_process._master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Kill multimon-ng
|
||||
app_module.current_process.terminate()
|
||||
@@ -333,6 +509,13 @@ def stop_decoding() -> Response:
|
||||
app_module.current_process.kill()
|
||||
|
||||
app_module.current_process = None
|
||||
|
||||
# Release device from registry
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr')
|
||||
pager_active_device = None
|
||||
pager_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
@@ -367,40 +550,35 @@ def toggle_logging() -> Response:
|
||||
is_in_logs = str(requested_path).startswith(str(logs_dir))
|
||||
|
||||
if not (is_in_cwd or is_in_logs):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid log file path'}), 400
|
||||
return api_error('Invalid log file path', 400)
|
||||
|
||||
# Ensure it's not a directory
|
||||
if requested_path.is_dir():
|
||||
return jsonify({'status': 'error', 'message': 'Log file path must be a file, not a directory'}), 400
|
||||
return api_error('Log file path must be a file, not a directory', 400)
|
||||
|
||||
app_module.log_file_path = str(requested_path)
|
||||
except (ValueError, OSError) as e:
|
||||
logger.warning(f"Invalid log file path: {e}")
|
||||
return jsonify({'status': 'error', 'message': 'Invalid log file path'}), 400
|
||||
return api_error('Invalid log file path', 400)
|
||||
|
||||
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
||||
|
||||
|
||||
@pager_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
import json
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('pager', msg, msg.get('type'))
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.output_queue.get(timeout=1)
|
||||
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
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.output_queue,
|
||||
channel_key='pager',
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
"""Radiosonde weather balloon tracking routes.
|
||||
|
||||
Uses radiosonde_auto_rx to automatically scan for and decode radiosonde
|
||||
telemetry (position, altitude, temperature, humidity, pressure) on the
|
||||
400-406 MHz band. Telemetry arrives as JSON over UDP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
from utils.constants import (
|
||||
MAX_RADIOSONDE_AGE_SECONDS,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
RADIOSONDE_TERMINATE_TIMEOUT,
|
||||
RADIOSONDE_UDP_PORT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
)
|
||||
from utils.gps import is_gpsd_running
|
||||
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,
|
||||
validate_latitude,
|
||||
validate_longitude,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.radiosonde')
|
||||
|
||||
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
|
||||
|
||||
# Track radiosonde state
|
||||
radiosonde_running = False
|
||||
radiosonde_active_device: int | None = None
|
||||
radiosonde_active_sdr_type: str | None = None
|
||||
|
||||
# Active balloon data: serial -> telemetry dict
|
||||
radiosonde_balloons: dict[str, dict[str, Any]] = {}
|
||||
_balloons_lock = threading.Lock()
|
||||
|
||||
# UDP listener socket reference (so /stop can close it)
|
||||
_udp_socket: socket.socket | None = None
|
||||
|
||||
# Common installation paths for radiosonde_auto_rx
|
||||
AUTO_RX_PATHS = [
|
||||
'/opt/radiosonde_auto_rx/auto_rx/auto_rx.py',
|
||||
'/usr/local/bin/radiosonde_auto_rx',
|
||||
'/opt/auto_rx/auto_rx.py',
|
||||
]
|
||||
|
||||
|
||||
def find_auto_rx() -> str | None:
|
||||
"""Find radiosonde_auto_rx script/binary."""
|
||||
# Check PATH first
|
||||
path = shutil.which('radiosonde_auto_rx')
|
||||
if path:
|
||||
return path
|
||||
# Check common locations
|
||||
for p in AUTO_RX_PATHS:
|
||||
if os.path.isfile(p) and os.access(p, os.X_OK):
|
||||
return p
|
||||
# Check for Python script (not executable but runnable)
|
||||
for p in AUTO_RX_PATHS:
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def generate_station_cfg(
|
||||
freq_min: float = 400.0,
|
||||
freq_max: float = 406.0,
|
||||
gain: float = 40.0,
|
||||
device_index: int = 0,
|
||||
ppm: int = 0,
|
||||
bias_t: bool = False,
|
||||
udp_port: int = RADIOSONDE_UDP_PORT,
|
||||
latitude: float = 0.0,
|
||||
longitude: float = 0.0,
|
||||
station_alt: float = 0.0,
|
||||
gpsd_enabled: bool = False,
|
||||
) -> str:
|
||||
"""Generate a station.cfg for radiosonde_auto_rx and return the file path."""
|
||||
cfg_dir = os.path.abspath(os.path.join('data', 'radiosonde'))
|
||||
log_dir = os.path.join(cfg_dir, 'logs')
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
cfg_path = os.path.join(cfg_dir, 'station.cfg')
|
||||
|
||||
# Full station.cfg based on radiosonde_auto_rx v1.8+ example config.
|
||||
# All sections and keys included to avoid missing-key crashes.
|
||||
cfg = f"""# Auto-generated by INTERCEPT for radiosonde_auto_rx
|
||||
|
||||
[sdr]
|
||||
sdr_type = RTLSDR
|
||||
sdr_quantity = 1
|
||||
sdr_hostname = localhost
|
||||
sdr_port = 5555
|
||||
|
||||
[sdr_1]
|
||||
device_idx = {device_index}
|
||||
ppm = {ppm}
|
||||
gain = {gain}
|
||||
bias = {str(bias_t)}
|
||||
|
||||
[search_params]
|
||||
min_freq = {freq_min}
|
||||
max_freq = {freq_max}
|
||||
rx_timeout = 180
|
||||
only_scan = []
|
||||
never_scan = []
|
||||
always_scan = []
|
||||
always_decode = []
|
||||
|
||||
[location]
|
||||
station_lat = {latitude}
|
||||
station_lon = {longitude}
|
||||
station_alt = {station_alt}
|
||||
gpsd_enabled = {str(gpsd_enabled)}
|
||||
gpsd_host = localhost
|
||||
gpsd_port = 2947
|
||||
|
||||
[habitat]
|
||||
uploader_callsign = INTERCEPT
|
||||
upload_listener_position = False
|
||||
uploader_antenna = unknown
|
||||
|
||||
[sondehub]
|
||||
sondehub_enabled = False
|
||||
sondehub_upload_rate = 15
|
||||
sondehub_contact_email = none@none.com
|
||||
|
||||
[aprs]
|
||||
aprs_enabled = False
|
||||
aprs_user = N0CALL
|
||||
aprs_pass = 00000
|
||||
upload_rate = 30
|
||||
aprs_server = radiosondy.info
|
||||
aprs_port = 14580
|
||||
station_beacon_enabled = False
|
||||
station_beacon_rate = 30
|
||||
station_beacon_comment = radiosonde_auto_rx
|
||||
station_beacon_icon = /`
|
||||
aprs_object_id = <id>
|
||||
aprs_use_custom_object_id = False
|
||||
aprs_custom_comment = <type> <freq>
|
||||
|
||||
[oziplotter]
|
||||
ozi_enabled = False
|
||||
ozi_update_rate = 5
|
||||
ozi_host = 127.0.0.1
|
||||
ozi_port = 8942
|
||||
payload_summary_enabled = True
|
||||
payload_summary_host = 127.0.0.1
|
||||
payload_summary_port = {udp_port}
|
||||
|
||||
[email]
|
||||
email_enabled = False
|
||||
launch_notifications = True
|
||||
landing_notifications = True
|
||||
encrypted_sonde_notifications = True
|
||||
landing_range_threshold = 30
|
||||
landing_altitude_threshold = 1000
|
||||
error_notifications = False
|
||||
smtp_server = localhost
|
||||
smtp_port = 25
|
||||
smtp_authentication = None
|
||||
smtp_login = None
|
||||
smtp_password = None
|
||||
from = sonde@localhost
|
||||
to = none@none.com
|
||||
subject = Sonde launch detected
|
||||
|
||||
[rotator]
|
||||
rotator_enabled = False
|
||||
update_rate = 30
|
||||
rotation_threshold = 5.0
|
||||
rotator_hostname = 127.0.0.1
|
||||
rotator_port = 4533
|
||||
rotator_homing_enabled = False
|
||||
rotator_homing_delay = 10
|
||||
rotator_home_azimuth = 0.0
|
||||
rotator_home_elevation = 0.0
|
||||
azimuth_only = False
|
||||
|
||||
[logging]
|
||||
per_sonde_log = True
|
||||
save_system_log = False
|
||||
enable_debug_logging = False
|
||||
save_cal_data = False
|
||||
|
||||
[web]
|
||||
web_host = 127.0.0.1
|
||||
web_port = 0
|
||||
archive_age = 120
|
||||
web_control = False
|
||||
web_password = none
|
||||
kml_refresh_rate = 10
|
||||
|
||||
[debugging]
|
||||
save_detection_audio = False
|
||||
save_decode_audio = False
|
||||
save_decode_iq = False
|
||||
save_raw_hex = False
|
||||
|
||||
[advanced]
|
||||
search_step = 800
|
||||
snr_threshold = 10
|
||||
max_peaks = 10
|
||||
min_distance = 1000
|
||||
scan_dwell_time = 20
|
||||
detect_dwell_time = 5
|
||||
scan_delay = 10
|
||||
quantization = 10000
|
||||
decoder_spacing_limit = 15000
|
||||
temporary_block_time = 120
|
||||
max_async_scan_workers = 4
|
||||
synchronous_upload = True
|
||||
payload_id_valid = 3
|
||||
sdr_fm_path = rtl_fm
|
||||
sdr_power_path = rtl_power
|
||||
ss_iq_path = ./ss_iq
|
||||
ss_power_path = ./ss_power
|
||||
|
||||
[filtering]
|
||||
max_altitude = 50000
|
||||
max_radius_km = 1000
|
||||
min_radius_km = 0
|
||||
radius_temporary_block = False
|
||||
sonde_time_threshold = 3
|
||||
"""
|
||||
|
||||
try:
|
||||
with open(cfg_path, 'w') as f:
|
||||
f.write(cfg)
|
||||
except OSError as e:
|
||||
logger.error(f"Cannot write station.cfg to {cfg_path}: {e}")
|
||||
raise RuntimeError(
|
||||
f"Cannot write radiosonde config to {cfg_path}: {e}. "
|
||||
f"Fix permissions with: sudo chown -R $(whoami) {cfg_dir}"
|
||||
) from e
|
||||
|
||||
# When running as root via sudo, fix ownership so next non-root run
|
||||
# can still read/write these files.
|
||||
_fix_data_ownership(cfg_dir)
|
||||
|
||||
logger.info(f"Generated station.cfg at {cfg_path}")
|
||||
return cfg_path
|
||||
|
||||
|
||||
def _fix_data_ownership(path: str) -> None:
|
||||
"""Recursively chown a path to the real (non-root) user when running via sudo."""
|
||||
uid = os.environ.get('INTERCEPT_SUDO_UID')
|
||||
gid = os.environ.get('INTERCEPT_SUDO_GID')
|
||||
if uid is None or gid is None:
|
||||
return
|
||||
try:
|
||||
uid_int, gid_int = int(uid), int(gid)
|
||||
for dirpath, _dirnames, filenames in os.walk(path):
|
||||
os.chown(dirpath, uid_int, gid_int)
|
||||
for fname in filenames:
|
||||
os.chown(os.path.join(dirpath, fname), uid_int, gid_int)
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not fix ownership of {path}: {e}")
|
||||
|
||||
|
||||
def parse_radiosonde_udp(udp_port: int) -> None:
|
||||
"""Thread function: listen for radiosonde_auto_rx UDP JSON telemetry."""
|
||||
global radiosonde_running, _udp_socket
|
||||
|
||||
logger.info(f"Radiosonde UDP listener started on port {udp_port}")
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(('0.0.0.0', udp_port))
|
||||
sock.settimeout(2.0)
|
||||
_udp_socket = sock
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to bind UDP port {udp_port}: {e}")
|
||||
return
|
||||
|
||||
while radiosonde_running:
|
||||
try:
|
||||
data, _addr = sock.recvfrom(4096)
|
||||
except socket.timeout:
|
||||
# Clean up stale balloons
|
||||
_cleanup_stale_balloons()
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
|
||||
try:
|
||||
msg = json.loads(data.decode('utf-8', errors='ignore'))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
balloon = _process_telemetry(msg)
|
||||
if balloon:
|
||||
serial = balloon.get('id', '')
|
||||
if serial:
|
||||
with _balloons_lock:
|
||||
radiosonde_balloons[serial] = balloon
|
||||
with contextlib.suppress(queue.Full):
|
||||
app_module.radiosonde_queue.put_nowait({
|
||||
'type': 'balloon',
|
||||
**balloon,
|
||||
})
|
||||
|
||||
with contextlib.suppress(OSError):
|
||||
sock.close()
|
||||
_udp_socket = None
|
||||
logger.info("Radiosonde UDP listener stopped")
|
||||
|
||||
|
||||
def _process_telemetry(msg: dict) -> dict | None:
|
||||
"""Extract relevant fields from a radiosonde_auto_rx UDP telemetry packet."""
|
||||
# auto_rx broadcasts packets with a 'type' field
|
||||
# Telemetry packets have type 'payload_summary' or individual sonde data
|
||||
serial = msg.get('id') or msg.get('serial')
|
||||
if not serial:
|
||||
return None
|
||||
|
||||
balloon: dict[str, Any] = {'id': str(serial)}
|
||||
|
||||
# Sonde type (RS41, RS92, DFM, M10, etc.) — prefer subtype if available
|
||||
if 'subtype' in msg:
|
||||
balloon['sonde_type'] = msg['subtype']
|
||||
elif 'type' in msg:
|
||||
balloon['sonde_type'] = msg['type']
|
||||
|
||||
# Timestamp
|
||||
if 'datetime' in msg:
|
||||
balloon['datetime'] = msg['datetime']
|
||||
|
||||
# Position
|
||||
for key in ('lat', 'latitude'):
|
||||
if key in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['lat'] = float(msg[key])
|
||||
break
|
||||
for key in ('lon', 'longitude'):
|
||||
if key in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['lon'] = float(msg[key])
|
||||
break
|
||||
|
||||
# Altitude (metres)
|
||||
if 'alt' in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['alt'] = float(msg['alt'])
|
||||
|
||||
# Meteorological data
|
||||
for field in ('temp', 'humidity', 'pressure'):
|
||||
if field in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon[field] = float(msg[field])
|
||||
|
||||
# Velocity
|
||||
if 'vel_h' in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['vel_h'] = float(msg['vel_h'])
|
||||
if 'vel_v' in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['vel_v'] = float(msg['vel_v'])
|
||||
if 'heading' in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['heading'] = float(msg['heading'])
|
||||
|
||||
# GPS satellites
|
||||
if 'sats' in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['sats'] = int(msg['sats'])
|
||||
|
||||
# Battery voltage
|
||||
if 'batt' in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['batt'] = float(msg['batt'])
|
||||
|
||||
# Frequency
|
||||
if 'freq' in msg:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
balloon['freq'] = float(msg['freq'])
|
||||
|
||||
balloon['last_seen'] = time.time()
|
||||
return balloon
|
||||
|
||||
|
||||
def _cleanup_stale_balloons() -> None:
|
||||
"""Remove balloons not seen within the retention window."""
|
||||
now = time.time()
|
||||
with _balloons_lock:
|
||||
stale = [
|
||||
k for k, v in radiosonde_balloons.items()
|
||||
if now - v.get('last_seen', 0) > MAX_RADIOSONDE_AGE_SECONDS
|
||||
]
|
||||
for k in stale:
|
||||
del radiosonde_balloons[k]
|
||||
|
||||
|
||||
@radiosonde_bp.route('/tools')
|
||||
def check_tools():
|
||||
"""Check for radiosonde decoding tools and hardware."""
|
||||
auto_rx_path = find_auto_rx()
|
||||
devices = SDRFactory.detect_devices()
|
||||
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
|
||||
|
||||
return jsonify({
|
||||
'auto_rx': auto_rx_path is not None,
|
||||
'auto_rx_path': auto_rx_path,
|
||||
'has_rtlsdr': has_rtlsdr,
|
||||
'device_count': len(devices),
|
||||
})
|
||||
|
||||
|
||||
@radiosonde_bp.route('/status')
|
||||
def radiosonde_status():
|
||||
"""Get radiosonde tracking status."""
|
||||
process_running = False
|
||||
if app_module.radiosonde_process:
|
||||
process_running = app_module.radiosonde_process.poll() is None
|
||||
|
||||
with _balloons_lock:
|
||||
balloon_count = len(radiosonde_balloons)
|
||||
balloons_snapshot = dict(radiosonde_balloons)
|
||||
|
||||
return jsonify({
|
||||
'tracking_active': radiosonde_running,
|
||||
'active_device': radiosonde_active_device,
|
||||
'balloon_count': balloon_count,
|
||||
'balloons': balloons_snapshot,
|
||||
'queue_size': app_module.radiosonde_queue.qsize(),
|
||||
'auto_rx_path': find_auto_rx(),
|
||||
'process_running': process_running,
|
||||
})
|
||||
|
||||
|
||||
@radiosonde_bp.route('/start', methods=['POST'])
|
||||
def start_radiosonde():
|
||||
"""Start radiosonde tracking."""
|
||||
global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type
|
||||
|
||||
with app_module.radiosonde_lock:
|
||||
if radiosonde_running:
|
||||
return api_error('Radiosonde tracking already active', 409)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
gain = float(validate_gain(data.get('gain', '40')))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
|
||||
freq_min = data.get('freq_min', 400.0)
|
||||
freq_max = data.get('freq_max', 406.0)
|
||||
try:
|
||||
freq_min = float(freq_min)
|
||||
freq_max = float(freq_max)
|
||||
if not (380.0 <= freq_min <= 410.0) or not (380.0 <= freq_max <= 410.0):
|
||||
raise ValueError("Frequency out of range")
|
||||
if freq_min >= freq_max:
|
||||
raise ValueError("Min frequency must be less than max")
|
||||
except (ValueError, TypeError) as e:
|
||||
return api_error(f'Invalid frequency range: {e}', 400)
|
||||
|
||||
bias_t = data.get('bias_t', False)
|
||||
ppm = int(data.get('ppm', 0))
|
||||
|
||||
# Validate optional location
|
||||
latitude = 0.0
|
||||
longitude = 0.0
|
||||
if data.get('latitude') is not None and data.get('longitude') is not None:
|
||||
try:
|
||||
latitude = validate_latitude(data['latitude'])
|
||||
longitude = validate_longitude(data['longitude'])
|
||||
except ValueError:
|
||||
latitude = 0.0
|
||||
longitude = 0.0
|
||||
|
||||
# Check if gpsd is available for live position updates
|
||||
gpsd_enabled = is_gpsd_running()
|
||||
|
||||
# Find auto_rx
|
||||
auto_rx_path = find_auto_rx()
|
||||
if not auto_rx_path:
|
||||
return api_error('radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx', 400)
|
||||
|
||||
# Get SDR type
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Kill any existing process
|
||||
if app_module.radiosonde_process:
|
||||
try:
|
||||
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
||||
os.killpg(pgid, 15)
|
||||
app_module.radiosonde_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||
try:
|
||||
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
||||
os.killpg(pgid, 9)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
app_module.radiosonde_process = None
|
||||
logger.info("Killed existing radiosonde process")
|
||||
|
||||
# Claim SDR device
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str)
|
||||
if error:
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
|
||||
# Generate config
|
||||
try:
|
||||
cfg_path = generate_station_cfg(
|
||||
freq_min=freq_min,
|
||||
freq_max=freq_max,
|
||||
gain=gain,
|
||||
device_index=device_int,
|
||||
ppm=ppm,
|
||||
bias_t=bias_t,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
gpsd_enabled=gpsd_enabled,
|
||||
)
|
||||
except (OSError, RuntimeError) as e:
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
logger.error(f"Failed to generate radiosonde config: {e}")
|
||||
return api_error(str(e), 500)
|
||||
|
||||
# Build command - auto_rx -c expects the path to station.cfg
|
||||
cfg_abs = os.path.abspath(cfg_path)
|
||||
if auto_rx_path.endswith('.py'):
|
||||
cmd = [sys.executable, auto_rx_path, '-c', cfg_abs]
|
||||
else:
|
||||
cmd = [auto_rx_path, '-c', cfg_abs]
|
||||
|
||||
# Set cwd to the auto_rx directory so 'from autorx.scan import ...' works
|
||||
auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path))
|
||||
|
||||
# Quick dependency check before launching the full process
|
||||
if auto_rx_path.endswith('.py'):
|
||||
dep_check = subprocess.run(
|
||||
[sys.executable, '-c', 'import autorx.scan'],
|
||||
cwd=auto_rx_dir,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
if dep_check.returncode != 0:
|
||||
dep_error = dep_check.stderr.decode('utf-8', errors='ignore').strip()
|
||||
logger.error(f"radiosonde_auto_rx dependency check failed:\n{dep_error}")
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
return api_error(
|
||||
'radiosonde_auto_rx dependencies not satisfied. '
|
||||
f'Re-run setup.sh to install. Error: {dep_error[:500]}',
|
||||
500,
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
|
||||
app_module.radiosonde_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
cwd=auto_rx_dir,
|
||||
)
|
||||
|
||||
# Wait briefly for process to start
|
||||
time.sleep(2.0)
|
||||
|
||||
if app_module.radiosonde_process.poll() is not None:
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
stderr_output = ''
|
||||
if app_module.radiosonde_process.stderr:
|
||||
with contextlib.suppress(Exception):
|
||||
stderr_output = app_module.radiosonde_process.stderr.read().decode(
|
||||
'utf-8', errors='ignore'
|
||||
).strip()
|
||||
if stderr_output:
|
||||
logger.error(f"radiosonde_auto_rx stderr:\n{stderr_output}")
|
||||
if stderr_output and (
|
||||
'ImportError' in stderr_output
|
||||
or 'ModuleNotFoundError' in stderr_output
|
||||
):
|
||||
error_msg = (
|
||||
'radiosonde_auto_rx failed to start due to missing Python '
|
||||
'dependencies. Re-run setup.sh or reinstall radiosonde_auto_rx.'
|
||||
)
|
||||
else:
|
||||
error_msg = (
|
||||
'radiosonde_auto_rx failed to start. '
|
||||
'Check SDR device connection.'
|
||||
)
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:500]}'
|
||||
return api_error(error_msg, 500)
|
||||
|
||||
radiosonde_running = True
|
||||
radiosonde_active_device = device_int
|
||||
radiosonde_active_sdr_type = sdr_type_str
|
||||
|
||||
# Clear stale data
|
||||
with _balloons_lock:
|
||||
radiosonde_balloons.clear()
|
||||
|
||||
# Start UDP listener thread
|
||||
udp_thread = threading.Thread(
|
||||
target=parse_radiosonde_udp,
|
||||
args=(RADIOSONDE_UDP_PORT,),
|
||||
daemon=True,
|
||||
)
|
||||
udp_thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'message': 'Radiosonde tracking started',
|
||||
'device': device,
|
||||
})
|
||||
except Exception as e:
|
||||
app_module.release_sdr_device(device_int, sdr_type_str)
|
||||
logger.error(f"Failed to start radiosonde_auto_rx: {e}")
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@radiosonde_bp.route('/stop', methods=['POST'])
|
||||
def stop_radiosonde():
|
||||
"""Stop radiosonde tracking."""
|
||||
global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type, _udp_socket
|
||||
|
||||
with app_module.radiosonde_lock:
|
||||
if app_module.radiosonde_process:
|
||||
try:
|
||||
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
||||
os.killpg(pgid, 15)
|
||||
app_module.radiosonde_process.wait(timeout=RADIOSONDE_TERMINATE_TIMEOUT)
|
||||
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
|
||||
try:
|
||||
pgid = os.getpgid(app_module.radiosonde_process.pid)
|
||||
os.killpg(pgid, 9)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
app_module.radiosonde_process = None
|
||||
logger.info("Radiosonde process stopped")
|
||||
|
||||
# Close UDP socket to unblock listener thread
|
||||
if _udp_socket:
|
||||
with contextlib.suppress(OSError):
|
||||
_udp_socket.close()
|
||||
_udp_socket = None
|
||||
|
||||
# Release SDR device
|
||||
if radiosonde_active_device is not None:
|
||||
app_module.release_sdr_device(
|
||||
radiosonde_active_device,
|
||||
radiosonde_active_sdr_type or 'rtlsdr',
|
||||
)
|
||||
|
||||
radiosonde_running = False
|
||||
radiosonde_active_device = None
|
||||
radiosonde_active_sdr_type = None
|
||||
|
||||
with _balloons_lock:
|
||||
radiosonde_balloons.clear()
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@radiosonde_bp.route('/stream')
|
||||
def stream_radiosonde():
|
||||
"""SSE stream for radiosonde telemetry."""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.radiosonde_queue,
|
||||
channel_key='radiosonde',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@radiosonde_bp.route('/balloons')
|
||||
def get_balloons():
|
||||
"""Get current balloon data."""
|
||||
with _balloons_lock:
|
||||
return api_success(data={
|
||||
'count': len(radiosonde_balloons),
|
||||
'balloons': dict(radiosonde_balloons),
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Session recording API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, request, send_file
|
||||
|
||||
from utils.recording import RECORDING_ROOT, get_recording_manager
|
||||
from utils.responses import api_error, api_success
|
||||
|
||||
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
|
||||
|
||||
|
||||
@recordings_bp.route('/start', methods=['POST'])
|
||||
def start_recording():
|
||||
data = request.get_json() or {}
|
||||
mode = (data.get('mode') or '').strip()
|
||||
if not mode:
|
||||
return api_error('mode is required', 400)
|
||||
|
||||
label = data.get('label')
|
||||
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
|
||||
|
||||
manager = get_recording_manager()
|
||||
session = manager.start_recording(mode=mode, label=label, metadata=metadata)
|
||||
|
||||
return api_success(data={'session': {
|
||||
'id': session.id,
|
||||
'mode': session.mode,
|
||||
'label': session.label,
|
||||
'started_at': session.started_at.isoformat(),
|
||||
'file_path': str(session.file_path),
|
||||
}})
|
||||
|
||||
|
||||
@recordings_bp.route('/stop', methods=['POST'])
|
||||
def stop_recording():
|
||||
data = request.get_json() or {}
|
||||
mode = data.get('mode')
|
||||
session_id = data.get('id')
|
||||
|
||||
manager = get_recording_manager()
|
||||
session = manager.stop_recording(mode=mode, session_id=session_id)
|
||||
if not session:
|
||||
return api_error('No active recording found', 404)
|
||||
|
||||
return api_success(data={'session': {
|
||||
'id': session.id,
|
||||
'mode': session.mode,
|
||||
'label': session.label,
|
||||
'started_at': session.started_at.isoformat(),
|
||||
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
|
||||
'event_count': session.event_count,
|
||||
'size_bytes': session.size_bytes,
|
||||
'file_path': str(session.file_path),
|
||||
}})
|
||||
|
||||
|
||||
@recordings_bp.route('', methods=['GET'])
|
||||
def list_recordings():
|
||||
manager = get_recording_manager()
|
||||
limit = request.args.get('limit', default=50, type=int)
|
||||
return api_success(data={
|
||||
'recordings': manager.list_recordings(limit=limit),
|
||||
'active': manager.get_active(),
|
||||
})
|
||||
|
||||
|
||||
@recordings_bp.route('/<session_id>', methods=['GET'])
|
||||
def get_recording(session_id: str):
|
||||
manager = get_recording_manager()
|
||||
rec = manager.get_recording(session_id)
|
||||
if not rec:
|
||||
return api_error('Recording not found', 404)
|
||||
return api_success(data={'recording': rec})
|
||||
|
||||
|
||||
@recordings_bp.route('/<session_id>/download', methods=['GET'])
|
||||
def download_recording(session_id: str):
|
||||
manager = get_recording_manager()
|
||||
rec = manager.get_recording(session_id)
|
||||
if not rec:
|
||||
return api_error('Recording not found', 404)
|
||||
|
||||
file_path = Path(rec['file_path'])
|
||||
try:
|
||||
resolved_root = RECORDING_ROOT.resolve()
|
||||
resolved_file = file_path.resolve()
|
||||
if resolved_root not in resolved_file.parents:
|
||||
return api_error('Invalid recording path', 400)
|
||||
except Exception:
|
||||
return api_error('Invalid recording path', 400)
|
||||
|
||||
if not file_path.exists():
|
||||
return api_error('Recording file missing', 404)
|
||||
|
||||
return send_file(
|
||||
file_path,
|
||||
mimetype='application/x-ndjson',
|
||||
as_attachment=True,
|
||||
download_name=file_path.name,
|
||||
)
|
||||
|
||||
|
||||
@recordings_bp.route('/<session_id>/events', methods=['GET'])
|
||||
def get_recording_events(session_id: str):
|
||||
"""Return parsed events from a recording for in-app replay."""
|
||||
manager = get_recording_manager()
|
||||
rec = manager.get_recording(session_id)
|
||||
if not rec:
|
||||
return api_error('Recording not found', 404)
|
||||
|
||||
file_path = Path(rec['file_path'])
|
||||
try:
|
||||
resolved_root = RECORDING_ROOT.resolve()
|
||||
resolved_file = file_path.resolve()
|
||||
if resolved_root not in resolved_file.parents:
|
||||
return api_error('Invalid recording path', 400)
|
||||
except Exception:
|
||||
return api_error('Invalid recording path', 400)
|
||||
|
||||
if not file_path.exists():
|
||||
return api_error('Recording file missing', 404)
|
||||
|
||||
limit = max(1, min(5000, request.args.get('limit', default=500, type=int)))
|
||||
offset = max(0, request.args.get('offset', default=0, type=int))
|
||||
|
||||
events: list[dict] = []
|
||||
seen = 0
|
||||
with file_path.open('r', encoding='utf-8', errors='replace') as fh:
|
||||
for idx, line in enumerate(fh):
|
||||
if idx < offset:
|
||||
continue
|
||||
if seen >= limit:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
seen += 1
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return api_success(data={
|
||||
'recording': {
|
||||
'id': rec['id'],
|
||||
'mode': rec['mode'],
|
||||
'started_at': rec['started_at'],
|
||||
'stopped_at': rec['stopped_at'],
|
||||
'event_count': rec['event_count'],
|
||||
},
|
||||
'offset': offset,
|
||||
'limit': limit,
|
||||
'returned': len(events),
|
||||
'events': events,
|
||||
})
|
||||
@@ -0,0 +1,317 @@
|
||||
"""RTLAMR utility meter monitoring routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.process import register_process, unregister_process
|
||||
from utils.responses import api_error
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.validation import validate_device_index, validate_frequency, validate_gain, validate_ppm
|
||||
|
||||
rtlamr_bp = Blueprint('rtlamr', __name__)
|
||||
|
||||
# Store rtl_tcp process separately
|
||||
rtl_tcp_process = None
|
||||
rtl_tcp_lock = threading.Lock()
|
||||
|
||||
# Track which device is being used
|
||||
rtlamr_active_device: int | None = None
|
||||
rtlamr_active_sdr_type: str = 'rtlsdr'
|
||||
|
||||
|
||||
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream rtlamr JSON output to queue."""
|
||||
try:
|
||||
app_module.rtlamr_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# rtlamr outputs JSON objects, one per line
|
||||
data = json.loads(line)
|
||||
data['type'] = 'rtlamr'
|
||||
app_module.rtlamr_queue.put(data)
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | RTLAMR | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, send as raw
|
||||
app_module.rtlamr_queue.put({'type': 'raw', 'text': line})
|
||||
|
||||
except Exception as e:
|
||||
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
|
||||
# Ensure rtlamr process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
process.kill()
|
||||
unregister_process(process)
|
||||
# Kill companion rtl_tcp process
|
||||
with rtl_tcp_lock:
|
||||
if rtl_tcp_process:
|
||||
try:
|
||||
rtl_tcp_process.terminate()
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
rtl_tcp_process.kill()
|
||||
unregister_process(rtl_tcp_process)
|
||||
rtl_tcp_process = None
|
||||
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.rtlamr_lock:
|
||||
app_module.rtlamr_process = None
|
||||
# Release SDR device
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
rtlamr_active_device = None
|
||||
|
||||
|
||||
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
||||
def start_rtlamr() -> Response:
|
||||
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
|
||||
|
||||
with app_module.rtlamr_lock:
|
||||
if app_module.rtlamr_process:
|
||||
return api_error('RTLAMR already running', 409)
|
||||
|
||||
data = request.json or {}
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
if sdr_type_str != 'rtlsdr':
|
||||
return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400)
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '912.0'))
|
||||
gain = validate_gain(data.get('gain', '0'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'rtlamr', sdr_type_str)
|
||||
if error:
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
|
||||
rtlamr_active_device = device_int
|
||||
rtlamr_active_sdr_type = sdr_type_str
|
||||
|
||||
# Clear queue
|
||||
while not app_module.rtlamr_queue.empty():
|
||||
try:
|
||||
app_module.rtlamr_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Get message type (default to scm)
|
||||
msgtype = data.get('msgtype', 'scm')
|
||||
output_format = data.get('format', 'json')
|
||||
|
||||
# Start rtl_tcp first
|
||||
rtl_tcp_just_started = False
|
||||
rtl_tcp_cmd_str = ''
|
||||
with rtl_tcp_lock:
|
||||
if not rtl_tcp_process:
|
||||
logger.info("Starting rtl_tcp server...")
|
||||
try:
|
||||
rtl_tcp_cmd = ['rtl_tcp', '-a', '0.0.0.0']
|
||||
|
||||
# Add device index if not 0
|
||||
if device and device != '0':
|
||||
rtl_tcp_cmd.extend(['-d', str(device)])
|
||||
|
||||
# Add gain if not auto
|
||||
if gain and gain != '0':
|
||||
rtl_tcp_cmd.extend(['-g', str(gain)])
|
||||
|
||||
# Add PPM correction if not 0
|
||||
if ppm and ppm != '0':
|
||||
rtl_tcp_cmd.extend(['-p', str(ppm)])
|
||||
|
||||
rtl_tcp_process = subprocess.Popen(
|
||||
rtl_tcp_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(rtl_tcp_process)
|
||||
rtl_tcp_just_started = True
|
||||
rtl_tcp_cmd_str = ' '.join(rtl_tcp_cmd)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start rtl_tcp: {e}")
|
||||
# Release SDR device on rtl_tcp failure
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
rtlamr_active_device = None
|
||||
return api_error(f'Failed to start rtl_tcp: {e}', 500)
|
||||
|
||||
# Wait for rtl_tcp to start outside lock
|
||||
if rtl_tcp_just_started:
|
||||
time.sleep(3)
|
||||
logger.info(f"rtl_tcp started: {rtl_tcp_cmd_str}")
|
||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {rtl_tcp_cmd_str}'})
|
||||
|
||||
# Build rtlamr command
|
||||
cmd = [
|
||||
'rtlamr',
|
||||
'-server=127.0.0.1:1234',
|
||||
f'-msgtype={msgtype}',
|
||||
f'-format={output_format}',
|
||||
f'-centerfreq={int(float(freq) * 1e6)}'
|
||||
]
|
||||
|
||||
# Add filter options if provided
|
||||
filterid = data.get('filterid')
|
||||
if filterid:
|
||||
cmd.append(f'-filterid={filterid}')
|
||||
|
||||
filtertype = data.get('filtertype')
|
||||
if filtertype:
|
||||
cmd.append(f'-filtertype={filtertype}')
|
||||
|
||||
# Unique messages only
|
||||
if data.get('unique', True):
|
||||
cmd.append('-unique=true')
|
||||
|
||||
full_cmd = ' '.join(cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
try:
|
||||
app_module.rtlamr_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(app_module.rtlamr_process)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_rtlamr_output, args=(app_module.rtlamr_process,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Monitor stderr
|
||||
def monitor_stderr():
|
||||
for line in app_module.rtlamr_process.stderr:
|
||||
err = line.decode('utf-8', errors='replace').strip()
|
||||
if err:
|
||||
logger.debug(f"[rtlamr] {err}")
|
||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'[rtlamr] {err}'})
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError:
|
||||
# If rtlamr fails, clean up rtl_tcp and release device
|
||||
with rtl_tcp_lock:
|
||||
if rtl_tcp_process:
|
||||
rtl_tcp_process.terminate()
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
rtl_tcp_process = None
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
rtlamr_active_device = None
|
||||
return api_error('rtlamr not found. Install from https://github.com/bemasher/rtlamr')
|
||||
except Exception as e:
|
||||
# If rtlamr fails, clean up rtl_tcp and release device
|
||||
with rtl_tcp_lock:
|
||||
if rtl_tcp_process:
|
||||
rtl_tcp_process.terminate()
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
rtl_tcp_process = None
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
rtlamr_active_device = None
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
||||
def stop_rtlamr() -> Response:
|
||||
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
|
||||
|
||||
# Grab process refs inside locks, clear state, then terminate outside
|
||||
rtlamr_proc = None
|
||||
with app_module.rtlamr_lock:
|
||||
if app_module.rtlamr_process:
|
||||
rtlamr_proc = app_module.rtlamr_process
|
||||
app_module.rtlamr_process = None
|
||||
|
||||
if rtlamr_proc:
|
||||
rtlamr_proc.terminate()
|
||||
try:
|
||||
rtlamr_proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
rtlamr_proc.kill()
|
||||
|
||||
# Also stop rtl_tcp
|
||||
tcp_proc = None
|
||||
with rtl_tcp_lock:
|
||||
if rtl_tcp_process:
|
||||
tcp_proc = rtl_tcp_process
|
||||
rtl_tcp_process = None
|
||||
|
||||
if tcp_proc:
|
||||
tcp_proc.terminate()
|
||||
try:
|
||||
tcp_proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
tcp_proc.kill()
|
||||
logger.info("rtl_tcp stopped")
|
||||
|
||||
# Release device from registry
|
||||
if rtlamr_active_device is not None:
|
||||
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
|
||||
rtlamr_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@rtlamr_bp.route('/stream_rtlamr')
|
||||
def stream_rtlamr() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('rtlamr', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.rtlamr_queue,
|
||||
channel_key='rtlamr',
|
||||
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
|
||||
@@ -2,20 +2,39 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, jsonify, request, render_template, Response
|
||||
import requests
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
from data.satellites import TLE_SATELLITES
|
||||
from utils.database import (
|
||||
add_tracked_satellite,
|
||||
bulk_add_tracked_satellites,
|
||||
get_tracked_satellites,
|
||||
remove_tracked_satellite,
|
||||
update_tracked_satellite,
|
||||
)
|
||||
from utils.logging import satellite_logger as logger
|
||||
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
|
||||
from utils.responses import api_error
|
||||
from utils.validation import validate_elevation, validate_hours, validate_latitude, validate_longitude
|
||||
|
||||
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
|
||||
|
||||
# Cache skyfield timescale to avoid re-downloading/re-parsing per request
|
||||
_cached_timescale = None
|
||||
|
||||
|
||||
def _get_timescale():
|
||||
global _cached_timescale
|
||||
if _cached_timescale is None:
|
||||
from skyfield.api import load
|
||||
_cached_timescale = load.timescale()
|
||||
return _cached_timescale
|
||||
|
||||
# Maximum response size for external requests (1MB)
|
||||
MAX_RESPONSE_SIZE = 1024 * 1024
|
||||
|
||||
@@ -26,18 +45,148 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
|
||||
_tle_cache = dict(TLE_SATELLITES)
|
||||
|
||||
|
||||
def _load_db_satellites_into_cache():
|
||||
"""Load user-tracked satellites from DB into the TLE cache."""
|
||||
global _tle_cache
|
||||
try:
|
||||
db_sats = get_tracked_satellites()
|
||||
loaded = 0
|
||||
for sat in db_sats:
|
||||
if sat['tle_line1'] and sat['tle_line2']:
|
||||
# Use a cache key derived from name (sanitised)
|
||||
cache_key = sat['name'].replace(' ', '-').upper()
|
||||
if cache_key not in _tle_cache:
|
||||
_tle_cache[cache_key] = (sat['name'], sat['tle_line1'], sat['tle_line2'])
|
||||
loaded += 1
|
||||
if loaded:
|
||||
logger.info(f"Loaded {loaded} user-tracked satellites into TLE cache")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load DB satellites into TLE cache: {e}")
|
||||
|
||||
|
||||
def init_tle_auto_refresh():
|
||||
"""Initialize TLE auto-refresh. Called by app.py after initialization."""
|
||||
import threading
|
||||
|
||||
def _auto_refresh_tle():
|
||||
try:
|
||||
_load_db_satellites_into_cache()
|
||||
updated = refresh_tle_data()
|
||||
if updated:
|
||||
logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Auto TLE refresh failed: {e}")
|
||||
|
||||
# Start auto-refresh in background
|
||||
threading.Timer(2.0, _auto_refresh_tle).start()
|
||||
logger.info("TLE auto-refresh scheduled")
|
||||
|
||||
|
||||
def _fetch_iss_realtime(observer_lat: float | None = None, observer_lon: float | None = None) -> dict | None:
|
||||
"""
|
||||
Fetch real-time ISS position from external APIs.
|
||||
|
||||
Returns position data dict or None if all APIs fail.
|
||||
"""
|
||||
iss_lat = None
|
||||
iss_lon = None
|
||||
iss_alt = 420 # Default altitude in km
|
||||
source = None
|
||||
|
||||
# Try primary API: Where The ISS At
|
||||
try:
|
||||
response = requests.get('https://api.wheretheiss.at/v1/satellites/25544', timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
iss_lat = float(data['latitude'])
|
||||
iss_lon = float(data['longitude'])
|
||||
iss_alt = float(data.get('altitude', 420))
|
||||
source = 'wheretheiss'
|
||||
except Exception as e:
|
||||
logger.debug(f"Where The ISS At API failed: {e}")
|
||||
|
||||
# Try fallback API: Open Notify
|
||||
if iss_lat is None:
|
||||
try:
|
||||
response = requests.get('http://api.open-notify.org/iss-now.json', timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('message') == 'success':
|
||||
iss_lat = float(data['iss_position']['latitude'])
|
||||
iss_lon = float(data['iss_position']['longitude'])
|
||||
source = 'open-notify'
|
||||
except Exception as e:
|
||||
logger.debug(f"Open Notify API failed: {e}")
|
||||
|
||||
if iss_lat is None:
|
||||
return None
|
||||
|
||||
result = {
|
||||
'satellite': 'ISS',
|
||||
'lat': iss_lat,
|
||||
'lon': iss_lon,
|
||||
'altitude': iss_alt,
|
||||
'source': source
|
||||
}
|
||||
|
||||
# Calculate observer-relative data if location provided
|
||||
if observer_lat is not None and observer_lon is not None:
|
||||
# Earth radius in km
|
||||
earth_radius = 6371
|
||||
|
||||
# Convert to radians
|
||||
lat1 = math.radians(observer_lat)
|
||||
lat2 = math.radians(iss_lat)
|
||||
lon1 = math.radians(observer_lon)
|
||||
lon2 = math.radians(iss_lon)
|
||||
|
||||
# Haversine for ground distance
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
ground_distance = earth_radius * c
|
||||
|
||||
# Calculate slant range
|
||||
slant_range = math.sqrt(ground_distance**2 + iss_alt**2)
|
||||
|
||||
# Calculate elevation angle (simplified)
|
||||
if ground_distance > 0:
|
||||
elevation = math.degrees(math.atan2(iss_alt - (ground_distance**2 / (2 * earth_radius)), ground_distance))
|
||||
else:
|
||||
elevation = 90.0
|
||||
|
||||
# Calculate azimuth
|
||||
y = math.sin(dlon) * math.cos(lat2)
|
||||
x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
|
||||
azimuth = math.degrees(math.atan2(y, x))
|
||||
azimuth = (azimuth + 360) % 360
|
||||
|
||||
result['elevation'] = round(elevation, 1)
|
||||
result['azimuth'] = round(azimuth, 1)
|
||||
result['distance'] = round(slant_range, 1)
|
||||
result['visible'] = elevation > 0
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@satellite_bp.route('/dashboard')
|
||||
def satellite_dashboard():
|
||||
"""Popout satellite tracking dashboard."""
|
||||
return render_template('satellite_dashboard.html')
|
||||
embedded = request.args.get('embedded', 'false') == 'true'
|
||||
return render_template(
|
||||
'satellite_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
embedded=embedded,
|
||||
)
|
||||
|
||||
|
||||
@satellite_bp.route('/predict', methods=['POST'])
|
||||
def predict_passes():
|
||||
"""Calculate satellite passes using skyfield."""
|
||||
try:
|
||||
from skyfield.api import load, wgs84, EarthSatellite
|
||||
from skyfield.almanac import find_discrete
|
||||
from skyfield.api import EarthSatellite, wgs84
|
||||
except ImportError:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -53,19 +202,15 @@ def predict_passes():
|
||||
hours = validate_hours(data.get('hours', 24))
|
||||
min_el = validate_elevation(data.get('minEl', 10))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
25338: 'NOAA-15',
|
||||
28654: 'NOAA-18',
|
||||
33591: 'NOAA-19',
|
||||
43013: 'NOAA-20',
|
||||
40069: 'METEOR-M2',
|
||||
57166: 'METEOR-M2-3'
|
||||
}
|
||||
|
||||
sat_input = data.get('satellites', ['ISS', 'NOAA-15', 'NOAA-18', 'NOAA-19'])
|
||||
sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3'])
|
||||
satellites = []
|
||||
for sat in sat_input:
|
||||
if isinstance(sat, int) and sat in norad_to_name:
|
||||
@@ -76,16 +221,12 @@ def predict_passes():
|
||||
passes = []
|
||||
colors = {
|
||||
'ISS': '#00ffff',
|
||||
'NOAA-15': '#00ff00',
|
||||
'NOAA-18': '#ff6600',
|
||||
'NOAA-19': '#ff3366',
|
||||
'NOAA-20': '#00ffaa',
|
||||
'METEOR-M2': '#9370DB',
|
||||
'METEOR-M2-3': '#ff00ff'
|
||||
}
|
||||
name_to_norad = {v: k for k, v in norad_to_name.items()}
|
||||
|
||||
ts = load.timescale()
|
||||
ts = _get_timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
|
||||
t0 = ts.now()
|
||||
@@ -198,9 +339,9 @@ def predict_passes():
|
||||
def get_satellite_position():
|
||||
"""Get real-time positions of satellites."""
|
||||
try:
|
||||
from skyfield.api import load, wgs84, EarthSatellite
|
||||
from skyfield.api import EarthSatellite, wgs84
|
||||
except ImportError:
|
||||
return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503
|
||||
return api_error('skyfield not installed', 503)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
@@ -209,17 +350,13 @@ def get_satellite_position():
|
||||
lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074)))
|
||||
lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278)))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
return api_error(str(e), 400)
|
||||
|
||||
sat_input = data.get('satellites', [])
|
||||
include_track = bool(data.get('includeTrack', True))
|
||||
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
25338: 'NOAA-15',
|
||||
28654: 'NOAA-18',
|
||||
33591: 'NOAA-19',
|
||||
43013: 'NOAA-20',
|
||||
40069: 'METEOR-M2',
|
||||
57166: 'METEOR-M2-3'
|
||||
}
|
||||
@@ -231,7 +368,7 @@ def get_satellite_position():
|
||||
else:
|
||||
satellites.append(sat)
|
||||
|
||||
ts = load.timescale()
|
||||
ts = _get_timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
now = ts.now()
|
||||
now_dt = now.utc_datetime()
|
||||
@@ -239,6 +376,35 @@ def get_satellite_position():
|
||||
positions = []
|
||||
|
||||
for sat_name in satellites:
|
||||
# Special handling for ISS - use real-time API for accurate position
|
||||
if sat_name == 'ISS':
|
||||
iss_data = _fetch_iss_realtime(lat, lon)
|
||||
if iss_data:
|
||||
# Add orbit track if requested (using TLE for track prediction)
|
||||
if include_track and 'ISS' in _tle_cache:
|
||||
try:
|
||||
tle_data = _tle_cache['ISS']
|
||||
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||
orbit_track = []
|
||||
for minutes_offset in range(-45, 46, 1):
|
||||
t_point = ts.utc(now_dt + timedelta(minutes=minutes_offset))
|
||||
try:
|
||||
geo = satellite.at(t_point)
|
||||
sp = wgs84.subpoint(geo)
|
||||
orbit_track.append({
|
||||
'lat': float(sp.latitude.degrees),
|
||||
'lon': float(sp.longitude.degrees),
|
||||
'past': minutes_offset < 0
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
iss_data['track'] = orbit_track
|
||||
except Exception:
|
||||
pass
|
||||
positions.append(iss_data)
|
||||
continue
|
||||
|
||||
# Other satellites - use TLE data
|
||||
if sat_name not in _tle_cache:
|
||||
continue
|
||||
|
||||
@@ -292,58 +458,73 @@ def get_satellite_position():
|
||||
})
|
||||
|
||||
|
||||
@satellite_bp.route('/update-tle', methods=['POST'])
|
||||
def update_tle():
|
||||
"""Update TLE data from CelesTrak."""
|
||||
def refresh_tle_data() -> list:
|
||||
"""
|
||||
Refresh TLE data from CelesTrak.
|
||||
|
||||
This can be called at startup or periodically to keep TLE data fresh.
|
||||
Returns list of satellite names that were updated.
|
||||
"""
|
||||
global _tle_cache
|
||||
|
||||
try:
|
||||
name_mappings = {
|
||||
'ISS (ZARYA)': 'ISS',
|
||||
'NOAA 15': 'NOAA-15',
|
||||
'NOAA 18': 'NOAA-18',
|
||||
'NOAA 19': 'NOAA-19',
|
||||
'METEOR-M 2': 'METEOR-M2',
|
||||
'METEOR-M2 3': 'METEOR-M2-3'
|
||||
}
|
||||
name_mappings = {
|
||||
'ISS (ZARYA)': 'ISS',
|
||||
'NOAA 15': 'NOAA-15',
|
||||
'NOAA 18': 'NOAA-18',
|
||||
'NOAA 19': 'NOAA-19',
|
||||
'NOAA 20 (JPSS-1)': 'NOAA-20',
|
||||
'NOAA 21 (JPSS-2)': 'NOAA-21',
|
||||
'METEOR-M 2': 'METEOR-M2',
|
||||
'METEOR-M2 3': 'METEOR-M2-3',
|
||||
'METEOR-M2 4': 'METEOR-M2-4'
|
||||
}
|
||||
|
||||
updated = []
|
||||
updated = []
|
||||
|
||||
for group in ['stations', 'weather']:
|
||||
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=tle'
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10) as response:
|
||||
content = response.read().decode('utf-8')
|
||||
lines = content.strip().split('\n')
|
||||
for group in ['stations', 'weather', 'noaa']:
|
||||
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=tle'
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=15) as response:
|
||||
content = response.read().decode('utf-8')
|
||||
lines = content.strip().split('\n')
|
||||
|
||||
i = 0
|
||||
while i + 2 < len(lines):
|
||||
name = lines[i].strip()
|
||||
line1 = lines[i + 1].strip()
|
||||
line2 = lines[i + 2].strip()
|
||||
i = 0
|
||||
while i + 2 < len(lines):
|
||||
name = lines[i].strip()
|
||||
line1 = lines[i + 1].strip()
|
||||
line2 = lines[i + 2].strip()
|
||||
|
||||
if not (line1.startswith('1 ') and line2.startswith('2 ')):
|
||||
i += 1
|
||||
continue
|
||||
if not (line1.startswith('1 ') and line2.startswith('2 ')):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
internal_name = name_mappings.get(name, name)
|
||||
internal_name = name_mappings.get(name, name)
|
||||
|
||||
if internal_name in _tle_cache:
|
||||
_tle_cache[internal_name] = (name, line1, line2)
|
||||
if internal_name in _tle_cache:
|
||||
_tle_cache[internal_name] = (name, line1, line2)
|
||||
if internal_name not in updated:
|
||||
updated.append(internal_name)
|
||||
|
||||
i += 3
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching {group}: {e}")
|
||||
continue
|
||||
i += 3
|
||||
except Exception as e:
|
||||
logger.warning(f"Error fetching TLE group {group}: {e}")
|
||||
continue
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
@satellite_bp.route('/update-tle', methods=['POST'])
|
||||
def update_tle():
|
||||
"""Update TLE data from CelesTrak (API endpoint)."""
|
||||
try:
|
||||
updated = refresh_tle_data()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'updated': updated
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
logger.error(f"Error updating TLE data: {e}")
|
||||
return api_error('TLE update failed')
|
||||
|
||||
|
||||
@satellite_bp.route('/celestrak/<category>')
|
||||
@@ -357,7 +538,7 @@ def fetch_celestrak(category):
|
||||
]
|
||||
|
||||
if category not in valid_categories:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid category. Valid: {valid_categories}'})
|
||||
return api_error(f'Invalid category. Valid: {valid_categories}')
|
||||
|
||||
try:
|
||||
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={category}&FORMAT=tle'
|
||||
@@ -397,4 +578,104 @@ def fetch_celestrak(category):
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
logger.error(f"Error fetching CelesTrak data: {e}")
|
||||
return api_error('Failed to fetch satellite data')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tracked Satellites CRUD
|
||||
# =============================================================================
|
||||
|
||||
@satellite_bp.route('/tracked', methods=['GET'])
|
||||
def list_tracked_satellites():
|
||||
"""Return all tracked satellites from the database."""
|
||||
enabled_only = request.args.get('enabled', '').lower() == 'true'
|
||||
sats = get_tracked_satellites(enabled_only=enabled_only)
|
||||
return jsonify({'status': 'success', 'satellites': sats})
|
||||
|
||||
|
||||
@satellite_bp.route('/tracked', methods=['POST'])
|
||||
def add_tracked_satellites_endpoint():
|
||||
"""Add one or more tracked satellites."""
|
||||
global _tle_cache
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return api_error('No data provided', 400)
|
||||
|
||||
# Accept a single satellite dict or a list
|
||||
sat_list = data if isinstance(data, list) else [data]
|
||||
|
||||
normalized: list[dict] = []
|
||||
for sat in sat_list:
|
||||
norad_id = str(sat.get('norad_id', sat.get('norad', '')))
|
||||
name = sat.get('name', '')
|
||||
if not norad_id or not name:
|
||||
continue
|
||||
tle1 = sat.get('tle_line1', sat.get('tle1'))
|
||||
tle2 = sat.get('tle_line2', sat.get('tle2'))
|
||||
enabled = sat.get('enabled', True)
|
||||
|
||||
normalized.append({
|
||||
'norad_id': norad_id,
|
||||
'name': name,
|
||||
'tle_line1': tle1,
|
||||
'tle_line2': tle2,
|
||||
'enabled': bool(enabled),
|
||||
'builtin': False,
|
||||
})
|
||||
|
||||
# Also inject into TLE cache if we have TLE data
|
||||
if tle1 and tle2:
|
||||
cache_key = name.replace(' ', '-').upper()
|
||||
_tle_cache[cache_key] = (name, tle1, tle2)
|
||||
|
||||
# Single inserts preserve previous behavior; list inserts use DB-level bulk path.
|
||||
if len(normalized) == 1:
|
||||
sat = normalized[0]
|
||||
added = 1 if add_tracked_satellite(
|
||||
sat['norad_id'],
|
||||
sat['name'],
|
||||
sat.get('tle_line1'),
|
||||
sat.get('tle_line2'),
|
||||
sat.get('enabled', True),
|
||||
sat.get('builtin', False),
|
||||
) else 0
|
||||
else:
|
||||
added = bulk_add_tracked_satellites(normalized)
|
||||
|
||||
response_payload = {
|
||||
'status': 'success',
|
||||
'added': added,
|
||||
'processed': len(normalized),
|
||||
}
|
||||
|
||||
# Returning all tracked satellites for very large imports can stall the UI.
|
||||
include_satellites = request.args.get('include_satellites', '').lower() == 'true'
|
||||
if include_satellites or len(normalized) <= 32:
|
||||
response_payload['satellites'] = get_tracked_satellites()
|
||||
|
||||
return jsonify(response_payload)
|
||||
|
||||
|
||||
@satellite_bp.route('/tracked/<norad_id>', methods=['PUT'])
|
||||
def update_tracked_satellite_endpoint(norad_id):
|
||||
"""Update the enabled state of a tracked satellite."""
|
||||
data = request.json or {}
|
||||
enabled = data.get('enabled')
|
||||
if enabled is None:
|
||||
return api_error('Missing enabled field', 400)
|
||||
|
||||
ok = update_tracked_satellite(str(norad_id), bool(enabled))
|
||||
if ok:
|
||||
return jsonify({'status': 'success'})
|
||||
return api_error('Satellite not found', 404)
|
||||
|
||||
|
||||
@satellite_bp.route('/tracked/<norad_id>', methods=['DELETE'])
|
||||
def delete_tracked_satellite_endpoint(norad_id):
|
||||
"""Remove a tracked satellite by NORAD ID."""
|
||||
ok, msg = remove_tracked_satellite(str(norad_id))
|
||||
if ok:
|
||||
return jsonify({'status': 'success', 'message': msg})
|
||||
status_code = 403 if 'builtin' in msg.lower() else 404
|
||||
return api_error(msg, status_code)
|
||||
|
||||
@@ -1,199 +1,349 @@
|
||||
"""RTL_433 sensor monitoring routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.process import safe_terminate, register_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
sensor_bp = Blueprint('sensor', __name__)
|
||||
|
||||
|
||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
try:
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# rtl_433 outputs JSON objects, one per line
|
||||
data = json.loads(line)
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, send as raw
|
||||
app_module.sensor_queue.put({'type': 'raw', 'text': line})
|
||||
|
||||
except Exception as e:
|
||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
process.wait()
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.sensor_lock:
|
||||
app_module.sensor_process = None
|
||||
|
||||
|
||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||
def start_sensor() -> Response:
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '433.92'))
|
||||
gain = validate_gain(data.get('gain', '0'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Clear queue
|
||||
while not app_module.sensor_queue.empty():
|
||||
try:
|
||||
app_module.sensor_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Get SDR type and build command via abstraction layer
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
if rtl_tcp_host:
|
||||
# Validate and create network device
|
||||
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 jsonify({'status': 'error', 'message': 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:
|
||||
# Create local device object
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build ISM band decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
cmd = builder.build_ism_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
gain=float(gain) if gain and gain != 0 else None,
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None,
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
full_cmd = ' '.join(cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
try:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Monitor stderr
|
||||
def monitor_stderr():
|
||||
for line in app_module.sensor_process.stderr:
|
||||
err = line.decode('utf-8', errors='replace').strip()
|
||||
if err:
|
||||
logger.debug(f"[rtl_433] {err}")
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||
def stop_sensor() -> Response:
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
app_module.sensor_process.terminate()
|
||||
try:
|
||||
app_module.sensor_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.sensor_process.kill()
|
||||
app_module.sensor_process = None
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@sensor_bp.route('/stream_sensor')
|
||||
def stream_sensor() -> Response:
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.sensor_queue.get(timeout=1)
|
||||
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
|
||||
|
||||
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
|
||||
"""RTL_433 sensor monitoring routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import math
|
||||
import queue
|
||||
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.event_pipeline import process_event
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.process import register_process, unregister_process
|
||||
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_frequency,
|
||||
validate_gain,
|
||||
validate_ppm,
|
||||
validate_rtl_tcp_host,
|
||||
validate_rtl_tcp_port,
|
||||
)
|
||||
|
||||
sensor_bp = Blueprint('sensor', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
sensor_active_device: int | None = None
|
||||
sensor_active_sdr_type: str | None = None
|
||||
|
||||
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
||||
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
||||
_MAX_RSSI_HISTORY = 60
|
||||
|
||||
|
||||
def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 256) -> list[int]:
|
||||
"""Synthesize a compact waveform from rtl_433 level metrics."""
|
||||
points = max(32, min(points, 512))
|
||||
|
||||
# rssi is usually negative; stronger signals are closer to 0 dBm.
|
||||
rssi_norm = min(max(abs(rssi) / 40.0, 0.0), 1.0)
|
||||
snr_norm = min(max((snr + 5.0) / 35.0, 0.0), 1.0)
|
||||
noise_norm = min(max(abs(noise) / 40.0, 0.0), 1.0)
|
||||
|
||||
amplitude = max(0.06, min(1.0, (0.6 * rssi_norm + 0.4 * snr_norm) - (0.22 * noise_norm)))
|
||||
cycles = 3.0 + (snr_norm * 8.0)
|
||||
harmonic = 0.25 + (0.35 * snr_norm)
|
||||
hiss = 0.08 + (0.18 * noise_norm)
|
||||
phase = (time.monotonic() * (1.4 + (snr_norm * 2.2))) % (2.0 * math.pi)
|
||||
|
||||
waveform: list[int] = []
|
||||
for i in range(points):
|
||||
t = i / (points - 1)
|
||||
base = math.sin((2.0 * math.pi * cycles * t) + phase)
|
||||
overtone = math.sin((2.0 * math.pi * (cycles * 2.4) * t) + (phase * 0.7))
|
||||
noise_wobble = math.sin((2.0 * math.pi * (cycles * 7.0) * t) + (phase * 2.1))
|
||||
|
||||
sample = amplitude * (base + (harmonic * overtone) + (hiss * noise_wobble))
|
||||
sample /= (1.0 + harmonic + hiss)
|
||||
packed = int(round(max(-1.0, min(1.0, sample)) * 127.0))
|
||||
waveform.append(max(-127, min(127, packed)))
|
||||
|
||||
return waveform
|
||||
|
||||
|
||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
try:
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# rtl_433 outputs JSON objects, one per line
|
||||
data = json.loads(line)
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# Track RSSI history per device
|
||||
_model = data.get('model', '')
|
||||
_dev_id = data.get('id', '')
|
||||
_rssi_val = data.get('rssi')
|
||||
if _rssi_val is not None and _model:
|
||||
_hist_key = f"{_model}_{_dev_id}"
|
||||
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
||||
hist.append((time.time(), float(_rssi_val)))
|
||||
if len(hist) > _MAX_RSSI_HISTORY:
|
||||
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
||||
|
||||
# Push scope event when signal level data is present
|
||||
rssi = data.get('rssi')
|
||||
snr = data.get('snr')
|
||||
noise = data.get('noise')
|
||||
if rssi is not None or snr is not None:
|
||||
try:
|
||||
rssi_value = float(rssi) if rssi is not None else 0.0
|
||||
snr_value = float(snr) if snr is not None else 0.0
|
||||
noise_value = float(noise) if noise is not None else 0.0
|
||||
app_module.sensor_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rssi': rssi_value,
|
||||
'snr': snr_value,
|
||||
'noise': noise_value,
|
||||
'waveform': _build_scope_waveform(
|
||||
rssi=rssi_value,
|
||||
snr=snr_value,
|
||||
noise=noise_value,
|
||||
),
|
||||
})
|
||||
except (TypeError, ValueError, queue.Full):
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, send as raw
|
||||
app_module.sensor_queue.put({'type': 'raw', 'text': line})
|
||||
|
||||
except Exception as e:
|
||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
global sensor_active_device, sensor_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.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.sensor_lock:
|
||||
app_module.sensor_process = None
|
||||
# Release SDR device
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/status')
|
||||
def sensor_status() -> Response:
|
||||
"""Check if sensor decoder is currently running."""
|
||||
with app_module.sensor_lock:
|
||||
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
|
||||
return jsonify({'running': running})
|
||||
|
||||
|
||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||
def start_sensor() -> Response:
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
return api_error('Sensor already running', 409)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '433.92'))
|
||||
gain = validate_gain(data.get('gain', '0'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return api_error(str(e), 400)
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
# Get SDR type early so we can pass it to claim/release
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Claim local device if not using remote rtl_tcp
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str)
|
||||
if error:
|
||||
return api_error(error, 409, error_type='DEVICE_BUSY')
|
||||
sensor_active_device = device_int
|
||||
sensor_active_sdr_type = sdr_type_str
|
||||
|
||||
# Clear queue
|
||||
while not app_module.sensor_queue.empty():
|
||||
try:
|
||||
app_module.sensor_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Build command via SDR abstraction layer
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if rtl_tcp_host:
|
||||
# Validate and create network device
|
||||
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:
|
||||
# Create local device object
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build ISM band decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
cmd = builder.build_ism_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
gain=float(gain) if gain and gain != 0 else None,
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None,
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
full_cmd = ' '.join(cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
||||
# Disable stats reporting to suppress "row count limit 50 reached" warnings
|
||||
cmd.extend(['-M', 'level', '-M', 'stats:0'])
|
||||
|
||||
try:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(app_module.sensor_process)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Monitor stderr
|
||||
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
||||
_stderr_noise = (
|
||||
'bitbuffer_add_bit',
|
||||
'row count limit',
|
||||
)
|
||||
|
||||
def monitor_stderr():
|
||||
for line in app_module.sensor_process.stderr:
|
||||
err = line.decode('utf-8', errors='replace').strip()
|
||||
if err and not any(noise in err for noise in _stderr_noise):
|
||||
logger.debug(f"[rtl_433] {err}")
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
return api_error('rtl_433 not found. Install with: brew install rtl_433')
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
return api_error(str(e))
|
||||
|
||||
|
||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||
def stop_sensor() -> Response:
|
||||
global sensor_active_device, sensor_active_sdr_type
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
app_module.sensor_process.terminate()
|
||||
try:
|
||||
app_module.sensor_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.sensor_process.kill()
|
||||
app_module.sensor_process = None
|
||||
|
||||
# Release device from registry
|
||||
if sensor_active_device is not None:
|
||||
app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr')
|
||||
sensor_active_device = None
|
||||
sensor_active_sdr_type = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@sensor_bp.route('/stream_sensor')
|
||||
def stream_sensor() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('sensor', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.sensor_queue,
|
||||
channel_key='sensor',
|
||||
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
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/rssi_history')
|
||||
def get_rssi_history() -> Response:
|
||||
"""Return RSSI history for all tracked sensor devices."""
|
||||
result = {}
|
||||
for key, entries in sensor_rssi_history.items():
|
||||
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
||||
return api_success(data={'devices': result})
|
||||
|
||||
@@ -2,16 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.database import (
|
||||
get_setting,
|
||||
set_setting,
|
||||
delete_setting,
|
||||
get_all_settings,
|
||||
get_correlations,
|
||||
get_setting,
|
||||
set_setting,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
from utils.responses import api_error, api_success
|
||||
|
||||
logger = get_logger('intercept.settings')
|
||||
|
||||
@@ -23,16 +28,10 @@ def get_settings() -> Response:
|
||||
"""Get all settings."""
|
||||
try:
|
||||
settings = get_all_settings()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'settings': settings
|
||||
})
|
||||
return api_success(data={'settings': settings})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting settings: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@settings_bp.route('', methods=['POST'])
|
||||
@@ -41,10 +40,7 @@ def save_settings() -> Response:
|
||||
data = request.json or {}
|
||||
|
||||
if not data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No settings provided'
|
||||
}), 400
|
||||
return api_error('No settings provided', 400)
|
||||
|
||||
try:
|
||||
saved = []
|
||||
@@ -56,16 +52,10 @@ def save_settings() -> Response:
|
||||
set_setting(key, value)
|
||||
saved.append(key)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'saved': saved
|
||||
})
|
||||
return api_success(data={'saved': saved})
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving settings: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['GET'])
|
||||
@@ -79,17 +69,10 @@ def get_single_setting(key: str) -> Response:
|
||||
'key': key
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'value': value
|
||||
})
|
||||
return api_success(data={'key': key, 'value': value})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['PUT'])
|
||||
@@ -99,24 +82,14 @@ def update_single_setting(key: str) -> Response:
|
||||
value = data.get('value')
|
||||
|
||||
if value is None and 'value' not in data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Value is required'
|
||||
}), 400
|
||||
return api_error('Value is required', 400)
|
||||
|
||||
try:
|
||||
set_setting(key, value)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'value': value
|
||||
})
|
||||
return api_success(data={'key': key, 'value': value})
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['DELETE'])
|
||||
@@ -125,11 +98,7 @@ def delete_single_setting(key: str) -> Response:
|
||||
try:
|
||||
deleted = delete_setting(key)
|
||||
if deleted:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'deleted': True
|
||||
})
|
||||
return api_success(data={'key': key, 'deleted': True})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'not_found',
|
||||
@@ -137,10 +106,7 @@ def delete_single_setting(key: str) -> Response:
|
||||
}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -154,13 +120,121 @@ def get_device_correlations() -> Response:
|
||||
|
||||
try:
|
||||
correlations = get_correlations(min_confidence)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'correlations': correlations
|
||||
})
|
||||
return api_success(data={'correlations': correlations})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting correlations: {e}")
|
||||
return api_error(str(e), 500)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RTL-SDR DVB Driver Management
|
||||
# =============================================================================
|
||||
|
||||
DVB_MODULES = ['dvb_usb_rtl28xxu', 'rtl2832_sdr', 'rtl2832', 'rtl2830', 'r820t']
|
||||
BLACKLIST_FILE = '/etc/modprobe.d/blacklist-rtlsdr.conf'
|
||||
|
||||
|
||||
@settings_bp.route('/rtlsdr/driver-status', methods=['GET'])
|
||||
def check_dvb_driver_status() -> Response:
|
||||
"""Check if DVB kernel drivers are loaded and blocking RTL-SDR devices."""
|
||||
if sys.platform != 'linux':
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
'status': 'success',
|
||||
'platform': sys.platform,
|
||||
'issue_detected': False,
|
||||
'message': 'DVB driver conflict only affects Linux systems'
|
||||
})
|
||||
|
||||
# Check which DVB modules are currently loaded
|
||||
loaded_modules = []
|
||||
try:
|
||||
result = subprocess.run(['lsmod'], capture_output=True, text=True, timeout=5)
|
||||
lsmod_output = result.stdout
|
||||
for mod in DVB_MODULES:
|
||||
if mod in lsmod_output:
|
||||
loaded_modules.append(mod)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not check loaded modules: {e}")
|
||||
|
||||
# Check if blacklist file exists
|
||||
blacklist_exists = os.path.exists(BLACKLIST_FILE)
|
||||
|
||||
# Check blacklist file contents
|
||||
blacklist_contents = []
|
||||
if blacklist_exists:
|
||||
try:
|
||||
with open(BLACKLIST_FILE) as f:
|
||||
blacklist_contents = [line.strip() for line in f if line.strip() and not line.startswith('#')]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
issue_detected = len(loaded_modules) > 0
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'platform': 'linux',
|
||||
'issue_detected': issue_detected,
|
||||
'loaded_modules': loaded_modules,
|
||||
'blacklist_file_exists': blacklist_exists,
|
||||
'blacklist_contents': blacklist_contents,
|
||||
'message': 'DVB drivers are claiming RTL-SDR devices' if issue_detected else 'No DVB driver conflict detected'
|
||||
})
|
||||
|
||||
|
||||
@settings_bp.route('/rtlsdr/blacklist-drivers', methods=['POST'])
|
||||
def blacklist_dvb_drivers() -> Response:
|
||||
"""Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices."""
|
||||
if sys.platform != 'linux':
|
||||
return api_error('This feature is only available on Linux', 400)
|
||||
|
||||
# Check if we have permission (need to be running as root or with sudo)
|
||||
if os.geteuid() != 0:
|
||||
return api_error('Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t', 403)
|
||||
|
||||
errors = []
|
||||
successes = []
|
||||
|
||||
# Create blacklist file if it doesn't exist
|
||||
if not os.path.exists(BLACKLIST_FILE):
|
||||
try:
|
||||
blacklist_content = """# RTL-SDR blacklist - prevents DVB drivers from claiming RTL-SDR devices
|
||||
# Created by INTERCEPT
|
||||
blacklist dvb_usb_rtl28xxu
|
||||
blacklist rtl2832
|
||||
blacklist rtl2830
|
||||
blacklist r820t
|
||||
"""
|
||||
with open(BLACKLIST_FILE, 'w') as f:
|
||||
f.write(blacklist_content)
|
||||
successes.append(f'Created {BLACKLIST_FILE}')
|
||||
except Exception as e:
|
||||
errors.append(f'Failed to create blacklist file: {e}')
|
||||
|
||||
# Unload the modules
|
||||
for mod in DVB_MODULES:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['modprobe', '-r', mod],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
successes.append(f'Unloaded module: {mod}')
|
||||
# returncode != 0 is OK - module might not be loaded
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not unload {mod}: {e}")
|
||||
|
||||
if errors:
|
||||
return jsonify({
|
||||
'status': 'partial',
|
||||
'message': 'Some operations failed. Please unplug and replug your RTL-SDR device.',
|
||||
'successes': successes,
|
||||
'errors': errors
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'message': 'DVB drivers blacklisted. Please unplug and replug your RTL-SDR device.',
|
||||
'successes': successes
|
||||
})
|
||||
|
||||