Compare commits
768 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 007400d2a7 | |||
| 1f60e64217 | |||
| 69de7e4afd | |||
| 29025059af | |||
| 6229c25872 | |||
| 73ac74a9d6 | |||
| ebb1e233d8 | |||
| e719e32c73 | |||
| 46ab5fe78d | |||
| dc467aef91 | |||
| 0bc915fe1f | |||
| b7f9ad786a | |||
| 6c80521cf8 | |||
| a174884269 | |||
| f3b1865a79 | |||
| 6c99651ac9 | |||
| 0aaf888dd1 | |||
| d947ce17a3 | |||
| 97c957b70f | |||
| 82830c86ac | |||
| d8e4189100 | |||
| 6bcde56525 | |||
| 88ebe3c337 | |||
| 5f4d1b05a8 | |||
| 370c46bddb | |||
| 47b5e03bbb | |||
| 556ca59a99 | |||
| 81c5af474d | |||
| cdaee3f62f | |||
| aab4288f67 | |||
| bab49e4442 | |||
| 7608aca681 | |||
| 58907bdc4d | |||
| 8dfd92082c | |||
| e39304da90 | |||
| 31fd3f3f63 | |||
| e1ab24b36b | |||
| f5b92ddcf9 | |||
| d9ee87d4b4 | |||
| 5e83db54ac | |||
| de7b12a759 | |||
| 1236011174 | |||
| b60f2cdf81 | |||
| 0c310ab068 | |||
| a87f66cc0c | |||
| c05756357f | |||
| f4b4b5febd | |||
| 805290b17f | |||
| fecc2237b8 | |||
| 471cc1ee94 | |||
| 41ebf59964 | |||
| a5e9a3e1ce | |||
| 23689d9fe1 | |||
| 601d432fbf | |||
| a21e9c508e | |||
| 55b0c0509d | |||
| 563c6b79fa | |||
| 8d9e5f9d56 | |||
| c0f6ccaf2a | |||
| 9b3e4ec7fb | |||
| 9d45eb21a4 | |||
| bcf8fe59f5 | |||
| 5b411456c7 | |||
| 4432816934 | |||
| 5277537445 | |||
| e73ce8cd8f | |||
| 120015d133 | |||
| f85cf61019 | |||
| 41226d173a | |||
| 83244c85fe | |||
| 27dd868d97 | |||
| 45b35ea5b0 | |||
| ac8b9f82cd | |||
| 9d0e417f2a | |||
| 40369ccb7b | |||
| 61ef3f7bdd | |||
| bcb1a825d3 | |||
| 1f7a3fe664 | |||
| dcd855896e | |||
| 4778134ab6 | |||
| 300b19d1d6 | |||
| 945ae33361 | |||
| dbbcb6c5cc | |||
| 016959ad7c | |||
| 7a9599786c | |||
| fa537390c5 | |||
| bb24bdb06c | |||
| b55100d5c3 | |||
| 02cb9c751a | |||
| 8555938f52 | |||
| a2a3ea62f1 | |||
| 0d5310eb4b | |||
| 5c6bd5d65a | |||
| dcb1b4e3a6 | |||
| b5547d3fa9 | |||
| a5a2692a5f | |||
| 7a112c84be | |||
| b3b3566a27 | |||
| f77c501db6 |
@@ -15,6 +15,7 @@ venv/
|
||||
.eggs/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
.uv
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -32,6 +33,10 @@ htmlcov/
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Local Postgres data
|
||||
pgdata/
|
||||
pgdata.bak/
|
||||
|
||||
# Captured files (don't include in image)
|
||||
*.cap
|
||||
*.pcap
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Uncomment and set to use external storage for ADS-B history
|
||||
# PGDATA_PATH=/mnt/external/intercept/pgdata
|
||||
@@ -0,0 +1 @@
|
||||
buy_me_a_coffee: smittix
|
||||
@@ -8,11 +8,20 @@ env/
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
uv.lock
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
pager_messages.log
|
||||
|
||||
# Local data
|
||||
downloads/
|
||||
pgdata/
|
||||
|
||||
# Local data
|
||||
downloads/
|
||||
pgdata/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -28,3 +37,31 @@ Thumbs.db
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# 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/
|
||||
|
||||
# SDR capture files (large IQ recordings)
|
||||
data/subghz/captures/
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to iNTERCEPT will be documented in this file.
|
||||
|
||||
## [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
|
||||
- **Landing Page** - Animated welcome screen with logo reveal and "See the Invisible" tagline
|
||||
- **New Branding** - Redesigned logo featuring 'i' with signal wave brackets
|
||||
- **Logo Assets** - Full-size SVG logos in `/static/img/` for external use
|
||||
- **Instagram Promo** - Animated HTML promo video template in `/promo/` directory
|
||||
- **Listening Post Scanner** - Fully functional frequency scanning with signal detection
|
||||
- Scan button toggles between start/stop states
|
||||
- Signal hits logged with Listen button to tune directly
|
||||
- Proper 4-column display (Time, Frequency, Modulation, Action)
|
||||
|
||||
### Changed
|
||||
- **Rebranding** - Application renamed from "INTERCEPT" to "iNTERCEPT"
|
||||
- **Updated Tagline** - "Signal Intelligence & Counter Surveillance Platform"
|
||||
- **Setup Script** - Now installs Python packages via apt first (more reliable on Debian/Ubuntu)
|
||||
- Uses `--system-site-packages` for venv to leverage apt packages
|
||||
- Added fallback logic when pip fails
|
||||
- **Troubleshooting Docs** - Added sections for pip install issues and apt alternatives
|
||||
|
||||
### Fixed
|
||||
- **Tuning Dial Audio** - Fixed audio stopping when using tuning knob
|
||||
- Added restart prevention flags to avoid overlapping restarts
|
||||
- Increased debounce time for smoother operation
|
||||
- Added silent mode for programmatic value changes
|
||||
- **Scanner Signal Hits** - Fixed table column count and colspan
|
||||
- **Favicon** - Updated to new 'i' logo design
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0] - 2026-01-06
|
||||
|
||||
### Added
|
||||
- **Listening Post Mode** - New frequency scanner with automatic signal detection
|
||||
- Scans frequency ranges and stops on detected signals
|
||||
- Real-time audio monitoring with ffmpeg integration
|
||||
- Skip button to continue scanning after signal detection
|
||||
- Configurable dwell time, squelch, and step size
|
||||
- Preset frequency bands (FM broadcast, Air band, Marine, etc.)
|
||||
- Activity log of detected signals
|
||||
- **Aircraft Dashboard Improvements**
|
||||
- Dependency warning when rtl_fm or ffmpeg not installed
|
||||
- Auto-restart audio when switching frequencies
|
||||
- Fixed toolbar overflow with custom frequency input
|
||||
- **Device Correlation** - Match WiFi and Bluetooth devices by manufacturer
|
||||
- **Settings System** - SQLite-based persistent settings storage
|
||||
- **Comprehensive Test Suite** - Added tests for routes, validation, correlation, database
|
||||
|
||||
### Changed
|
||||
- **Documentation Overhaul**
|
||||
- Simplified README with clear macOS and Debian installation steps
|
||||
- Added Docker installation option
|
||||
- Complete tool reference table in HARDWARE.md
|
||||
- Removed redundant/confusing content
|
||||
- **Setup Script Rewrite**
|
||||
- Full macOS support with Homebrew auto-installation
|
||||
- Improved Debian/Ubuntu package detection
|
||||
- Added ffmpeg to tool checks
|
||||
- Better error messages with platform-specific install commands
|
||||
- **Dockerfile Updated**
|
||||
- Added ffmpeg for Listening Post audio encoding
|
||||
- Added dump1090 with fallback for different package names
|
||||
|
||||
### Fixed
|
||||
- SoapySDR device detection for RTL-SDR and HackRF
|
||||
- Aircraft dashboard toolbar layout when using custom frequency input
|
||||
- Frequency switching now properly stops/restarts audio
|
||||
|
||||
### Technical
|
||||
- Added `utils/constants.py` for centralized configuration values
|
||||
- Added `utils/database.py` for SQLite settings storage
|
||||
- Added `utils/correlation.py` for device correlation logic
|
||||
- Added `routes/listening_post.py` for scanner endpoints
|
||||
- Added `routes/settings.py` for settings API
|
||||
- Added `routes/correlation.py` for correlation API
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] - 2026-12-29
|
||||
|
||||
### Added
|
||||
- Airspy SDR support
|
||||
- GPS coordinate persistence
|
||||
- SoapySDR device detection improvements
|
||||
|
||||
### Fixed
|
||||
- RTL-SDR and HackRF detection via SoapySDR
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] - 2026-12-18
|
||||
|
||||
### Added
|
||||
- Satellite tracking with TLE data
|
||||
- Full-screen dashboard for aircraft radar
|
||||
- Full-screen dashboard for satellite tracking
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2026-12-15
|
||||
|
||||
### Initial Release
|
||||
- Pager decoding (POCSAG/FLEX)
|
||||
- 433MHz sensor decoding
|
||||
- ADS-B aircraft tracking
|
||||
- WiFi reconnaissance
|
||||
- Bluetooth scanning
|
||||
- Multi-SDR support (RTL-SDR, LimeSDR, HackRF)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# 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
|
||||
# Initial setup (installs dependencies and configures SDR tools)
|
||||
./setup.sh
|
||||
|
||||
# Run the application (requires sudo for SDR/network access)
|
||||
sudo -E venv/bin/python intercept.py
|
||||
|
||||
# Or activate venv first
|
||||
source venv/bin/activate
|
||||
sudo -E python intercept.py
|
||||
```
|
||||
|
||||
### 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
|
||||
- `intercept.py` - Main entry point script
|
||||
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure
|
||||
|
||||
### 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.
|
||||
|
||||
**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.)
|
||||
- `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.
|
||||
@@ -3,22 +3,230 @@
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
LABEL maintainer="INTERCEPT Project"
|
||||
LABEL description="Signal Intelligence Platform for SDR monitoring"
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies for RTL-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 system dependencies for SDR 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 \
|
||||
wireless-tools \
|
||||
# Bluetooth tools
|
||||
bluez \
|
||||
# Cleanup
|
||||
bluetooth \
|
||||
# GPS support
|
||||
gpsd-clients \
|
||||
# Utilities
|
||||
# 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 \
|
||||
hackrf \
|
||||
# Utilities
|
||||
curl \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Build dump1090-fa and acarsdec from source (packages not available in slim repos)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
git \
|
||||
pkg-config \
|
||||
cmake \
|
||||
libncurses-dev \
|
||||
libsndfile1-dev \
|
||||
# GTK is required for slowrx (SSTV decoder GUI dependency).
|
||||
# Note: slowrx is kept for backwards compatibility, but the pure Python
|
||||
# SSTV decoder in utils/sstv/ is now the primary implementation.
|
||||
# GTK can be removed if slowrx is deprecated in future releases.
|
||||
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-dev \
|
||||
liblapack-dev \
|
||||
libcodec2-dev \
|
||||
# Build dump1090
|
||||
&& 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 /usr/bin/dump1090-fa \
|
||||
&& ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \
|
||||
&& rm -rf /tmp/dump1090 \
|
||||
# Build AIS-catcher
|
||||
&& cd /tmp \
|
||||
&& git clone https://github.com/jvde-github/AIS-catcher.git \
|
||||
&& cd AIS-catcher \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make \
|
||||
&& cp AIS-catcher /usr/bin/AIS-catcher \
|
||||
&& cd /tmp \
|
||||
&& rm -rf /tmp/AIS-catcher \
|
||||
# Build readsb
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/wiedehopf/readsb.git \
|
||||
&& cd readsb \
|
||||
&& make BLADERF=no PLUTOSDR=no SOAPYSDR=yes \
|
||||
&& cp readsb /usr/bin/readsb \
|
||||
&& cd /tmp \
|
||||
&& rm -rf /tmp/readsb \
|
||||
# Build rx_tools
|
||||
&& cd /tmp \
|
||||
&& git clone https://github.com/rxseger/rx_tools.git \
|
||||
&& cd rx_tools \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make \
|
||||
&& make install \
|
||||
&& cd /tmp \
|
||||
&& rm -rf /tmp/rx_tools \
|
||||
# Build acarsdec
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
||||
&& cd acarsdec \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. -Drtl=ON \
|
||||
&& make \
|
||||
&& cp acarsdec /usr/bin/acarsdec \
|
||||
&& rm -rf /tmp/acarsdec \
|
||||
# Build slowrx (SSTV decoder) — pinned to known-good commit
|
||||
&& cd /tmp \
|
||||
&& git clone https://github.com/windytan/slowrx.git \
|
||||
&& cd slowrx \
|
||||
&& git checkout ca6d7012 \
|
||||
&& make \
|
||||
&& install -m 0755 slowrx /usr/local/bin/slowrx \
|
||||
&& rm -rf /tmp/slowrx \
|
||||
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
|
||||
&& cd SatDump \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \
|
||||
&& 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 \
|
||||
&& cd /tmp \
|
||||
&& rm -rf /tmp/SatDump \
|
||||
# Build rtlamr (utility meter decoder - requires Go)
|
||||
&& 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 /usr/bin/rtlamr \
|
||||
&& rm -rf /usr/local/go /tmp/gopath \
|
||||
# Build mbelib (required by DSD)
|
||||
&& cd /tmp \
|
||||
&& git clone https://github.com/lwvmobile/mbelib.git \
|
||||
&& cd mbelib \
|
||||
&& (git checkout ambe_tones || true) \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make -j$(nproc) \
|
||||
&& make install \
|
||||
&& ldconfig \
|
||||
&& rm -rf /tmp/mbelib \
|
||||
# Build DSD-FME (Digital Speech Decoder for DMR/P25)
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git \
|
||||
&& cd dsd-fme \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make -j$(nproc) \
|
||||
&& make install \
|
||||
&& ldconfig \
|
||||
&& rm -rf /tmp/dsd-fme \
|
||||
# Cleanup build tools to reduce image size
|
||||
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
|
||||
&& apt-get remove -y \
|
||||
build-essential \
|
||||
git \
|
||||
pkg-config \
|
||||
cmake \
|
||||
libncurses-dev \
|
||||
libsndfile1-dev \
|
||||
libgtk-3-dev \
|
||||
libasound2-dev \
|
||||
libpng-dev \
|
||||
libtiff-dev \
|
||||
libjemalloc-dev \
|
||||
libvolk-dev \
|
||||
libnng-dev \
|
||||
libzstd-dev \
|
||||
libsoapysdr-dev \
|
||||
libhackrf-dev \
|
||||
liblimesuite-dev \
|
||||
libsqlite3-dev \
|
||||
libcurl4-openssl-dev \
|
||||
zlib1g-dev \
|
||||
libzmq3-dev \
|
||||
libpulse-dev \
|
||||
libfftw3-dev \
|
||||
liblapack-dev \
|
||||
libcodec2-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
@@ -28,13 +236,21 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create data directory for persistence
|
||||
RUN mkdir -p /app/data /app/data/weather_sat
|
||||
|
||||
# Expose web interface port
|
||||
EXPOSE 5050
|
||||
|
||||
# Environment variables with defaults
|
||||
ENV INTERCEPT_HOST=0.0.0.0 \
|
||||
INTERCEPT_PORT=5050 \
|
||||
INTERCEPT_LOG_LEVEL=INFO
|
||||
INTERCEPT_LOG_LEVEL=INFO \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# Health check using the new endpoint
|
||||
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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,113 +7,228 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Signal Intelligence Platform</strong><br>
|
||||
A web-based front-end for signal intelligence tools.
|
||||
Support the developer of this open-source project
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="static/images/screenshots/screenshot2.png" alt="Screenshot">
|
||||
<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/intercept-main.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## What is INTERCEPT?
|
||||
|
||||
INTERCEPT provides a unified web interface for signal intelligence tools:
|
||||
## Features
|
||||
|
||||
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
||||
- **433MHz Sensors** - Weather stations, TPMS, IoT via rtl_433
|
||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map
|
||||
- **Satellite Tracking** - Pass prediction using TLE data
|
||||
- **WiFi Recon** - Monitor mode scanning via aircrack-ng
|
||||
- **Bluetooth Scanning** - Device discovery and tracker detection
|
||||
- **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
|
||||
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
||||
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
||||
- **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 rtl_amr
|
||||
- **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 (with Ubertooth support)
|
||||
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
|
||||
- **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
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
## Installation / Debian / Ubuntu / MacOS
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
```
|
||||
|
||||
**1. Clone and run:**
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo python3 intercept.py
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
Open http://localhost:5050 in your browser.
|
||||
|
||||
<details>
|
||||
<summary><strong>Alternative: Install with uv</strong></summary>
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
uv venv
|
||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||
uv sync
|
||||
sudo python3 intercept.py
|
||||
docker compose --profile basic up -d --build
|
||||
```
|
||||
</details>
|
||||
|
||||
> **Note:** Requires Python 3.9+ and external tools. See [Hardware & Installation](docs/HARDWARE.md).
|
||||
> **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
|
||||
```
|
||||
|
||||
### ADS-B History (Optional)
|
||||
|
||||
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
|
||||
|
||||
```bash
|
||||
# Start with ADS-B history and Postgres
|
||||
docker compose --profile history up -d
|
||||
```
|
||||
|
||||
Set the following environment variables (for example in a `.env` file):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### Other ADS-B Settings
|
||||
|
||||
Set these as environment variables for either local installs or Docker:
|
||||
|
||||
| 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 \
|
||||
python app.py
|
||||
```
|
||||
|
||||
**Docker example (.env)**
|
||||
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### Open the Interface
|
||||
|
||||
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
||||
|
||||
The credentials can be change in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
## Hardware Requirements
|
||||
|
||||
- **Python 3.9+**
|
||||
- **SDR Hardware** - RTL-SDR (~$25), LimeSDR, or HackRF
|
||||
- **External Tools** - rtl-sdr, multimon-ng, rtl_433, dump1090, aircrack-ng
|
||||
| Hardware | Purpose | Price |
|
||||
|----------|---------|-------|
|
||||
| **RTL-SDR** | Required for all SDR features | ~$25-35 |
|
||||
| **WiFi adapter** | Must support promiscuous (monitor) mode | ~$20-40 |
|
||||
| **Bluetooth adapter** | Device scanning (usually built-in) | - |
|
||||
| **GPS** | Any Linux supported GPS Unit | ~10 |
|
||||
|
||||
Quick install (Ubuntu/Debian):
|
||||
```bash
|
||||
sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez
|
||||
```
|
||||
Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
|
||||
|
||||
| :exclamation: Not using an RTL-SDR Device? |
|
||||
|-----------------------------------------------
|
||||
|Intercept supports any device that SoapySDR supports. You must however have the correct module for your device installed! For example if you have an SDRPlay device you'd need to install soapysdr-module-sdrplay.
|
||||
|
||||
| :exclamation: GPS Usage |
|
||||
|-----------------------------------------------
|
||||
|gpsd is needed for real time location. Intercept automatically checks to see if you're running gpsd in the background when any maps are rendered.
|
||||
|
||||
---
|
||||
|
||||
## Discord Server
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/EyeksEJmWE">Join our Discord</a>
|
||||
</p>
|
||||
|
||||
See [Hardware & Installation](docs/HARDWARE.md) for full details.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Features](docs/FEATURES.md) | Complete feature list for all modules |
|
||||
| [Usage Guide](docs/USAGE.md) | Detailed instructions for each mode |
|
||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Solutions for common issues |
|
||||
| [Hardware & Installation](docs/HARDWARE.md) | SDR hardware and tool installation |
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
This project was developed using AI as a coding partner, combining human direction with AI-assisted implementation. The goal: make Software Defined Radio more accessible by providing a clean, unified interface for common SDR tools.
|
||||
|
||||
Contributions and improvements welcome.
|
||||
- [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
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**This software is for educational purposes only.**
|
||||
This project was developed using AI as a coding partner, combining human direction with AI-assisted implementation. The goal: make Software Defined Radio more accessible by providing a clean, unified interface for common SDR tools.
|
||||
|
||||
**This software is for educational and authorized testing purposes only.**
|
||||
|
||||
- Only use with proper authorization
|
||||
- Intercepting communications without consent may be illegal
|
||||
- WiFi/Bluetooth attacks require explicit permission
|
||||
- You are responsible for compliance with applicable laws
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE)
|
||||
Apache 2.0 License - see [LICENSE](LICENSE)
|
||||
|
||||
## Author
|
||||
|
||||
@@ -125,6 +240,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) |
|
||||
[rtl_amr](https://github.com/bemasher/rtlamr) |
|
||||
[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/)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": "2026-02-01_ba81b697",
|
||||
"downloaded": "2026-02-04T17:06:54.806043Z"
|
||||
}
|
||||
@@ -9,6 +9,8 @@ from __future__ import annotations
|
||||
import sys
|
||||
import site
|
||||
|
||||
from utils.database import get_db
|
||||
|
||||
# Ensure user site-packages is available (may be disabled when running as root/sudo)
|
||||
if not site.ENABLE_USER_SITE:
|
||||
user_site = site.getusersitepackages()
|
||||
@@ -23,16 +25,91 @@ import subprocess
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, render_template, jsonify, send_file, Response, request
|
||||
|
||||
from config import VERSION
|
||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
|
||||
from werkzeug.security import check_password_hash
|
||||
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||
from utils.process import cleanup_stale_processes
|
||||
from utils.sdr import SDRFactory
|
||||
|
||||
from utils.cleanup import DataStore, cleanup_manager
|
||||
from utils.constants import (
|
||||
MAX_AIRCRAFT_AGE_SECONDS,
|
||||
MAX_WIFI_NETWORK_AGE_SECONDS,
|
||||
MAX_BT_DEVICE_AGE_SECONDS,
|
||||
MAX_VESSEL_AGE_SECONDS,
|
||||
MAX_DSC_MESSAGE_AGE_SECONDS,
|
||||
MAX_DEAUTH_ALERTS_AGE_SECONDS,
|
||||
QUEUE_MAX_SIZE,
|
||||
)
|
||||
import logging
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
# Track application start time for uptime calculation
|
||||
import time as _time
|
||||
_app_start_time = _time.time()
|
||||
logger = logging.getLogger('intercept.database')
|
||||
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
app.secret_key = "signals_intelligence_secret" # Required for flash messages
|
||||
|
||||
# Set up rate limiting
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address, # Identifies the user by their IP
|
||||
app=app,
|
||||
storage_uri="memory://", # Use RAM memory (change to redis:// etc. for distributed setups)
|
||||
)
|
||||
|
||||
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
||||
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
||||
|
||||
# ============================================
|
||||
# ERROR HANDLERS
|
||||
# ============================================
|
||||
@app.errorhandler(429)
|
||||
def ratelimit_handler(e):
|
||||
logger.warning(f"Rate limit exceeded for IP: {request.remote_addr}")
|
||||
flash("Too many login attempts. Please wait one minute before trying again.", "error")
|
||||
return render_template('login.html', version=VERSION), 429
|
||||
|
||||
# ============================================
|
||||
# SECURITY HEADERS
|
||||
# ============================================
|
||||
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
"""Add security headers to all responses."""
|
||||
# Prevent MIME type sniffing
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
# Prevent clickjacking
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
# Enable XSS filter
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
# Referrer policy
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
# Permissions policy (disable unnecessary features)
|
||||
response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()'
|
||||
return response
|
||||
|
||||
|
||||
# ============================================
|
||||
# CONTEXT PROCESSORS
|
||||
# ============================================
|
||||
|
||||
@app.context_processor
|
||||
def inject_offline_settings():
|
||||
"""Inject offline settings into all templates."""
|
||||
from utils.database import get_setting
|
||||
return {
|
||||
'offline_settings': {
|
||||
'enabled': get_setting('offline.enabled', False),
|
||||
'assets_source': get_setting('offline.assets_source', 'cdn'),
|
||||
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
|
||||
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
|
||||
'tile_server_url': get_setting('offline.tile_server_url', '')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# GLOBAL PROCESS MANAGEMENT
|
||||
@@ -40,34 +117,80 @@ app = Flask(__name__)
|
||||
|
||||
# Pager decoder
|
||||
current_process = None
|
||||
output_queue = queue.Queue()
|
||||
output_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
process_lock = threading.Lock()
|
||||
|
||||
# RTL_433 sensor
|
||||
sensor_process = None
|
||||
sensor_queue = queue.Queue()
|
||||
sensor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
sensor_lock = threading.Lock()
|
||||
|
||||
# WiFi
|
||||
wifi_process = None
|
||||
wifi_queue = queue.Queue()
|
||||
wifi_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
wifi_lock = threading.Lock()
|
||||
|
||||
# Bluetooth
|
||||
bt_process = None
|
||||
bt_queue = queue.Queue()
|
||||
bt_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
bt_lock = threading.Lock()
|
||||
|
||||
# ADS-B aircraft
|
||||
adsb_process = None
|
||||
adsb_queue = queue.Queue()
|
||||
adsb_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
adsb_lock = threading.Lock()
|
||||
|
||||
# Satellite/Iridium
|
||||
satellite_process = None
|
||||
satellite_queue = queue.Queue()
|
||||
satellite_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
satellite_lock = threading.Lock()
|
||||
|
||||
# ACARS aircraft messaging
|
||||
acars_process = None
|
||||
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
acars_lock = threading.Lock()
|
||||
|
||||
# APRS amateur radio tracking
|
||||
aprs_process = None
|
||||
aprs_rtl_process = None
|
||||
aprs_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
aprs_lock = threading.Lock()
|
||||
|
||||
# RTLAMR utility meter reading
|
||||
rtlamr_process = None
|
||||
rtlamr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
rtlamr_lock = threading.Lock()
|
||||
|
||||
# AIS vessel tracking
|
||||
ais_process = None
|
||||
ais_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
ais_lock = threading.Lock()
|
||||
|
||||
# DSC (Digital Selective Calling)
|
||||
dsc_process = None
|
||||
dsc_rtl_process = None
|
||||
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
dsc_lock = threading.Lock()
|
||||
|
||||
# DMR / Digital Voice
|
||||
dmr_process = None
|
||||
dmr_rtl_process = None
|
||||
dmr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
dmr_lock = threading.Lock()
|
||||
|
||||
# TSCM (Technical Surveillance Countermeasures)
|
||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
tscm_lock = threading.Lock()
|
||||
|
||||
# SubGHz Transceiver (HackRF)
|
||||
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
subghz_lock = threading.Lock()
|
||||
|
||||
# Deauth Attack Detection
|
||||
deauth_detector = None
|
||||
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
deauth_detector_lock = threading.Lock()
|
||||
|
||||
# ============================================
|
||||
# GLOBAL STATE DICTIONARIES
|
||||
# ============================================
|
||||
@@ -76,38 +199,184 @@ satellite_lock = threading.Lock()
|
||||
logging_enabled = False
|
||||
log_file_path = 'pager_messages.log'
|
||||
|
||||
# WiFi state
|
||||
# WiFi state - using DataStore for automatic cleanup
|
||||
wifi_monitor_interface = None
|
||||
wifi_networks = {} # BSSID -> network info
|
||||
wifi_clients = {} # Client MAC -> client info
|
||||
wifi_handshakes = [] # Captured handshakes
|
||||
wifi_networks = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_networks')
|
||||
wifi_clients = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_clients')
|
||||
wifi_handshakes = [] # Captured handshakes (list, not auto-cleaned)
|
||||
|
||||
# Bluetooth state
|
||||
# Bluetooth state - using DataStore for automatic cleanup
|
||||
bt_interface = None
|
||||
bt_devices = {} # MAC -> device info
|
||||
bt_beacons = {} # MAC -> beacon info (AirTags, Tiles, iBeacons)
|
||||
bt_services = {} # MAC -> list of services
|
||||
bt_devices = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_devices')
|
||||
bt_beacons = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_beacons')
|
||||
bt_services = {} # MAC -> list of services (not auto-cleaned, user-requested)
|
||||
|
||||
# Aircraft (ADS-B) state
|
||||
adsb_aircraft = {} # ICAO hex -> aircraft info
|
||||
# Aircraft (ADS-B) state - using DataStore for automatic cleanup
|
||||
adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_aircraft')
|
||||
|
||||
# Vessel (AIS) state - using DataStore for automatic cleanup
|
||||
ais_vessels = DataStore(max_age_seconds=MAX_VESSEL_AGE_SECONDS, name='ais_vessels')
|
||||
|
||||
# DSC (Digital Selective Calling) state - using DataStore for automatic cleanup
|
||||
dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages')
|
||||
|
||||
# Deauth alerts - using DataStore for automatic cleanup
|
||||
deauth_alerts = DataStore(max_age_seconds=MAX_DEAUTH_ALERTS_AGE_SECONDS, name='deauth_alerts')
|
||||
|
||||
# Satellite state
|
||||
satellite_passes = [] # Predicted satellite passes
|
||||
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
||||
|
||||
# Register data stores with cleanup manager
|
||||
cleanup_manager.register(wifi_networks)
|
||||
cleanup_manager.register(wifi_clients)
|
||||
cleanup_manager.register(bt_devices)
|
||||
cleanup_manager.register(bt_beacons)
|
||||
cleanup_manager.register(adsb_aircraft)
|
||||
cleanup_manager.register(ais_vessels)
|
||||
cleanup_manager.register(dsc_messages)
|
||||
cleanup_manager.register(deauth_alerts)
|
||||
|
||||
# ============================================
|
||||
# SDR DEVICE REGISTRY
|
||||
# ============================================
|
||||
# Tracks which mode is using which SDR device to prevent conflicts
|
||||
# Key: device_index (int), Value: mode_name (str)
|
||||
sdr_device_registry: dict[int, str] = {}
|
||||
sdr_device_registry_lock = threading.Lock()
|
||||
|
||||
|
||||
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
||||
"""Claim an SDR device for a mode.
|
||||
|
||||
Checks the in-app registry first, then probes the USB device to
|
||||
catch stale handles held by external processes (e.g. a leftover
|
||||
rtl_fm from a previous crash).
|
||||
|
||||
Args:
|
||||
device_index: The SDR device index to claim
|
||||
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
||||
|
||||
Returns:
|
||||
Error message if device is in use, None if successfully claimed
|
||||
"""
|
||||
with sdr_device_registry_lock:
|
||||
if device_index in sdr_device_registry:
|
||||
in_use_by = sdr_device_registry[device_index]
|
||||
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
||||
|
||||
# Probe the USB device to catch external processes holding the handle
|
||||
try:
|
||||
from utils.sdr.detection import probe_rtlsdr_device
|
||||
usb_error = probe_rtlsdr_device(device_index)
|
||||
if usb_error:
|
||||
return usb_error
|
||||
except Exception:
|
||||
pass # If probe fails, let the caller proceed normally
|
||||
|
||||
sdr_device_registry[device_index] = mode_name
|
||||
return None
|
||||
|
||||
|
||||
def release_sdr_device(device_index: int) -> None:
|
||||
"""Release an SDR device from the registry.
|
||||
|
||||
Args:
|
||||
device_index: The SDR device index to release
|
||||
"""
|
||||
with sdr_device_registry_lock:
|
||||
sdr_device_registry.pop(device_index, None)
|
||||
|
||||
|
||||
def get_sdr_device_status() -> dict[int, str]:
|
||||
"""Get current SDR device allocations.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping device indices to mode names
|
||||
"""
|
||||
with sdr_device_registry_lock:
|
||||
return dict(sdr_device_registry)
|
||||
|
||||
|
||||
# ============================================
|
||||
# MAIN ROUTES
|
||||
# ============================================
|
||||
|
||||
@app.before_request
|
||||
def require_login():
|
||||
# Routes that don't require login (to avoid infinite redirect loop)
|
||||
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
|
||||
|
||||
# Allow audio streaming endpoints without session auth
|
||||
if request.path.startswith('/listening/audio/'):
|
||||
return None
|
||||
|
||||
# Allow WebSocket upgrade requests (page load already required auth)
|
||||
if request.path.startswith('/ws/'):
|
||||
return None
|
||||
|
||||
# Controller API endpoints use API key auth, not session auth
|
||||
# Allow agent push/pull endpoints without session login
|
||||
if request.path.startswith('/controller/'):
|
||||
return None # Skip session check, controller routes handle their own auth
|
||||
|
||||
# If user is not logged in and the current route is not allowed...
|
||||
if 'logged_in' not in session and request.endpoint not in allowed_routes:
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
session.pop('logged_in', None)
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
@limiter.limit("5 per minute") # Limit to 5 login attempts per minute per IP
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
|
||||
# Connect to DB and find user
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
'SELECT password_hash, role FROM users WHERE username = ?',
|
||||
(username,)
|
||||
)
|
||||
user = cursor.fetchone()
|
||||
|
||||
# Verify user exists and password is correct
|
||||
if user and check_password_hash(user['password_hash'], password):
|
||||
# Store data in session
|
||||
session['logged_in'] = True
|
||||
session['username'] = username
|
||||
session['role'] = user['role']
|
||||
|
||||
logger.info(f"User '{username}' logged in successfully.")
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
logger.warning(f"Failed login attempt for username: {username}")
|
||||
flash("ACCESS DENIED: INVALID CREDENTIALS", "error")
|
||||
|
||||
return render_template('login.html', version=VERSION)
|
||||
|
||||
@app.route('/')
|
||||
def index() -> str:
|
||||
tools = {
|
||||
'rtl_fm': check_tool('rtl_fm'),
|
||||
'multimon': check_tool('multimon-ng'),
|
||||
'rtl_433': check_tool('rtl_433')
|
||||
'rtl_433': check_tool('rtl_433'),
|
||||
'rtlamr': check_tool('rtlamr')
|
||||
}
|
||||
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
|
||||
return render_template('index.html', tools=tools, devices=devices, version=VERSION)
|
||||
return render_template(
|
||||
'index.html',
|
||||
tools=tools,
|
||||
devices=devices,
|
||||
version=VERSION,
|
||||
changelog=CHANGELOG,
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
default_latitude=DEFAULT_LATITUDE,
|
||||
default_longitude=DEFAULT_LONGITUDE,
|
||||
)
|
||||
|
||||
|
||||
@app.route('/favicon.svg')
|
||||
@@ -122,6 +391,136 @@ def get_devices() -> Response:
|
||||
return jsonify([d.to_dict() for d in devices])
|
||||
|
||||
|
||||
@app.route('/devices/status')
|
||||
def get_devices_status() -> Response:
|
||||
"""Get all SDR devices with usage status."""
|
||||
devices = SDRFactory.detect_devices()
|
||||
registry = get_sdr_device_status()
|
||||
|
||||
result = []
|
||||
for device in devices:
|
||||
d = device.to_dict()
|
||||
d['in_use'] = device.index in registry
|
||||
d['used_by'] = registry.get(device.index)
|
||||
result.append(d)
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route('/devices/debug')
|
||||
def get_devices_debug() -> Response:
|
||||
"""Get detailed SDR device detection diagnostics."""
|
||||
import shutil
|
||||
|
||||
diagnostics = {
|
||||
'tools': {},
|
||||
'rtl_test': {},
|
||||
'soapy': {},
|
||||
'usb': {},
|
||||
'kernel_modules': {},
|
||||
'detected_devices': [],
|
||||
'suggestions': []
|
||||
}
|
||||
|
||||
# Check for required tools
|
||||
diagnostics['tools']['rtl_test'] = shutil.which('rtl_test') is not None
|
||||
diagnostics['tools']['SoapySDRUtil'] = shutil.which('SoapySDRUtil') is not None
|
||||
diagnostics['tools']['lsusb'] = shutil.which('lsusb') is not None
|
||||
|
||||
# Run rtl_test and capture full output
|
||||
if diagnostics['tools']['rtl_test']:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['rtl_test', '-t'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
diagnostics['rtl_test'] = {
|
||||
'returncode': result.returncode,
|
||||
'stdout': result.stdout[:2000] if result.stdout else '',
|
||||
'stderr': result.stderr[:2000] if result.stderr else ''
|
||||
}
|
||||
|
||||
# Check for common errors
|
||||
combined = (result.stdout or '') + (result.stderr or '')
|
||||
if 'No supported devices found' in combined:
|
||||
diagnostics['suggestions'].append('No RTL-SDR device detected. Check USB connection.')
|
||||
if 'usb_claim_interface error' in combined:
|
||||
diagnostics['suggestions'].append('Device busy - kernel DVB driver may have claimed it. Run: sudo modprobe -r dvb_usb_rtl28xxu')
|
||||
if 'Permission denied' in combined.lower():
|
||||
diagnostics['suggestions'].append('USB permission denied. Add udev rules or run as root.')
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
diagnostics['rtl_test'] = {'error': 'Timeout after 5 seconds'}
|
||||
except Exception as e:
|
||||
diagnostics['rtl_test'] = {'error': str(e)}
|
||||
else:
|
||||
diagnostics['suggestions'].append('rtl_test not found. Install rtl-sdr package.')
|
||||
|
||||
# Run SoapySDRUtil
|
||||
if diagnostics['tools']['SoapySDRUtil']:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['SoapySDRUtil', '--find'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
diagnostics['soapy'] = {
|
||||
'returncode': result.returncode,
|
||||
'stdout': result.stdout[:2000] if result.stdout else '',
|
||||
'stderr': result.stderr[:2000] if result.stderr else ''
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
diagnostics['soapy'] = {'error': 'Timeout after 10 seconds'}
|
||||
except Exception as e:
|
||||
diagnostics['soapy'] = {'error': str(e)}
|
||||
|
||||
# Check USB devices (Linux)
|
||||
if diagnostics['tools']['lsusb']:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['lsusb'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
# Filter for common SDR vendor IDs
|
||||
sdr_vendors = ['0bda', '1d50', '1df7', '0403'] # Realtek, OpenMoko/HackRF, SDRplay, FTDI
|
||||
usb_lines = [l for l in result.stdout.split('\n')
|
||||
if any(v in l.lower() for v in sdr_vendors) or 'rtl' in l.lower() or 'sdr' in l.lower()]
|
||||
diagnostics['usb']['devices'] = usb_lines if usb_lines else ['No SDR-related USB devices found']
|
||||
except Exception as e:
|
||||
diagnostics['usb'] = {'error': str(e)}
|
||||
|
||||
# Check for loaded kernel modules that conflict (Linux)
|
||||
if platform.system() == 'Linux':
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['lsmod'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
conflicting = ['dvb_usb_rtl28xxu', 'rtl2832', 'rtl2830']
|
||||
loaded = [m for m in conflicting if m in result.stdout]
|
||||
diagnostics['kernel_modules']['conflicting_loaded'] = loaded
|
||||
if loaded:
|
||||
diagnostics['suggestions'].append(f"Conflicting kernel modules loaded: {', '.join(loaded)}. Run: sudo modprobe -r {' '.join(loaded)}")
|
||||
except Exception as e:
|
||||
diagnostics['kernel_modules'] = {'error': str(e)}
|
||||
|
||||
# Get detected devices
|
||||
devices = SDRFactory.detect_devices()
|
||||
diagnostics['detected_devices'] = [d.to_dict() for d in devices]
|
||||
|
||||
if not devices and not diagnostics['suggestions']:
|
||||
diagnostics['suggestions'].append('No devices detected. Check USB connection and driver installation.')
|
||||
|
||||
return jsonify(diagnostics)
|
||||
|
||||
|
||||
@app.route('/dependencies')
|
||||
def get_dependencies() -> Response:
|
||||
"""Get status of all tool dependencies."""
|
||||
@@ -130,15 +529,16 @@ def get_dependencies() -> Response:
|
||||
# Determine OS for install instructions
|
||||
system = platform.system().lower()
|
||||
if system == 'darwin':
|
||||
install_method = 'brew'
|
||||
pkg_manager = 'brew'
|
||||
elif system == 'linux':
|
||||
install_method = 'apt'
|
||||
pkg_manager = 'apt'
|
||||
else:
|
||||
install_method = 'manual'
|
||||
pkg_manager = 'manual'
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'os': system,
|
||||
'install_method': install_method,
|
||||
'pkg_manager': pkg_manager,
|
||||
'modes': results
|
||||
})
|
||||
|
||||
@@ -159,14 +559,14 @@ def export_aircraft() -> Response:
|
||||
for icao, ac in adsb_aircraft.items():
|
||||
writer.writerow([
|
||||
icao,
|
||||
ac.get('callsign', ''),
|
||||
ac.get('altitude', ''),
|
||||
ac.get('speed', ''),
|
||||
ac.get('heading', ''),
|
||||
ac.get('lat', ''),
|
||||
ac.get('lon', ''),
|
||||
ac.get('squawk', ''),
|
||||
ac.get('lastSeen', '')
|
||||
ac.get('callsign', '') if isinstance(ac, dict) else '',
|
||||
ac.get('altitude', '') if isinstance(ac, dict) else '',
|
||||
ac.get('speed', '') if isinstance(ac, dict) else '',
|
||||
ac.get('heading', '') if isinstance(ac, dict) else '',
|
||||
ac.get('lat', '') if isinstance(ac, dict) else '',
|
||||
ac.get('lon', '') if isinstance(ac, dict) else '',
|
||||
ac.get('squawk', '') if isinstance(ac, dict) else '',
|
||||
ac.get('lastSeen', '') if isinstance(ac, dict) else ''
|
||||
])
|
||||
|
||||
response = Response(output.getvalue(), mimetype='text/csv')
|
||||
@@ -175,7 +575,7 @@ def export_aircraft() -> Response:
|
||||
else:
|
||||
return jsonify({
|
||||
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
||||
'aircraft': list(adsb_aircraft.values())
|
||||
'aircraft': adsb_aircraft.values()
|
||||
})
|
||||
|
||||
|
||||
@@ -195,11 +595,11 @@ def export_wifi() -> Response:
|
||||
for bssid, net in wifi_networks.items():
|
||||
writer.writerow([
|
||||
bssid,
|
||||
net.get('ssid', ''),
|
||||
net.get('channel', ''),
|
||||
net.get('signal', ''),
|
||||
net.get('encryption', ''),
|
||||
net.get('clients', 0)
|
||||
net.get('ssid', '') if isinstance(net, dict) else '',
|
||||
net.get('channel', '') if isinstance(net, dict) else '',
|
||||
net.get('signal', '') if isinstance(net, dict) else '',
|
||||
net.get('encryption', '') if isinstance(net, dict) else '',
|
||||
net.get('clients', 0) if isinstance(net, dict) else 0
|
||||
])
|
||||
|
||||
response = Response(output.getvalue(), mimetype='text/csv')
|
||||
@@ -208,8 +608,8 @@ def export_wifi() -> Response:
|
||||
else:
|
||||
return jsonify({
|
||||
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
||||
'networks': list(wifi_networks.values()),
|
||||
'clients': list(wifi_clients.values())
|
||||
'networks': wifi_networks.values(),
|
||||
'clients': wifi_clients.values()
|
||||
})
|
||||
|
||||
|
||||
@@ -229,11 +629,11 @@ def export_bluetooth() -> Response:
|
||||
for mac, dev in bt_devices.items():
|
||||
writer.writerow([
|
||||
mac,
|
||||
dev.get('name', ''),
|
||||
dev.get('rssi', ''),
|
||||
dev.get('type', ''),
|
||||
dev.get('manufacturer', ''),
|
||||
dev.get('lastSeen', '')
|
||||
dev.get('name', '') if isinstance(dev, dict) else '',
|
||||
dev.get('rssi', '') if isinstance(dev, dict) else '',
|
||||
dev.get('type', '') if isinstance(dev, dict) else '',
|
||||
dev.get('manufacturer', '') if isinstance(dev, dict) else '',
|
||||
dev.get('lastSeen', '') if isinstance(dev, dict) else ''
|
||||
])
|
||||
|
||||
response = Response(output.getvalue(), mimetype='text/csv')
|
||||
@@ -242,24 +642,82 @@ def export_bluetooth() -> Response:
|
||||
else:
|
||||
return jsonify({
|
||||
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
||||
'devices': list(bt_devices.values()),
|
||||
'beacons': list(bt_beacons.values())
|
||||
'devices': bt_devices.values(),
|
||||
'beacons': bt_beacons.values()
|
||||
})
|
||||
|
||||
|
||||
def _get_subghz_active() -> bool:
|
||||
"""Check if SubGHz manager has an active process."""
|
||||
try:
|
||||
from utils.subghz import get_subghz_manager
|
||||
return get_subghz_manager().active_mode != 'idle'
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _get_dmr_active() -> bool:
|
||||
"""Check if Digital Voice decoder has an active process."""
|
||||
try:
|
||||
from routes import dmr as dmr_module
|
||||
proc = dmr_module.dmr_dsd_process
|
||||
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@app.route('/health')
|
||||
def health_check() -> Response:
|
||||
"""Health check endpoint for monitoring."""
|
||||
import time
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'version': VERSION,
|
||||
'uptime_seconds': round(time.time() - _app_start_time, 2),
|
||||
'processes': {
|
||||
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
||||
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
||||
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
|
||||
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
|
||||
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
||||
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||
'dmr': _get_dmr_active(),
|
||||
'subghz': _get_subghz_active(),
|
||||
},
|
||||
'data': {
|
||||
'aircraft_count': len(adsb_aircraft),
|
||||
'vessel_count': len(ais_vessels),
|
||||
'wifi_networks_count': len(wifi_networks),
|
||||
'wifi_clients_count': len(wifi_clients),
|
||||
'bt_devices_count': len(bt_devices),
|
||||
'dsc_messages_count': len(dsc_messages),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@app.route('/killall', methods=['POST'])
|
||||
def kill_all() -> Response:
|
||||
"""Kill all decoder and WiFi processes."""
|
||||
global current_process, sensor_process, wifi_process, adsb_process
|
||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||
global dmr_process, dmr_rtl_process
|
||||
|
||||
# Import adsb module to reset its state
|
||||
# Import adsb and ais modules to reset their state
|
||||
from routes import adsb as adsb_module
|
||||
from routes import ais as ais_module
|
||||
from utils.bluetooth import reset_bluetooth_scanner
|
||||
|
||||
killed = []
|
||||
processes_to_kill = [
|
||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||
'dump1090'
|
||||
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
||||
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
|
||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
||||
'hackrf_transfer', 'hackrf_sweep'
|
||||
]
|
||||
|
||||
for proc in processes_to_kill:
|
||||
@@ -284,6 +742,61 @@ def kill_all() -> Response:
|
||||
adsb_process = None
|
||||
adsb_module.adsb_using_service = False
|
||||
|
||||
# Reset AIS state
|
||||
with ais_lock:
|
||||
ais_process = None
|
||||
ais_module.ais_running = False
|
||||
|
||||
# Reset ACARS state
|
||||
with acars_lock:
|
||||
acars_process = None
|
||||
|
||||
# Reset APRS state
|
||||
with aprs_lock:
|
||||
aprs_process = None
|
||||
aprs_rtl_process = None
|
||||
|
||||
# Reset DSC state
|
||||
with dsc_lock:
|
||||
dsc_process = None
|
||||
dsc_rtl_process = None
|
||||
|
||||
# Reset DMR state
|
||||
with dmr_lock:
|
||||
dmr_process = None
|
||||
dmr_rtl_process = None
|
||||
|
||||
# Reset Bluetooth state (legacy)
|
||||
with bt_lock:
|
||||
if bt_process:
|
||||
try:
|
||||
bt_process.terminate()
|
||||
bt_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
bt_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
bt_process = None
|
||||
|
||||
# Reset Bluetooth v2 scanner
|
||||
try:
|
||||
reset_bluetooth_scanner()
|
||||
killed.append('bluetooth')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Reset SubGHz state
|
||||
try:
|
||||
from utils.subghz import get_subghz_manager
|
||||
get_subghz_manager().stop_all()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clear SDR device registry
|
||||
with sdr_device_registry_lock:
|
||||
sdr_device_registry.clear()
|
||||
|
||||
return jsonify({'status': 'killed', 'processes': killed})
|
||||
|
||||
|
||||
@@ -336,17 +849,107 @@ def main() -> None:
|
||||
|
||||
print("=" * 50)
|
||||
print(" INTERCEPT // Signal Intelligence")
|
||||
print(" Pager / 433MHz / Aircraft / Satellite / WiFi / BT")
|
||||
print(" Pager / 433MHz / Aircraft / ACARS / Satellite / WiFi / BT")
|
||||
print("=" * 50)
|
||||
print()
|
||||
|
||||
# Check if running as root (required for WiFi monitor mode, some BT operations)
|
||||
import os
|
||||
if os.geteuid() != 0:
|
||||
print("\033[93m" + "=" * 50)
|
||||
print(" ⚠️ WARNING: Not running as root/sudo")
|
||||
print("=" * 50)
|
||||
print(" Some features require root privileges:")
|
||||
print(" - WiFi monitor mode and scanning")
|
||||
print(" - Bluetooth low-level operations")
|
||||
print(" - RTL-SDR access (on some systems)")
|
||||
print()
|
||||
print(" To run with full capabilities:")
|
||||
print(" sudo -E venv/bin/python intercept.py")
|
||||
print("=" * 50 + "\033[0m")
|
||||
print()
|
||||
# Store for API access
|
||||
app.config['RUNNING_AS_ROOT'] = False
|
||||
else:
|
||||
app.config['RUNNING_AS_ROOT'] = True
|
||||
print("Running as root - full capabilities enabled")
|
||||
print()
|
||||
|
||||
# Clean up any stale processes from previous runs
|
||||
cleanup_stale_processes()
|
||||
|
||||
# Initialize database for settings storage
|
||||
from utils.database import init_db
|
||||
init_db()
|
||||
|
||||
# Register database cleanup functions
|
||||
from utils.database import (
|
||||
cleanup_old_signal_history,
|
||||
cleanup_old_timeline_entries,
|
||||
cleanup_old_dsc_alerts,
|
||||
cleanup_old_payloads
|
||||
)
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440) # Every 24 hours
|
||||
|
||||
# Start automatic cleanup of stale data entries
|
||||
cleanup_manager.start()
|
||||
|
||||
# Register blueprints
|
||||
from routes import register_blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
# Initialize TLE auto-refresh (must be after blueprint registration)
|
||||
try:
|
||||
from routes.satellite import init_tle_auto_refresh
|
||||
import os
|
||||
if not os.environ.get('TESTING'):
|
||||
init_tle_auto_refresh()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
|
||||
|
||||
# Update TLE data in background thread (non-blocking)
|
||||
def update_tle_background():
|
||||
try:
|
||||
from routes.satellite import refresh_tle_data
|
||||
print("Updating satellite TLE data from CelesTrak...")
|
||||
updated = refresh_tle_data()
|
||||
if updated:
|
||||
print(f"TLE data updated for: {', '.join(updated)}")
|
||||
else:
|
||||
print("TLE update: No satellites updated (may be offline)")
|
||||
except Exception as e:
|
||||
print(f"TLE update failed (will use cached data): {e}")
|
||||
|
||||
tle_thread = threading.Thread(target=update_tle_background, daemon=True)
|
||||
tle_thread.start()
|
||||
|
||||
# Initialize WebSocket for audio streaming
|
||||
try:
|
||||
from routes.audio_websocket import init_audio_websocket
|
||||
init_audio_websocket(app)
|
||||
print("WebSocket audio streaming enabled")
|
||||
except ImportError as e:
|
||||
print(f"WebSocket audio disabled (install flask-sock): {e}")
|
||||
|
||||
# Initialize KiwiSDR WebSocket audio proxy
|
||||
try:
|
||||
from routes.websdr import init_websdr_audio
|
||||
init_websdr_audio(app)
|
||||
print("KiwiSDR audio proxy enabled")
|
||||
except ImportError as e:
|
||||
print(f"KiwiSDR audio proxy disabled: {e}")
|
||||
|
||||
# Initialize WebSocket for waterfall streaming
|
||||
try:
|
||||
from routes.waterfall_websocket import init_waterfall_websocket
|
||||
init_waterfall_websocket(app)
|
||||
print("WebSocket waterfall streaming enabled")
|
||||
except ImportError as e:
|
||||
print(f"WebSocket waterfall disabled: {e}")
|
||||
|
||||
print(f"Open http://localhost:{args.port} in your browser")
|
||||
print()
|
||||
print("Press Ctrl+C to stop")
|
||||
@@ -359,4 +962,4 @@ def main() -> None:
|
||||
debug=args.debug,
|
||||
threaded=True,
|
||||
load_dotenv=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,142 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "1.2.0"
|
||||
VERSION = "2.16.0"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"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",
|
||||
"DMR dsd-fme protocol fixes, tuning controls, and state sync",
|
||||
"SDR device lock-up fix from unreleased device registry on crash",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.14.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"DMR/P25/NXDN/D-STAR digital voice decoder with dsd-fme",
|
||||
"DMR visual synthesizer with event-driven spring-physics bars",
|
||||
"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:
|
||||
@@ -75,12 +210,59 @@ 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', 1000000)
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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,18 +1,29 @@
|
||||
# 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'),
|
||||
}
|
||||
|
||||
@@ -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,126 @@
|
||||
# INTERCEPT - Signal Intelligence Platform
|
||||
# Docker Compose configuration for easy deployment
|
||||
#
|
||||
# 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"
|
||||
# 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:
|
||||
# Persist decoded images and database across container rebuilds
|
||||
- ./data:/app/data
|
||||
# Optional: mount logs directory
|
||||
# - ./logs:/app/logs
|
||||
environment:
|
||||
- INTERCEPT_HOST=0.0.0.0
|
||||
- INTERCEPT_PORT=5050
|
||||
- INTERCEPT_LOG_LEVEL=INFO
|
||||
# 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
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
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"
|
||||
# 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:
|
||||
- INTERCEPT_HOST=0.0.0.0
|
||||
- INTERCEPT_PORT=5050
|
||||
- INTERCEPT_LOG_LEVEL=INFO
|
||||
- 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:
|
||||
- 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
|
||||
python app.py
|
||||
# Runs 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,36 @@ 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
|
||||
|
||||
## AIS Vessel Tracking
|
||||
|
||||
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
|
||||
- **Full-screen dashboard** - dedicated popout with interactive map
|
||||
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
||||
- **Vessel details popup** - name, MMSI, callsign, destination, ETA
|
||||
- **Navigation data** - speed, course, heading, rate of turn
|
||||
- **Ship type classification** - cargo, tanker, passenger, fishing, etc.
|
||||
- **Vessel dimensions** - length, width, draught
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
|
||||
## 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,11 +56,91 @@ 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
|
||||
- **Aircraft details popup** - callsign, altitude, speed, heading, squawk, ICAO
|
||||
|
||||
<p align="center">
|
||||
<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
|
||||
|
||||
## 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
|
||||
- **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
|
||||
|
||||
## HF SSTV
|
||||
|
||||
- **Terrestrial SSTV decoding** across HF (80m-10m), VHF (6m, 2m), and UHF (70cm) bands
|
||||
- **Predefined frequency lookup** for active SSTV calling frequencies
|
||||
- **Image gallery** with decoded transmissions
|
||||
|
||||
## 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
|
||||
|
||||
## Satellite Tracking
|
||||
|
||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||
@@ -43,6 +153,13 @@ Complete feature list for all modules.
|
||||
- **Telemetry panel** - real-time azimuth, elevation, range, velocity
|
||||
- **Multiple satellite tracking** simultaneously
|
||||
|
||||
<p align="center">
|
||||
<img src="/static/images/screenshots/screenshot_sat.png" alt="Screenshot">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="/static/images/screenshots/screenshot_sat_2.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
## WiFi Reconnaissance
|
||||
|
||||
- **Monitor mode** management via airmon-ng
|
||||
@@ -64,13 +181,165 @@ 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)
|
||||
|
||||
## 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** (requires RTL-SDR) - 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
|
||||
|
||||
## User Interface
|
||||
|
||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||
@@ -92,6 +361,42 @@ 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
|
||||
@@ -108,3 +413,4 @@ Complete feature list for all modules.
|
||||
- **GPS dongle support** - USB GPS receivers for precise observer location
|
||||
- **Disclaimer acceptance** on first use
|
||||
- **Auto-stop** when switching between modes
|
||||
|
||||
|
||||
@@ -1,93 +1,75 @@
|
||||
# Hardware & Installation
|
||||
# Hardware & Advanced Setup
|
||||
|
||||
## Supported SDR Hardware
|
||||
|
||||
| Hardware | Frequency Range | Gain Range | TX | Price | Notes |
|
||||
|----------|-----------------|------------|-----|-------|-------|
|
||||
| **RTL-SDR** | 24 - 1766 MHz | 0 - 50 dB | No | ~$25 | Most common, budget-friendly |
|
||||
| **LimeSDR** | 0.1 - 3800 MHz | 0 - 73 dB | Yes | ~$300 | Wide range, requires SoapySDR |
|
||||
| **HackRF** | 1 - 6000 MHz | 0 - 62 dB | Yes | ~$300 | Ultra-wide range, requires SoapySDR |
|
||||
| Hardware | Frequency Range | Price | Notes |
|
||||
|----------|-----------------|-------|-------|
|
||||
| **RTL-SDR** | 24 - 1766 MHz | ~$25-35 | Recommended for beginners |
|
||||
| **LimeSDR** | 0.1 - 3800 MHz | ~$300 | Wide range, requires SoapySDR |
|
||||
| **HackRF** | 1 - 6000 MHz | ~$300 | Ultra-wide range, requires SoapySDR |
|
||||
|
||||
INTERCEPT automatically detects connected devices and shows hardware-specific capabilities in the UI.
|
||||
INTERCEPT automatically detects connected devices.
|
||||
|
||||
## Requirements
|
||||
---
|
||||
|
||||
### Hardware
|
||||
- **SDR Device** - RTL-SDR, LimeSDR, or HackRF
|
||||
- **WiFi adapter** capable of monitor mode (for WiFi features)
|
||||
- **Bluetooth adapter** (for Bluetooth features)
|
||||
- **GPS dongle** (optional, for precise location)
|
||||
|
||||
### Software
|
||||
- **Python 3.9+** required
|
||||
- External tools (see installation below)
|
||||
|
||||
## Tool Installation
|
||||
|
||||
### Core SDR Tools
|
||||
|
||||
| Tool | macOS | Ubuntu/Debian | Purpose |
|
||||
|------|-------|---------------|---------|
|
||||
| rtl-sdr | `brew install librtlsdr` | `sudo apt install rtl-sdr` | RTL-SDR support |
|
||||
| multimon-ng | `brew install multimon-ng` | `sudo apt install multimon-ng` | Pager decoding |
|
||||
| rtl_433 | `brew install rtl_433` | `sudo apt install rtl-433` | 433MHz sensors |
|
||||
| dump1090 | `brew install dump1090-mutability` | `sudo apt install dump1090-mutability` | ADS-B aircraft |
|
||||
| aircrack-ng | `brew install aircrack-ng` | `sudo apt install aircrack-ng` | WiFi reconnaissance |
|
||||
| bluez | Built-in (limited) | `sudo apt install bluez bluetooth` | Bluetooth scanning |
|
||||
|
||||
### LimeSDR / HackRF Support (Optional)
|
||||
|
||||
| Tool | macOS | Ubuntu/Debian | Purpose |
|
||||
|------|-------|---------------|---------|
|
||||
| SoapySDR | `brew install soapysdr` | `sudo apt install soapysdr-tools` | Universal SDR abstraction |
|
||||
| LimeSDR | `brew install limesuite soapylms7` | `sudo apt install limesuite soapysdr-module-lms7` | LimeSDR support |
|
||||
| HackRF | `brew install hackrf soapyhackrf` | `sudo apt install hackrf soapysdr-module-hackrf` | HackRF support |
|
||||
| readsb | Build from source | Build from source | ADS-B with SoapySDR |
|
||||
|
||||
> **Note:** RTL-SDR works out of the box. LimeSDR and HackRF require SoapySDR plus the hardware-specific driver.
|
||||
|
||||
## Quick Install Commands
|
||||
|
||||
### Ubuntu/Debian
|
||||
> [!NOTE]
|
||||
> Known Issue: On the latest version of Debian (Trixie) and those distros that use it dump1090 is not available in the repsitories and will need to be built from source until the developers release it.
|
||||
```bash
|
||||
# Core tools
|
||||
sudo apt update
|
||||
sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez bluetooth
|
||||
|
||||
# LimeSDR (optional)
|
||||
sudo apt install soapysdr-tools limesuite soapysdr-module-lms7
|
||||
|
||||
# HackRF (optional)
|
||||
sudo apt install hackrf soapysdr-module-hackrf
|
||||
```
|
||||
## Quick Install
|
||||
|
||||
### macOS (Homebrew)
|
||||
```bash
|
||||
# Core tools
|
||||
brew install librtlsdr multimon-ng rtl_433 dump1090-mutability aircrack-ng
|
||||
|
||||
# LimeSDR (optional)
|
||||
```bash
|
||||
# Install Homebrew if needed
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
|
||||
# Core tools (required)
|
||||
brew install python@3.11 librtlsdr multimon-ng rtl_433 ffmpeg
|
||||
|
||||
# ADS-B aircraft tracking
|
||||
brew install dump1090-mutability
|
||||
|
||||
# WiFi tools (optional)
|
||||
brew install aircrack-ng
|
||||
|
||||
# LimeSDR support (optional)
|
||||
brew install soapysdr limesuite soapylms7
|
||||
|
||||
# HackRF (optional)
|
||||
# HackRF support (optional)
|
||||
brew install hackrf soapyhackrf
|
||||
```
|
||||
|
||||
### Arch Linux
|
||||
```bash
|
||||
# Core tools
|
||||
sudo pacman -S rtl-sdr multimon-ng
|
||||
yay -S rtl_433 dump1090
|
||||
### Debian / Ubuntu / Raspberry Pi OS
|
||||
|
||||
# LimeSDR/HackRF (optional)
|
||||
sudo pacman -S soapysdr limesuite hackrf
|
||||
```bash
|
||||
# Update package lists
|
||||
sudo apt update
|
||||
|
||||
# Core tools (required)
|
||||
sudo apt install -y python3 python3-pip python3-venv python3-skyfield
|
||||
sudo apt install -y rtl-sdr multimon-ng rtl-433 ffmpeg
|
||||
|
||||
# ADS-B aircraft tracking
|
||||
sudo apt install -y dump1090-mutability
|
||||
# Alternative: dump1090-fa (FlightAware version)
|
||||
|
||||
# WiFi tools (optional)
|
||||
sudo apt install -y aircrack-ng
|
||||
|
||||
# Bluetooth tools (optional)
|
||||
sudo apt install -y bluez bluetooth
|
||||
|
||||
# LimeSDR support (optional)
|
||||
sudo apt install -y soapysdr-tools limesuite soapysdr-module-lms7
|
||||
|
||||
# HackRF support (optional)
|
||||
sudo apt install -y hackrf soapysdr-module-hackrf
|
||||
```
|
||||
|
||||
## Linux udev Rules
|
||||
---
|
||||
|
||||
If your SDR isn't detected, add udev rules:
|
||||
## RTL-SDR Setup (Linux)
|
||||
|
||||
### Add udev rules
|
||||
|
||||
If your RTL-SDR isn't detected, create udev rules:
|
||||
|
||||
```bash
|
||||
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
|
||||
@@ -99,9 +81,9 @@ sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger
|
||||
```
|
||||
|
||||
Then unplug and replug your device.
|
||||
Then unplug and replug your RTL-SDR.
|
||||
|
||||
## Blacklist DVB-T Driver (Linux)
|
||||
### Blacklist DVB-T driver
|
||||
|
||||
The default DVB-T driver conflicts with rtl-sdr:
|
||||
|
||||
@@ -110,57 +92,165 @@ echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf
|
||||
sudo modprobe -r dvb_usb_rtl28xxu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verify Installation
|
||||
|
||||
Check what's installed:
|
||||
### Check dependencies
|
||||
```bash
|
||||
python3 intercept.py --check-deps
|
||||
```
|
||||
|
||||
Test SDR detection:
|
||||
### Test SDR detection
|
||||
```bash
|
||||
# RTL-SDR
|
||||
rtl_test
|
||||
|
||||
# LimeSDR/HackRF
|
||||
# LimeSDR/HackRF (via SoapySDR)
|
||||
SoapySDRUtil --find
|
||||
```
|
||||
|
||||
## Python Dependencies
|
||||
---
|
||||
|
||||
### Option 1: setup.sh (Recommended)
|
||||
## Python Environment
|
||||
|
||||
### Using setup.sh (Recommended)
|
||||
```bash
|
||||
./setup.sh
|
||||
```
|
||||
This creates a virtual environment and installs dependencies automatically.
|
||||
|
||||
### Option 2: pip
|
||||
This automatically:
|
||||
- Detects your OS
|
||||
- Creates a virtual environment if needed (for PEP 668 systems)
|
||||
- Installs Python dependencies
|
||||
- Checks for required tools
|
||||
|
||||
### Manual setup
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Option 3: uv (Fast alternative)
|
||||
[uv](https://github.com/astral-sh/uv) is a fast Python package installer.
|
||||
---
|
||||
|
||||
## Running INTERCEPT
|
||||
|
||||
After installation:
|
||||
|
||||
```bash
|
||||
# Install uv (if not already installed)
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
sudo -E venv/bin/python intercept.py
|
||||
|
||||
# Create venv and install deps
|
||||
uv venv
|
||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||
uv sync
|
||||
|
||||
# Or just install deps in existing environment
|
||||
uv pip install -r requirements.txt
|
||||
# Custom port
|
||||
INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### Option 4: pip with pyproject.toml
|
||||
Open **http://localhost:5050** in your browser.
|
||||
|
||||
---
|
||||
|
||||
## Complete Tool Reference
|
||||
|
||||
| Tool | Package (Debian) | Package (macOS) | Required For |
|
||||
|------|------------------|-----------------|--------------|
|
||||
| `rtl_fm` | rtl-sdr | librtlsdr | Pager, Listening Post |
|
||||
| `rtl_test` | rtl-sdr | librtlsdr | SDR detection |
|
||||
| `multimon-ng` | multimon-ng | multimon-ng | Pager decoding |
|
||||
| `rtl_433` | rtl-433 | rtl_433 | 433MHz sensors |
|
||||
| `dump1090` | dump1090-mutability | dump1090-mutability | ADS-B tracking |
|
||||
| `ffmpeg` | ffmpeg | ffmpeg | Listening Post audio |
|
||||
| `airmon-ng` | aircrack-ng | aircrack-ng | WiFi monitor mode |
|
||||
| `airodump-ng` | aircrack-ng | aircrack-ng | WiFi scanning |
|
||||
| `aireplay-ng` | aircrack-ng | aircrack-ng | WiFi deauth (optional) |
|
||||
| `hcitool` | bluez | N/A | Bluetooth scanning |
|
||||
| `bluetoothctl` | bluez | N/A | Bluetooth control |
|
||||
| `hciconfig` | bluez | N/A | Bluetooth config |
|
||||
|
||||
### Optional tools:
|
||||
| Tool | Package (Debian) | Package (macOS) | Purpose |
|
||||
|------|------------------|-----------------|---------|
|
||||
| `ffmpeg` | ffmpeg | ffmpeg | Alternative audio encoder |
|
||||
| `SoapySDRUtil` | soapysdr-tools | soapysdr | LimeSDR/HackRF support |
|
||||
| `LimeUtil` | limesuite | limesuite | LimeSDR native tools |
|
||||
| `hackrf_info` | hackrf | hackrf | HackRF native tools |
|
||||
|
||||
### Python dependencies (requirements.txt):
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `flask` | Web server |
|
||||
| `skyfield` | Satellite tracking |
|
||||
| `bleak` | BLE scanning with manufacturer data (TSCM) |
|
||||
|
||||
---
|
||||
|
||||
## dump1090 Notes
|
||||
|
||||
### Package names vary by distribution:
|
||||
- `dump1090-mutability` - Most common
|
||||
- `dump1090-fa` - FlightAware version (recommended)
|
||||
- `dump1090` - Generic
|
||||
|
||||
### Not in repositories (Debian Trixie)?
|
||||
|
||||
Install FlightAware's version:
|
||||
https://flightaware.com/adsb/piaware/install
|
||||
|
||||
Or build from source:
|
||||
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
|
||||
pip install . # Install as package
|
||||
pip install -e . # Install in editable mode (for development)
|
||||
pip install -e ".[dev]" # Include dev dependencies
|
||||
# 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 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
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Security Considerations
|
||||
|
||||
INTERCEPT is designed as a **local signal intelligence tool** for personal use on trusted networks. This document outlines security considerations and best practices.
|
||||
|
||||
## Network Binding
|
||||
|
||||
By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any network interface. This is convenient for accessing the web UI from other devices on your local network, but has security implications:
|
||||
|
||||
### Recommendations
|
||||
|
||||
1. **Firewall Rules**: If you don't need remote access, configure your firewall to block external access to port 5050:
|
||||
```bash
|
||||
# Linux (iptables)
|
||||
sudo iptables -A INPUT -p tcp --dport 5050 -s 127.0.0.1 -j ACCEPT
|
||||
sudo iptables -A INPUT -p tcp --dport 5050 -j DROP
|
||||
|
||||
# macOS (pf)
|
||||
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:
|
||||
```bash
|
||||
export INTERCEPT_HOST=127.0.0.1
|
||||
python intercept.py
|
||||
```
|
||||
|
||||
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
|
||||
|
||||
## Authentication
|
||||
|
||||
INTERCEPT does **not** include authentication. This is by design for ease of use as a personal tool. If you need to expose INTERCEPT to untrusted networks:
|
||||
|
||||
1. Use a reverse proxy (nginx, Caddy) with authentication
|
||||
2. Use a VPN to access your home network
|
||||
3. Use SSH port forwarding: `ssh -L 5050:localhost:5050 your-server`
|
||||
|
||||
## Security Headers
|
||||
|
||||
INTERCEPT includes the following security headers on all responses:
|
||||
|
||||
| Header | Value | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `X-Content-Type-Options` | `nosniff` | Prevent MIME type sniffing |
|
||||
| `X-Frame-Options` | `SAMEORIGIN` | Prevent clickjacking |
|
||||
| `X-XSS-Protection` | `1; mode=block` | Enable browser XSS filter |
|
||||
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Control referrer information |
|
||||
| `Permissions-Policy` | `geolocation=(self), microphone=()` | Restrict browser features |
|
||||
|
||||
## Input Validation
|
||||
|
||||
All user inputs are validated before use:
|
||||
|
||||
- **Network interface names**: Validated against strict regex pattern
|
||||
- **Bluetooth interface names**: Must match `hciX` format
|
||||
- **MAC addresses**: Validated format
|
||||
- **Frequencies**: Validated range and format
|
||||
- **File paths**: Protected against directory traversal
|
||||
- **HTML output**: All user-provided content is escaped
|
||||
|
||||
## Subprocess Execution
|
||||
|
||||
INTERCEPT executes external tools (rtl_fm, airodump-ng, etc.) via subprocess. Security measures:
|
||||
|
||||
- **No shell execution**: All subprocess calls use list arguments, not shell strings
|
||||
- **Input validation**: All user-provided arguments are validated before use
|
||||
- **Process isolation**: Each tool runs in its own process with limited permissions
|
||||
|
||||
## Debug Mode
|
||||
|
||||
Debug mode is **disabled by default**. If enabled via `INTERCEPT_DEBUG=true`:
|
||||
|
||||
- The Werkzeug debugger PIN is disabled (not needed for local tool)
|
||||
- Additional logging is enabled
|
||||
- Stack traces are shown on errors
|
||||
|
||||
**Never run in debug mode on untrusted networks.**
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
If you discover a security vulnerability, please report it by:
|
||||
|
||||
1. Opening a GitHub issue (for non-sensitive issues)
|
||||
2. Emailing the maintainer directly (for sensitive issues)
|
||||
|
||||
Please include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
@@ -14,6 +14,37 @@ pip install -r requirements.txt
|
||||
python3 -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### pip install fails for flask or skyfield
|
||||
|
||||
On newer Debian/Ubuntu systems, pip may fail with permission errors or dependency conflicts. **Use apt instead:**
|
||||
|
||||
```bash
|
||||
# Install Python packages via apt (recommended for Debian/Ubuntu)
|
||||
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
|
||||
```
|
||||
|
||||
### "error: externally-managed-environment" (pip blocked)
|
||||
|
||||
This is PEP 668 protection on Ubuntu 23.04+, Debian 12+, and similar systems. Solutions:
|
||||
|
||||
```bash
|
||||
# Option 1: Use apt packages (recommended)
|
||||
sudo apt install python3-flask python3-requests python3-serial python3-skyfield
|
||||
python3 -m venv --system-site-packages venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Option 2: Use pipx for isolated install
|
||||
pipx install flask
|
||||
|
||||
# Option 3: Force pip (not recommended)
|
||||
pip install --break-system-packages flask
|
||||
```
|
||||
|
||||
### "TypeError: 'type' object is not subscriptable"
|
||||
|
||||
This error occurs on Python 3.7 or 3.8. **INTERCEPT requires Python 3.9 or later.**
|
||||
@@ -33,18 +64,12 @@ pip install -r requirements.txt
|
||||
sudo venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### "externally-managed-environment" error (Ubuntu 23.04+, Debian 12+)
|
||||
### Alternative: Use the setup script
|
||||
|
||||
Modern systems use PEP 668 to protect system Python. Use a virtual environment:
|
||||
The setup script handles all installation automatically, including apt packages:
|
||||
|
||||
```bash
|
||||
# Option 1: Virtual environment (recommended)
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
sudo venv/bin/python intercept.py
|
||||
|
||||
# Option 2: Use the setup script (auto-creates venv if needed)
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
@@ -101,11 +126,204 @@ Then unplug and replug your RTL-SDR.
|
||||
3. Check for other applications: `lsof | grep rtl`
|
||||
|
||||
### LimeSDR/HackRF not detected
|
||||
Ensure the correct SoapySDR module for your hardware is installed first
|
||||
|
||||
1. Verify SoapySDR is installed: `SoapySDRUtil --info`
|
||||
2. Check driver is loaded: `SoapySDRUtil --find`
|
||||
3. May need udev rules or run as root
|
||||
|
||||
### Using HackRF/Airspy/LimeSDR with ADS-B
|
||||
|
||||
For non-RTL-SDR devices, ADS-B requires `readsb` compiled with SoapySDR support (standard dump1090 won't work).
|
||||
|
||||
**Option 1: Run readsb separately and connect via Remote mode**
|
||||
|
||||
1. Start readsb with your device:
|
||||
```bash
|
||||
# HackRF
|
||||
readsb --device-type soapysdr --device driver=hackrf --net --quiet
|
||||
|
||||
# Airspy
|
||||
readsb --device-type soapysdr --device driver=airspy --net --quiet
|
||||
|
||||
# LimeSDR
|
||||
readsb --device-type soapysdr --device driver=lime --net --quiet
|
||||
```
|
||||
|
||||
2. In Intercept's ADS-B dashboard:
|
||||
- Check the **"Remote"** checkbox
|
||||
- Enter Host: `localhost` and Port: `30003`
|
||||
- Click **START**
|
||||
|
||||
3. Intercept will connect to readsb's SBS output on port 30003
|
||||
|
||||
**Option 2: Install readsb with SoapySDR support**
|
||||
|
||||
On Debian/Ubuntu:
|
||||
```bash
|
||||
# Install dependencies
|
||||
sudo apt install build-essential debhelper librtlsdr-dev pkg-config \
|
||||
libncurses5-dev libbladerf-dev libhackrf-dev liblimesuite-dev libsoapysdr-dev
|
||||
|
||||
# Clone and build
|
||||
git clone https://github.com/wiedehopf/readsb.git
|
||||
cd readsb
|
||||
dpkg-buildpackage -b --no-sign
|
||||
sudo dpkg -i ../readsb_*.deb
|
||||
```
|
||||
|
||||
### Using HackRF/Airspy with Listening Post
|
||||
|
||||
The Listening Post requires `rx_fm` from SoapySDR utilities for non-RTL-SDR devices.
|
||||
|
||||
```bash
|
||||
# Install SoapySDR utilities (includes rx_fm)
|
||||
sudo apt install soapysdr-tools
|
||||
|
||||
# Verify rx_fm is available
|
||||
which rx_fm
|
||||
```
|
||||
|
||||
If `rx_fm` is installed, select your device from the SDR dropdown in the Listening Post - HackRF, Airspy, LimeSDR, and SDRPlay are all supported.
|
||||
|
||||
### Setting up Icecast for Listening Post Audio
|
||||
|
||||
The Listening Post uses Icecast for low-latency audio streaming (2-10 second latency). Intercept will automatically start Icecast when you begin listening, but you must install and configure it first.
|
||||
|
||||
**Install Icecast:**
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install icecast2
|
||||
|
||||
# macOS
|
||||
brew install icecast
|
||||
```
|
||||
|
||||
**Configure Icecast:**
|
||||
|
||||
During installation on Debian/Ubuntu, you'll be prompted to configure. Otherwise, edit `/etc/icecast2/icecast.xml`:
|
||||
|
||||
```xml
|
||||
<icecast>
|
||||
<authentication>
|
||||
<!-- Source password - used by ffmpeg to send audio -->
|
||||
<source-password>hackme</source-password>
|
||||
<!-- Admin password for web interface -->
|
||||
<admin-password>your-admin-password</admin-password>
|
||||
</authentication>
|
||||
<hostname>localhost</hostname>
|
||||
<listen-socket>
|
||||
<port>8000</port>
|
||||
</listen-socket>
|
||||
</icecast>
|
||||
```
|
||||
|
||||
**Start Icecast:**
|
||||
```bash
|
||||
# Ubuntu/Debian (as service)
|
||||
sudo systemctl enable icecast2
|
||||
sudo systemctl start icecast2
|
||||
|
||||
# Or run directly
|
||||
icecast -c /etc/icecast2/icecast.xml
|
||||
|
||||
# macOS
|
||||
brew services start icecast
|
||||
# Or: icecast -c /usr/local/etc/icecast.xml
|
||||
```
|
||||
|
||||
**Verify Icecast is running:**
|
||||
- Open http://localhost:8000 in your browser
|
||||
- You should see the Icecast status page
|
||||
|
||||
**Configure Intercept (optional):**
|
||||
|
||||
The default configuration expects Icecast on `127.0.0.1:8000` with source password `hackme` and mount point `/listen.mp3`. To change these, modify the scanner config in your API calls or update the defaults in `routes/listening_post.py`:
|
||||
|
||||
```python
|
||||
scanner_config = {
|
||||
# ... other settings ...
|
||||
'icecast_host': '127.0.0.1',
|
||||
'icecast_port': 8000,
|
||||
'icecast_mount': '/listen.mp3',
|
||||
'icecast_source_password': 'hackme',
|
||||
}
|
||||
```
|
||||
|
||||
**Troubleshooting Icecast:**
|
||||
|
||||
- **"Connection refused" errors**: Ensure Icecast is running on the configured port
|
||||
- **"Authentication failed"**: Check the source password matches between Icecast config and Intercept
|
||||
- **No audio playing**: Check Icecast status page (http://localhost:8000) to verify the mount point is active
|
||||
- **High latency**: Ensure nginx/reverse proxy isn't buffering - add `proxy_buffering off;` to nginx config
|
||||
|
||||
### Audio Streaming Issues - Detailed Debugging
|
||||
|
||||
If the Listening Post shows "Icecast mount not active" errors or audio doesn't play:
|
||||
|
||||
**1. Check the console output for errors**
|
||||
|
||||
Intercept now logs detailed error output. Look for lines starting with `[AUDIO]`:
|
||||
```
|
||||
[AUDIO] SDR errors: ... # Problems with rtl_fm/rx_fm (SDR not connected, device busy)
|
||||
[AUDIO] FFmpeg errors: ... # Problems with ffmpeg (wrong password, codec issues)
|
||||
```
|
||||
|
||||
**2. Verify SDR is connected and working**
|
||||
```bash
|
||||
# For RTL-SDR
|
||||
rtl_test -t
|
||||
|
||||
# You should see: "Found 1 device(s)"
|
||||
# If not, check USB connection and drivers
|
||||
```
|
||||
|
||||
**3. Check Icecast password (macOS Homebrew)**
|
||||
|
||||
On macOS with Homebrew, the Icecast config is at `/opt/homebrew/etc/icecast.xml`. Check the source password:
|
||||
```bash
|
||||
grep source-password /opt/homebrew/etc/icecast.xml
|
||||
```
|
||||
|
||||
If it's different from `hackme`, update it in the Listening Post Icecast config panel, or change the Icecast config and restart:
|
||||
```bash
|
||||
brew services restart icecast
|
||||
```
|
||||
|
||||
**4. Verify ffmpeg has required codecs**
|
||||
```bash
|
||||
# Check MP3 encoder is available
|
||||
ffmpeg -encoders 2>/dev/null | grep mp3
|
||||
|
||||
# Should show: libmp3lame
|
||||
# If not, reinstall ffmpeg with all codecs:
|
||||
# macOS: brew reinstall ffmpeg
|
||||
# Linux: sudo apt install ffmpeg
|
||||
```
|
||||
|
||||
**5. Test the pipeline manually**
|
||||
|
||||
Try running the audio pipeline directly to see errors:
|
||||
```bash
|
||||
# Test rtl_fm (should produce raw audio data)
|
||||
rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>&1 | head -c 1000 | xxd | head
|
||||
|
||||
# Test ffmpeg to Icecast (replace PASSWORD with your source password)
|
||||
rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
|
||||
ffmpeg -f s16le -ar 24000 -ac 1 -i pipe:0 -c:a libmp3lame -b:a 64k \
|
||||
-f mp3 -content_type audio/mpeg icecast://source:PASSWORD@127.0.0.1:8000/listen.mp3
|
||||
```
|
||||
|
||||
**6. Common error messages and solutions**
|
||||
|
||||
| Error | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| `No supported devices found` | SDR not connected | Plug in SDR, check USB |
|
||||
| `Device or resource busy` | Another process using SDR | Click "Kill All Processes" |
|
||||
| `401 Unauthorized` | Wrong Icecast password | Check password in Icecast config |
|
||||
| `Connection refused` | Icecast not running | Start Icecast service |
|
||||
| `Encoder libmp3lame not found` | ffmpeg missing codec | Reinstall ffmpeg with codecs |
|
||||
|
||||
## WiFi Issues
|
||||
|
||||
### Monitor mode fails
|
||||
@@ -118,9 +336,7 @@ Then unplug and replug your RTL-SDR.
|
||||
|
||||
Run INTERCEPT with sudo:
|
||||
```bash
|
||||
sudo python3 intercept.py
|
||||
# Or with venv:
|
||||
sudo venv/bin/python intercept.py
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### Interface not found after enabling monitor mode
|
||||
@@ -146,21 +362,6 @@ Run with sudo or add your user to the bluetooth group:
|
||||
sudo usermod -a -G bluetooth $USER
|
||||
```
|
||||
|
||||
## GPS Issues
|
||||
|
||||
### GPS dongle not detected
|
||||
|
||||
1. Install pyserial: `pip install pyserial`
|
||||
2. Check device is connected:
|
||||
- Linux: `ls /dev/ttyUSB* /dev/ttyACM*`
|
||||
- macOS: `ls /dev/tty.usb*`
|
||||
3. Add user to dialout group (Linux):
|
||||
```bash
|
||||
sudo usermod -a -G dialout $USER
|
||||
```
|
||||
4. Most GPS dongles use 9600 baud (default in INTERCEPT)
|
||||
5. GPS needs clear sky view to get a fix
|
||||
|
||||
## Decoding Issues
|
||||
|
||||
### No messages appearing (Pager mode)
|
||||
@@ -170,15 +371,20 @@ sudo usermod -a -G bluetooth $USER
|
||||
3. Check pager services are active in your area
|
||||
4. Ensure antenna is connected
|
||||
|
||||
### 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.
|
||||
|
||||
### No aircraft appearing (ADS-B mode)
|
||||
|
||||
1. Verify dump1090 or readsb is installed
|
||||
1. Verify dump1090 is installed
|
||||
2. Check antenna is connected (1090 MHz antenna recommended)
|
||||
3. Ensure clear view of sky
|
||||
4. Set correct observer location for range calculations
|
||||
4. Set correct observer location for range calculations or use gpsd
|
||||
|
||||
### Satellite passes not calculating
|
||||
|
||||
1. Ensure skyfield is installed: `pip install skyfield`
|
||||
1. Ensure skyfield is installed: `apt install python3-skyfield`
|
||||
2. Check TLE data is valid and recent
|
||||
3. Verify observer location is set correctly
|
||||
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
# 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 |
|
||||
|
||||
### Navigation Groups
|
||||
|
||||
The navigation is organized into groups:
|
||||
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
|
||||
- **Wireless**: WiFi, Bluetooth
|
||||
- **Security**: TSCM
|
||||
- **Space**: Satellite, ISS SSTV
|
||||
|
||||
---
|
||||
|
||||
## 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,32 @@ 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.
|
||||
|
||||
## 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 +91,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 +100,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 +110,68 @@ The system highlights aircraft transmitting emergency squawks:
|
||||
- **7600** - Radio failure
|
||||
- **7700** - General emergency
|
||||
|
||||
## 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 \
|
||||
python app.py
|
||||
```
|
||||
|
||||
**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 +191,158 @@ 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
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
|
||||
## 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,7 +355,7 @@ 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 -E venv/bin/python intercept.py`
|
||||
|
||||
## Command-line Options
|
||||
|
||||
|
||||
@@ -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: 837 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 694 KiB |
|
After Width: | Height: | Size: 694 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
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: 692 KiB |
|
After Width: | Height: | Size: 791 KiB |
|
After Width: | Height: | Size: 811 KiB |
@@ -0,0 +1,399 @@
|
||||
<!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>
|
||||
<nav class="navbar">
|
||||
<div class="nav-container">
|
||||
<a href="#" class="nav-logo">iNTERCEPT</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>iNTERCEPT</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">20+</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="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📟</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">
|
||||
<div class="feature-icon">✈️</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">
|
||||
<div class="feature-icon">📡</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">
|
||||
<div class="feature-icon">📻</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">
|
||||
<div class="feature-icon">📻</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">
|
||||
<div class="feature-icon">🛰️</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">
|
||||
<div class="feature-icon">📶</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">
|
||||
<div class="feature-icon">🔵</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">
|
||||
<div class="feature-icon">📍</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">
|
||||
<div class="feature-icon">🛰️</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">
|
||||
<div class="feature-icon">🛡️</div>
|
||||
<h3>TSCM</h3>
|
||||
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">⚡</div>
|
||||
<h3>Meter Reading</h3>
|
||||
<p>Intercept smart utility meters via rtl_amr. Monitor electricity, gas, and water meter transmissions.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🚢</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">
|
||||
<div class="feature-icon">🔢</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">
|
||||
<div class="feature-icon">🌐</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">
|
||||
<div class="feature-icon">📴</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">
|
||||
<div class="feature-icon">📡</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">
|
||||
<div class="feature-icon">🌧️</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">
|
||||
<div class="feature-icon">🖼️</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">
|
||||
<div class="feature-icon">🖼️</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">
|
||||
<div class="feature-icon">✈️</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">
|
||||
<div class="feature-icon">📍</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">
|
||||
<div class="feature-icon">🌐</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">
|
||||
<div class="feature-icon">⚡</div>
|
||||
<h3>Utility Meters</h3>
|
||||
<p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
||||
</div>
|
||||
</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>
|
||||
</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
|
||||
sudo -E venv/bin/python intercept.py</code></pre>
|
||||
</div>
|
||||
<p class="install-note">Requires Python 3.9+ and RTL-SDR drivers</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 up -d</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>
|
||||
</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>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-brand">
|
||||
<span class="footer-logo">iNTERCEPT</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="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> · MIT 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();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,694 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
* {
|
||||
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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px 24px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.screenshot-gallery {
|
||||
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;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.screenshot-gallery {
|
||||
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"
|
||||
@@ -1,8 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" fill="#000"/>
|
||||
<path d="M50 5 L90 27.5 L90 72.5 L50 95 L10 72.5 L10 27.5 Z" stroke="#00d4ff" stroke-width="3" fill="none"/>
|
||||
<path d="M30 50 Q40 35, 50 50 Q60 65, 70 50" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
<path d="M35 50 Q42 40, 50 50 Q58 60, 65 50" stroke="#00ff88" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M40 50 Q45 45, 50 50 Q55 55, 60 50" stroke="#ffffff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="50" cy="50" r="4" fill="#00d4ff"/>
|
||||
<!-- Background -->
|
||||
<rect width="100" height="100" fill="#0a0a0f"/>
|
||||
|
||||
<!-- Signal brackets - left side -->
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Signal brackets - right side -->
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- The 'i' letter -->
|
||||
<circle cx="50" cy="22" r="7" fill="#00ff88"/>
|
||||
<rect x="43" y="35" width="14" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="36" y="35" width="28" height="5" rx="1" fill="#00d4ff"/>
|
||||
<rect x="36" y="75" width="28" height="5" rx="1" fill="#00d4ff"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 639 B After Width: | Height: | Size: 1.2 KiB |
@@ -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,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
|
||||
@@ -0,0 +1,898 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iNTERCEPT Promo</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--cyan: #00d4ff;
|
||||
--green: #00ff88;
|
||||
--red: #ff3366;
|
||||
--purple: #a855f7;
|
||||
--orange: #ff9500;
|
||||
--bg: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Container maintains 9:16 aspect ratio and scales to fit */
|
||||
.video-frame {
|
||||
position: relative;
|
||||
width: min(100vw, calc(100vh * 9 / 16));
|
||||
height: min(100vh, calc(100vw * 16 / 9));
|
||||
max-width: 1080px;
|
||||
max-height: 1920px;
|
||||
background: var(--bg);
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
/* Scale font size based on container width */
|
||||
font-size: min(16px, calc(100vw * 16 / 1080));
|
||||
}
|
||||
|
||||
/* Animated background grid */
|
||||
.grid-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
animation: gridMove 20s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gridMove {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(30px, 30px); }
|
||||
}
|
||||
|
||||
/* Scanning line effect */
|
||||
.scanline {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
|
||||
animation: scan 3s linear infinite;
|
||||
opacity: 0.7;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% { top: 0; }
|
||||
100% { top: 100%; }
|
||||
}
|
||||
|
||||
/* Glowing orbs background */
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(50px);
|
||||
opacity: 0.25;
|
||||
animation: orbFloat 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: var(--cyan);
|
||||
top: 10%;
|
||||
left: -10%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
background: var(--purple);
|
||||
bottom: 20%;
|
||||
right: -5%;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.orb-3 {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: var(--green);
|
||||
bottom: 40%;
|
||||
left: 20%;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
@keyframes orbFloat {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(30px, -30px) scale(1.1); }
|
||||
}
|
||||
|
||||
/* Main content container */
|
||||
.container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Scene management */
|
||||
.scene {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.8s ease, visibility 0.8s ease;
|
||||
}
|
||||
|
||||
.scene.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Scene 1: Logo reveal */
|
||||
.logo-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo-svg {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
margin-bottom: 20px;
|
||||
filter: drop-shadow(0 0 40px rgba(0, 212, 255, 0.5));
|
||||
}
|
||||
|
||||
.logo-svg .signal-wave {
|
||||
opacity: 0;
|
||||
animation: signalReveal 0.5s ease forwards;
|
||||
}
|
||||
|
||||
.logo-svg .signal-wave-1 { animation-delay: 0.5s; }
|
||||
.logo-svg .signal-wave-2 { animation-delay: 0.7s; }
|
||||
.logo-svg .signal-wave-3 { animation-delay: 0.9s; }
|
||||
.logo-svg .signal-wave-4 { animation-delay: 0.5s; }
|
||||
.logo-svg .signal-wave-5 { animation-delay: 0.7s; }
|
||||
.logo-svg .signal-wave-6 { animation-delay: 0.9s; }
|
||||
|
||||
@keyframes signalReveal {
|
||||
0% { opacity: 0; transform: scale(0.8); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.logo-svg .logo-i {
|
||||
opacity: 0;
|
||||
animation: logoReveal 0.8s ease forwards 0.2s;
|
||||
}
|
||||
|
||||
@keyframes logoReveal {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.logo-svg .logo-dot {
|
||||
animation: dotPulse 1.5s ease-in-out infinite 1s;
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%, 100% { filter: drop-shadow(0 0 5px rgba(0, 255, 136, 0.5)); }
|
||||
50% { filter: drop-shadow(0 0 25px rgba(0, 255, 136, 1)); }
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0;
|
||||
animation: titleReveal 1s ease forwards 1.2s;
|
||||
}
|
||||
|
||||
@keyframes titleReveal {
|
||||
0% { opacity: 0; transform: translateY(20px); letter-spacing: 0.3em; }
|
||||
100% { opacity: 1; transform: translateY(0); letter-spacing: 0.15em; }
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 18px;
|
||||
color: var(--cyan);
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0;
|
||||
animation: taglineReveal 0.8s ease forwards 1.8s;
|
||||
}
|
||||
|
||||
@keyframes taglineReveal {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 15px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0;
|
||||
animation: subtitleReveal 0.8s ease forwards 2.2s;
|
||||
}
|
||||
|
||||
@keyframes subtitleReveal {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Scene 2: Features */
|
||||
.features-scene {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 24px;
|
||||
color: var(--cyan);
|
||||
margin-bottom: 30px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
animation: featureReveal 0.6s ease forwards;
|
||||
}
|
||||
|
||||
.feature-card:nth-child(1) { animation-delay: 0.2s; }
|
||||
.feature-card:nth-child(2) { animation-delay: 0.4s; }
|
||||
.feature-card:nth-child(3) { animation-delay: 0.6s; }
|
||||
.feature-card:nth-child(4) { animation-delay: 0.8s; }
|
||||
|
||||
@keyframes featureReveal {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feature-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Scene 3: Modes showcase */
|
||||
.modes-scene {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mode-showcase {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mode-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-left: 3px solid var(--cyan);
|
||||
padding: 10px 15px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
animation: modeSlide 0.5s ease forwards;
|
||||
}
|
||||
|
||||
.mode-item:nth-child(1) { animation-delay: 0.1s; border-color: var(--cyan); }
|
||||
.mode-item:nth-child(2) { animation-delay: 0.2s; border-color: var(--green); }
|
||||
.mode-item:nth-child(3) { animation-delay: 0.3s; border-color: var(--purple); }
|
||||
.mode-item:nth-child(4) { animation-delay: 0.4s; border-color: var(--orange); }
|
||||
.mode-item:nth-child(5) { animation-delay: 0.5s; border-color: var(--red); }
|
||||
.mode-item:nth-child(6) { animation-delay: 0.6s; border-color: #00ffcc; }
|
||||
.mode-item:nth-child(7) { animation-delay: 0.7s; border-color: #ff66cc; }
|
||||
|
||||
@keyframes modeSlide {
|
||||
0% { opacity: 0; transform: translateX(-30px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
font-size: 22px;
|
||||
width: 35px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-info {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mode-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mode-desc {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Scene 4: UI Preview */
|
||||
.ui-scene {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-preview {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 60px rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.ui-header {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.ui-logo-small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.ui-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-body {
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ui-card {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.ui-card-header {
|
||||
font-size: 8px;
|
||||
color: var(--cyan);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.ui-stat {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.ui-stat.cyan { color: var(--cyan); }
|
||||
.ui-stat.orange { color: var(--orange); }
|
||||
|
||||
.ui-console {
|
||||
grid-column: span 3;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
.console-line {
|
||||
margin-bottom: 4px;
|
||||
opacity: 0;
|
||||
animation: consoleLine 0.3s ease forwards;
|
||||
}
|
||||
|
||||
.console-line:nth-child(1) { animation-delay: 0.5s; }
|
||||
.console-line:nth-child(2) { animation-delay: 0.8s; }
|
||||
.console-line:nth-child(3) { animation-delay: 1.1s; }
|
||||
.console-line:nth-child(4) { animation-delay: 1.4s; }
|
||||
.console-line:nth-child(5) { animation-delay: 1.7s; }
|
||||
|
||||
@keyframes consoleLine {
|
||||
0% { opacity: 0; transform: translateX(-10px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.console-time { color: #666; }
|
||||
.console-type { color: var(--cyan); }
|
||||
.console-msg { color: var(--green); }
|
||||
.console-freq { color: var(--orange); }
|
||||
|
||||
/* Scene 5: CTA */
|
||||
.cta-scene {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta-logo {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-bottom: 20px;
|
||||
animation: ctaLogoPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ctaLogoPulse {
|
||||
0%, 100% { filter: drop-shadow(0 0 20px rgba(0, 212, 255, 0.5)); transform: scale(1); }
|
||||
50% { filter: drop-shadow(0 0 40px rgba(0, 212, 255, 0.8)); transform: scale(1.05); }
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cta-tagline {
|
||||
font-size: 18px;
|
||||
color: var(--cyan);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.cta-btn {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
background: var(--cyan);
|
||||
border-radius: 30px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
animation: ctaBtnPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ctaBtnPulse {
|
||||
0%, 100% { box-shadow: 0 0 20px rgba(0, 212, 255, 0.5); }
|
||||
50% { box-shadow: 0 0 40px rgba(0, 212, 255, 0.8); }
|
||||
}
|
||||
|
||||
.cta-url {
|
||||
margin-top: 20px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Typing cursor effect */
|
||||
.typing-cursor {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 1em;
|
||||
background: var(--cyan);
|
||||
margin-left: 5px;
|
||||
animation: blink 0.8s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.progress-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-dot.active {
|
||||
background: var(--cyan);
|
||||
box-shadow: 0 0 10px var(--cyan);
|
||||
}
|
||||
|
||||
/* Decorative elements */
|
||||
.corner-decoration {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.corner-tl {
|
||||
top: 15px;
|
||||
left: 15px;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.corner-tr {
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
border-left: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.corner-bl {
|
||||
bottom: 50px;
|
||||
left: 15px;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.corner-br {
|
||||
bottom: 50px;
|
||||
right: 15px;
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="video-frame">
|
||||
<!-- Background elements -->
|
||||
<div class="grid-bg"></div>
|
||||
<div class="scanline"></div>
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
<div class="orb orb-3"></div>
|
||||
|
||||
<!-- Corner decorations -->
|
||||
<div class="corner-decoration corner-tl"></div>
|
||||
<div class="corner-decoration corner-tr"></div>
|
||||
<div class="corner-decoration corner-bl"></div>
|
||||
<div class="corner-decoration corner-br"></div>
|
||||
|
||||
<!-- Scene 1: Logo Reveal -->
|
||||
<div class="scene active" id="scene1">
|
||||
<div class="logo-container">
|
||||
<svg class="logo-svg" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Signal brackets - left side -->
|
||||
<path class="signal-wave signal-wave-1" d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path class="signal-wave signal-wave-2" d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path class="signal-wave signal-wave-3" d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<!-- Signal brackets - right side -->
|
||||
<path class="signal-wave signal-wave-4" d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path class="signal-wave signal-wave-5" d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path class="signal-wave signal-wave-6" d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<!-- The 'i' letter -->
|
||||
<g class="logo-i">
|
||||
<circle class="logo-dot" cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</g>
|
||||
</svg>
|
||||
<h1 class="title">iNTERCEPT</h1>
|
||||
<p class="tagline">// See the Invisible</p>
|
||||
<p class="subtitle">Signal Intelligence & Counter Surveillance</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 2: Features Grid -->
|
||||
<div class="scene" id="scene2">
|
||||
<div class="features-scene">
|
||||
<h2 class="feature-title">Capabilities</h2>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📡</div>
|
||||
<div class="feature-name">SDR Scanning</div>
|
||||
<div class="feature-desc">Multi-band reception</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔐</div>
|
||||
<div class="feature-name">Decryption</div>
|
||||
<div class="feature-desc">Signal analysis</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛰️</div>
|
||||
<div class="feature-name">Tracking</div>
|
||||
<div class="feature-desc">Real-time monitoring</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔍</div>
|
||||
<div class="feature-name">Detection</div>
|
||||
<div class="feature-desc">Counter surveillance</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 3: Modes List -->
|
||||
<div class="scene" id="scene3">
|
||||
<div class="modes-scene">
|
||||
<div class="mode-showcase">
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">📟</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">PAGER</div>
|
||||
<div class="mode-desc">POCSAG & FLEX decoding</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">✈️</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">ADS-B</div>
|
||||
<div class="mode-desc">Aircraft tracking</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">📻</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">LISTENING POST</div>
|
||||
<div class="mode-desc">RF monitoring & scanning</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">📶</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">WiFi</div>
|
||||
<div class="mode-desc">Network reconnaissance</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">🔵</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">BLUETOOTH</div>
|
||||
<div class="mode-desc">Device & tracker detection</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">🌡️</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">SENSORS</div>
|
||||
<div class="mode-desc">433MHz IoT decoding</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">🛰️</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">SATELLITE</div>
|
||||
<div class="mode-desc">Pass prediction & tracking</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 4: UI Preview -->
|
||||
<div class="scene" id="scene4">
|
||||
<div class="ui-scene">
|
||||
<div class="ui-preview">
|
||||
<div class="ui-header">
|
||||
<svg class="ui-logo-small" viewBox="0 0 100 100" fill="none">
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</svg>
|
||||
<span class="ui-title">iNTERCEPT</span>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-header">Messages</div>
|
||||
<div class="ui-stat">2,847</div>
|
||||
</div>
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-header">Aircraft</div>
|
||||
<div class="ui-stat cyan">42</div>
|
||||
</div>
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-header">Devices</div>
|
||||
<div class="ui-stat orange">156</div>
|
||||
</div>
|
||||
<div class="ui-console">
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:07]</span>
|
||||
<span class="console-type"> POCSAG </span>
|
||||
<span class="console-msg">Signal intercepted</span>
|
||||
<span class="console-freq"> 153.350 MHz</span>
|
||||
</div>
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:09]</span>
|
||||
<span class="console-type"> ADS-B </span>
|
||||
<span class="console-msg">Aircraft detected: BA284</span>
|
||||
<span class="console-freq"> FL350</span>
|
||||
</div>
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:11]</span>
|
||||
<span class="console-type"> BT </span>
|
||||
<span class="console-msg">AirTag detected nearby</span>
|
||||
<span class="console-freq"> -42 dBm</span>
|
||||
</div>
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:14]</span>
|
||||
<span class="console-type"> SENSOR </span>
|
||||
<span class="console-msg">Temperature: 22.4C</span>
|
||||
<span class="console-freq"> 433.92 MHz</span>
|
||||
</div>
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:16]</span>
|
||||
<span class="console-type"> SCAN </span>
|
||||
<span class="console-msg">Signal found</span>
|
||||
<span class="console-freq"> 145.500 MHz</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 5: CTA -->
|
||||
<div class="scene" id="scene5">
|
||||
<div class="cta-scene">
|
||||
<svg class="cta-logo" viewBox="0 0 100 100" fill="none">
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</svg>
|
||||
<h2 class="cta-title">iNTERCEPT</h2>
|
||||
<p class="cta-tagline">See the Invisible</p>
|
||||
<div class="cta-btn">Open Source</div>
|
||||
<p class="cta-url">github.com/yourrepo/intercept</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress dots -->
|
||||
<div class="progress-bar">
|
||||
<div class="progress-dot active" data-scene="1"></div>
|
||||
<div class="progress-dot" data-scene="2"></div>
|
||||
<div class="progress-dot" data-scene="3"></div>
|
||||
<div class="progress-dot" data-scene="4"></div>
|
||||
<div class="progress-dot" data-scene="5"></div>
|
||||
</div>
|
||||
</div><!-- end video-frame -->
|
||||
|
||||
<script>
|
||||
// Scene timing (in milliseconds)
|
||||
const sceneTiming = [
|
||||
{ scene: 1, duration: 4000 }, // Logo reveal
|
||||
{ scene: 2, duration: 4000 }, // Features
|
||||
{ scene: 3, duration: 5000 }, // Modes
|
||||
{ scene: 4, duration: 5000 }, // UI Preview
|
||||
{ scene: 5, duration: 4000 }, // CTA
|
||||
];
|
||||
|
||||
let currentScene = 0;
|
||||
|
||||
function showScene(index) {
|
||||
// Hide all scenes
|
||||
document.querySelectorAll('.scene').forEach(s => s.classList.remove('active'));
|
||||
document.querySelectorAll('.progress-dot').forEach(d => d.classList.remove('active'));
|
||||
|
||||
// Show current scene
|
||||
const scene = document.getElementById(`scene${index + 1}`);
|
||||
if (scene) {
|
||||
scene.classList.add('active');
|
||||
document.querySelector(`.progress-dot[data-scene="${index + 1}"]`).classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function nextScene() {
|
||||
currentScene++;
|
||||
if (currentScene >= sceneTiming.length) {
|
||||
currentScene = 0; // Loop back to start
|
||||
}
|
||||
showScene(currentScene);
|
||||
setTimeout(nextScene, sceneTiming[currentScene].duration);
|
||||
}
|
||||
|
||||
// Start the animation sequence
|
||||
setTimeout(nextScene, sceneTiming[0].duration);
|
||||
|
||||
// Keyboard controls for manual navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowRight') {
|
||||
currentScene = (currentScene + 1) % sceneTiming.length;
|
||||
showScene(currentScene);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
currentScene = (currentScene - 1 + sceneTiming.length) % sceneTiming.length;
|
||||
showScene(currentScene);
|
||||
} else if (e.key === ' ') {
|
||||
// Spacebar to pause/resume could be added here
|
||||
}
|
||||
});
|
||||
|
||||
// Click on progress dots to jump to scene
|
||||
document.querySelectorAll('.progress-dot').forEach(dot => {
|
||||
dot.addEventListener('click', () => {
|
||||
currentScene = parseInt(dot.dataset.scene) - 1;
|
||||
showScene(currentScene);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,10 +1,10 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "1.2.0"
|
||||
version = "2.16.0"
|
||||
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]
|
||||
@@ -40,12 +46,23 @@ Issues = "https://github.com/smittix/intercept/issues"
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"pytest-mock>=3.15.1",
|
||||
"ruff>=0.1.0",
|
||||
"black>=23.0.0",
|
||||
"mypy>=1.0.0",
|
||||
"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"
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
# Testing
|
||||
pytest>=7.0.0
|
||||
pytest-cov>=4.0.0
|
||||
pytest-mock>=3.15.1
|
||||
|
||||
# Code quality
|
||||
ruff>=0.1.0
|
||||
|
||||
@@ -1,15 +1,46 @@
|
||||
# Core dependencies
|
||||
flask>=2.0.0
|
||||
flask>=3.0.0
|
||||
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
|
||||
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
||||
flask-sock
|
||||
websocket-client>=1.6.0
|
||||
|
||||
@@ -4,16 +4,71 @@ def register_blueprints(app):
|
||||
"""Register all route blueprints with the Flask app."""
|
||||
from .pager import pager_bp
|
||||
from .sensor import sensor_bp
|
||||
from .rtlamr import rtlamr_bp
|
||||
from .wifi import wifi_bp
|
||||
from .wifi_v2 import wifi_v2_bp
|
||||
from .bluetooth import bluetooth_bp
|
||||
from .bluetooth_v2 import bluetooth_v2_bp
|
||||
from .adsb import adsb_bp
|
||||
from .ais import ais_bp
|
||||
from .dsc import dsc_bp
|
||||
from .acars import acars_bp
|
||||
from .aprs import aprs_bp
|
||||
from .satellite import satellite_bp
|
||||
from .gps import gps_bp
|
||||
from .settings import settings_bp
|
||||
from .correlation import correlation_bp
|
||||
from .listening_post import listening_post_bp
|
||||
from .meshtastic import meshtastic_bp
|
||||
from .tscm import tscm_bp, init_tscm_state
|
||||
from .spy_stations import spy_stations_bp
|
||||
from .controller import controller_bp
|
||||
from .offline import offline_bp
|
||||
from .updater import updater_bp
|
||||
from .sstv import sstv_bp
|
||||
from .weather_sat import weather_sat_bp
|
||||
from .sstv_general import sstv_general_bp
|
||||
from .dmr import dmr_bp
|
||||
from .websdr import websdr_bp
|
||||
from .alerts import alerts_bp
|
||||
from .recordings import recordings_bp
|
||||
from .subghz import subghz_bp
|
||||
from .bt_locate import bt_locate_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(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(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(dmr_bp) # DMR / P25 / Digital Voice
|
||||
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
|
||||
|
||||
# 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,424 @@
|
||||
"""ACARS aircraft messaging routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
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 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_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
from utils.process import register_process, unregister_process
|
||||
|
||||
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
||||
|
||||
# Default VHF ACARS frequencies (MHz) - common worldwide
|
||||
DEFAULT_ACARS_FREQUENCIES = [
|
||||
'131.550', # Primary worldwide
|
||||
'130.025', # Secondary USA/Canada
|
||||
'129.125', # USA
|
||||
'131.525', # Europe
|
||||
'131.725', # Europe secondary
|
||||
]
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
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'
|
||||
|
||||
# Update stats
|
||||
acars_message_count += 1
|
||||
acars_last_message_time = time.time()
|
||||
|
||||
app_module.acars_queue.put(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
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
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_device = 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
|
||||
|
||||
with app_module.acars_lock:
|
||||
if app_module.acars_process and app_module.acars_process.poll() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'ACARS decoder already running'
|
||||
}), 409
|
||||
|
||||
# Check for acarsdec
|
||||
acarsdec_path = find_acarsdec()
|
||||
if not acarsdec_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': '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 jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'acars')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
acars_active_device = device_int
|
||||
|
||||
# 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
|
||||
|
||||
# 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> ...
|
||||
# Note: gain/ppm must come BEFORE -r
|
||||
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)
|
||||
if gain and str(gain) != '0':
|
||||
cmd.extend(['-g', str(gain)])
|
||||
|
||||
# Add PPM correction if specified (must be before -r)
|
||||
if ppm and str(ppm) != '0':
|
||||
cmd.extend(['-p', str(ppm)])
|
||||
|
||||
# Add device and frequencies
|
||||
# f00b4r0 uses --rtlsdr <device>, TLeconte uses -r <device>
|
||||
if json_flag == '--output':
|
||||
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
|
||||
cmd.extend(['-m', '256'])
|
||||
cmd.extend(['--rtlsdr', str(device)])
|
||||
else:
|
||||
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 = io.open(master_fd, 'r', 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_device = None
|
||||
stderr = ''
|
||||
if process.stderr:
|
||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||
error_msg = f'acarsdec failed to start'
|
||||
if stderr:
|
||||
error_msg += f': {stderr[:200]}'
|
||||
logger.error(error_msg)
|
||||
return jsonify({'status': 'error', 'message': 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_device = None
|
||||
logger.error(f"Failed to start ACARS decoder: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@acars_bp.route('/stop', methods=['POST'])
|
||||
def stop_acars() -> Response:
|
||||
"""Stop ACARS decoder."""
|
||||
global acars_active_device
|
||||
|
||||
with app_module.acars_lock:
|
||||
if not app_module.acars_process:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': '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_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@acars_bp.route('/stream')
|
||||
def stream_acars() -> Response:
|
||||
"""SSE stream for ACARS messages."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('acars', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
|
||||
|
||||
@acars_bp.route('/frequencies')
|
||||
def get_frequencies() -> Response:
|
||||
"""Get default ACARS frequencies."""
|
||||
return jsonify({
|
||||
'default': DEFAULT_ACARS_FREQUENCIES,
|
||||
'regions': {
|
||||
'north_america': ['129.125', '130.025', '130.450', '131.550'],
|
||||
'europe': ['131.525', '131.725', '131.550'],
|
||||
'asia_pacific': ['131.550', '131.450'],
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,511 @@
|
||||
"""AIS vessel tracking routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, render_template
|
||||
|
||||
import app as app_module
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
from utils.logging import get_logger
|
||||
from utils.validation import validate_device_index, validate_gain
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
AIS_TCP_PORT,
|
||||
AIS_TERMINATE_TIMEOUT,
|
||||
AIS_SOCKET_TIMEOUT,
|
||||
AIS_RECONNECT_DELAY,
|
||||
AIS_UPDATE_INTERVAL,
|
||||
SOCKET_BUFFER_SIZE,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SOCKET_CONNECT_TIMEOUT,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
)
|
||||
|
||||
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_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:
|
||||
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:
|
||||
try:
|
||||
app_module.ais_queue.put_nowait({
|
||||
'type': 'vessel',
|
||||
**app_module.ais_vessels[mmsi]
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
|
||||
sock.close()
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
|
||||
with app_module.ais_lock:
|
||||
if ais_running:
|
||||
return jsonify({'status': 'already_running', 'message': '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 jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Find AIS-catcher
|
||||
ais_catcher_path = find_ais_catcher()
|
||||
if not ais_catcher_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': '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')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
# 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)
|
||||
stderr_output = ''
|
||||
if app_module.ais_process.stderr:
|
||||
try:
|
||||
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||
except Exception:
|
||||
pass
|
||||
error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
ais_running = True
|
||||
ais_active_device = device
|
||||
|
||||
# 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)
|
||||
logger.error(f"Failed to start AIS-catcher: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@ais_bp.route('/stop', methods=['POST'])
|
||||
def stop_ais():
|
||||
"""Stop AIS tracking."""
|
||||
global ais_running, ais_active_device
|
||||
|
||||
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_running = False
|
||||
ais_active_device = None
|
||||
|
||||
app_module.ais_vessels.clear()
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@ais_bp.route('/stream')
|
||||
def stream_ais():
|
||||
"""SSE stream for AIS vessels."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('ais', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
|
||||
|
||||
@ais_bp.route('/dashboard')
|
||||
def ais_dashboard():
|
||||
"""Popout AIS dashboard."""
|
||||
return render_template(
|
||||
'ais_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Alerting API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.alerts import get_alert_manager
|
||||
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 jsonify({'status': 'success', '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 jsonify({'status': 'error', 'message': 'match must be a JSON object'}), 400
|
||||
|
||||
manager = get_alert_manager()
|
||||
rule_id = manager.add_rule(data)
|
||||
return jsonify({'status': 'success', '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 jsonify({'status': 'error', 'message': 'Rule not found or no changes'}), 404
|
||||
return jsonify({'status': '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 jsonify({'status': 'error', 'message': 'Rule not found'}), 404
|
||||
return jsonify({'status': '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 jsonify({'status': 'success', '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
|
||||
@@ -0,0 +1,276 @@
|
||||
"""WebSocket-based audio streaming for SDR."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from flask import Flask
|
||||
|
||||
# Try to import flask-sock
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
Sock = None
|
||||
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.audio_ws')
|
||||
|
||||
# Global state
|
||||
audio_process = None
|
||||
rtl_process = None
|
||||
process_lock = threading.Lock()
|
||||
current_config = {
|
||||
'frequency': 118.0,
|
||||
'modulation': 'am',
|
||||
'squelch': 0,
|
||||
'gain': 40,
|
||||
'device': 0
|
||||
}
|
||||
|
||||
|
||||
def find_rtl_fm():
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
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
|
||||
|
||||
if audio_process:
|
||||
try:
|
||||
audio_process.terminate()
|
||||
audio_process.wait(timeout=0.5)
|
||||
except:
|
||||
try:
|
||||
audio_process.kill()
|
||||
except:
|
||||
pass
|
||||
audio_process = None
|
||||
|
||||
if rtl_process:
|
||||
try:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=0.5)
|
||||
except:
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except:
|
||||
pass
|
||||
rtl_process = None
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
def start_audio_stream(config):
|
||||
"""Start rtl_fm + ffmpeg pipeline, return the ffmpeg process."""
|
||||
global audio_process, rtl_process, current_config
|
||||
|
||||
kill_audio_processes()
|
||||
|
||||
rtl_fm = find_rtl_fm()
|
||||
ffmpeg = find_ffmpeg()
|
||||
|
||||
if not rtl_fm or not ffmpeg:
|
||||
logger.error("rtl_fm or ffmpeg not found")
|
||||
return None
|
||||
|
||||
current_config.update(config)
|
||||
|
||||
freq = config.get('frequency', 118.0)
|
||||
mod = config.get('modulation', 'am')
|
||||
squelch = config.get('squelch', 0)
|
||||
gain = config.get('gain', 40)
|
||||
device = config.get('device', 0)
|
||||
|
||||
# Sample rates based on modulation
|
||||
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
|
||||
|
||||
freq_hz = int(freq * 1e6)
|
||||
|
||||
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),
|
||||
]
|
||||
|
||||
# Encode to MP3 for browser compatibility
|
||||
ffmpeg_cmd = [
|
||||
ffmpeg,
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-f', 's16le',
|
||||
'-ar', str(resample_rate),
|
||||
'-ac', '1',
|
||||
'-i', 'pipe:0',
|
||||
'-acodec', 'libmp3lame',
|
||||
'-b:a', '128k',
|
||||
'-f', 'mp3',
|
||||
'-flush_packets', '1',
|
||||
'pipe:1'
|
||||
]
|
||||
|
||||
try:
|
||||
logger.info(f"Starting rtl_fm: {freq} MHz, {mod}")
|
||||
rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
audio_process = subprocess.Popen(
|
||||
ffmpeg_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
bufsize=0
|
||||
)
|
||||
|
||||
rtl_process.stdout.close()
|
||||
|
||||
# Check processes started
|
||||
time.sleep(0.2)
|
||||
if rtl_process.poll() is not None or audio_process.poll() is not None:
|
||||
logger.error("Audio process failed to start")
|
||||
kill_audio_processes()
|
||||
return None
|
||||
|
||||
return audio_process
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start audio: {e}")
|
||||
kill_audio_processes()
|
||||
return None
|
||||
|
||||
|
||||
def init_audio_websocket(app: Flask):
|
||||
"""Initialize WebSocket audio streaming."""
|
||||
if not WEBSOCKET_AVAILABLE:
|
||||
logger.warning("flask-sock not installed, WebSocket audio disabled")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/audio')
|
||||
def audio_stream(ws):
|
||||
"""WebSocket endpoint for audio streaming."""
|
||||
logger.info("WebSocket audio client connected")
|
||||
|
||||
proc = None
|
||||
streaming = False
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Check for messages from client (non-blocking with timeout)
|
||||
try:
|
||||
msg = ws.receive(timeout=0.01)
|
||||
if msg:
|
||||
data = json.loads(msg)
|
||||
cmd = data.get('cmd')
|
||||
|
||||
if cmd == 'start':
|
||||
config = data.get('config', {})
|
||||
logger.info(f"Starting audio: {config}")
|
||||
with process_lock:
|
||||
proc = start_audio_stream(config)
|
||||
if proc:
|
||||
streaming = True
|
||||
ws.send(json.dumps({'status': 'started'}))
|
||||
else:
|
||||
ws.send(json.dumps({'status': 'error', 'message': 'Failed to start'}))
|
||||
|
||||
elif cmd == 'stop':
|
||||
logger.info("Stopping audio")
|
||||
streaming = False
|
||||
with process_lock:
|
||||
kill_audio_processes()
|
||||
proc = None
|
||||
ws.send(json.dumps({'status': 'stopped'}))
|
||||
|
||||
elif cmd == 'tune':
|
||||
# Change frequency/modulation - restart stream
|
||||
config = data.get('config', {})
|
||||
logger.info(f"Retuning: {config}")
|
||||
with process_lock:
|
||||
proc = start_audio_stream(config)
|
||||
if proc:
|
||||
streaming = True
|
||||
ws.send(json.dumps({'status': 'tuned'}))
|
||||
else:
|
||||
streaming = False
|
||||
ws.send(json.dumps({'status': 'error', 'message': 'Failed to tune'}))
|
||||
|
||||
except TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
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
|
||||
if streaming and proc and proc.poll() is None:
|
||||
try:
|
||||
chunk = proc.stdout.read(4096)
|
||||
if chunk:
|
||||
ws.send(chunk)
|
||||
except Exception as e:
|
||||
logger.error(f"Audio read error: {e}")
|
||||
streaming = False
|
||||
elif streaming:
|
||||
# Process died
|
||||
streaming = False
|
||||
ws.send(json.dumps({'status': 'error', 'message': 'Audio process died'}))
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"WebSocket closed: {e}")
|
||||
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.
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ws.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("WebSocket audio client disconnected")
|
||||
@@ -18,11 +18,24 @@ from typing import Any, Generator
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
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.dependencies import check_tool
|
||||
from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.validation import validate_bluetooth_interface
|
||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||
from utils.constants import (
|
||||
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,
|
||||
)
|
||||
|
||||
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
|
||||
|
||||
@@ -32,42 +45,76 @@ def classify_bt_device(name, device_class, services, manufacturer=None):
|
||||
name_lower = (name or '').lower()
|
||||
mfr_lower = (manufacturer or '').lower()
|
||||
|
||||
# Audio devices - check name patterns first
|
||||
audio_patterns = [
|
||||
'airpod', 'earbud', 'headphone', 'headset', 'speaker', 'audio', 'beats', 'bose',
|
||||
'jbl', 'sony wh', 'sony wf', 'sennheiser', 'jabra', 'soundcore', 'anker', 'buds',
|
||||
'earphone', 'pod', 'soundbar', 'skullcandy', 'marshall', 'b&o', 'bang', 'olufsen'
|
||||
'earphone', 'pod', 'soundbar', 'skullcandy', 'marshall', 'b&o', 'bang', 'olufsen',
|
||||
'powerbeats', 'soundlink', 'soundsport', 'quietcomfort', 'qc35', 'qc45', 'nc700',
|
||||
'wh-1000', 'wf-1000', 'linkbuds', 'freebuds', 'galaxy buds', 'pixel buds',
|
||||
'echo dot', 'homepod', 'sonos', 'ue boom', 'flip', 'charge', 'xtreme', 'pulse'
|
||||
]
|
||||
if any(x in name_lower for x in audio_patterns):
|
||||
return 'audio'
|
||||
|
||||
# Wearables
|
||||
wearable_patterns = [
|
||||
'watch', 'band', 'fitbit', 'garmin', 'mi band', 'miband', 'amazfit',
|
||||
'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire'
|
||||
'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire', 'fenix',
|
||||
'forerunner', 'venu', 'vivoactive', 'instinct', 'apple watch', 'gt 2', 'gt2'
|
||||
]
|
||||
if any(x in name_lower for x in wearable_patterns):
|
||||
return 'wearable'
|
||||
|
||||
# Phones - check name patterns
|
||||
phone_patterns = [
|
||||
'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi'
|
||||
'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi',
|
||||
'redmi', 'poco', 'realme', 'oppo', 'vivo', 'motorola', 'nokia', 'lg-', 'sm-',
|
||||
'moto g', 'moto e', 'note', 'ultra', 'pro max', 's21', 's22', 's23', 's24'
|
||||
]
|
||||
if any(x in name_lower for x in phone_patterns):
|
||||
return 'phone'
|
||||
|
||||
tracker_patterns = ['airtag', 'tile', 'smarttag', 'chipolo', 'find my']
|
||||
# Trackers
|
||||
tracker_patterns = ['airtag', 'tile', 'smarttag', 'chipolo', 'find my', 'findmy']
|
||||
if any(x in name_lower for x in tracker_patterns):
|
||||
return 'tracker'
|
||||
|
||||
input_patterns = ['keyboard', 'mouse', 'controller', 'gamepad', 'remote']
|
||||
# Input devices
|
||||
input_patterns = ['keyboard', 'mouse', 'controller', 'gamepad', 'remote', 'trackpad',
|
||||
'magic keyboard', 'magic mouse', 'magic trackpad', 'mx master', 'mx keys',
|
||||
'logitech k', 'logitech m', 'razer', 'dualshock', 'dualsense', 'xbox']
|
||||
if any(x in name_lower for x in input_patterns):
|
||||
return 'input'
|
||||
|
||||
if mfr_lower in ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats']:
|
||||
# Computers/laptops
|
||||
computer_patterns = ['macbook', 'imac', 'mac pro', 'mac mini', 'dell', 'hp ', 'lenovo',
|
||||
'thinkpad', 'surface', 'chromebook', 'laptop', 'desktop', 'pc']
|
||||
if any(x in name_lower for x in computer_patterns):
|
||||
return 'computer'
|
||||
|
||||
# Check manufacturer for device type inference
|
||||
audio_manufacturers = ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats',
|
||||
'bang & olufsen', 'audio-technica', 'skullcandy', 'anker', 'plantronics']
|
||||
if mfr_lower in audio_manufacturers:
|
||||
return 'audio'
|
||||
if mfr_lower in ['fitbit', 'garmin']:
|
||||
|
||||
wearable_manufacturers = ['fitbit', 'garmin']
|
||||
if mfr_lower in wearable_manufacturers:
|
||||
return 'wearable'
|
||||
|
||||
if mfr_lower == 'tile':
|
||||
return 'tracker'
|
||||
|
||||
phone_manufacturers = ['samsung', 'xiaomi', 'huawei', 'oneplus', 'google', 'oppo', 'vivo', 'realme']
|
||||
if mfr_lower in phone_manufacturers:
|
||||
return 'phone'
|
||||
|
||||
computer_manufacturers = ['dell', 'hp', 'lenovo', 'microsoft', 'intel']
|
||||
if mfr_lower in computer_manufacturers:
|
||||
return 'computer'
|
||||
|
||||
# Check device class if available
|
||||
if device_class:
|
||||
major_class = (device_class >> 8) & 0x1F
|
||||
if major_class == 1:
|
||||
@@ -113,7 +160,7 @@ def detect_bt_interfaces():
|
||||
|
||||
if platform.system() == 'Linux':
|
||||
try:
|
||||
result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=5)
|
||||
result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
|
||||
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
|
||||
for block in blocks:
|
||||
if block.strip():
|
||||
@@ -127,8 +174,12 @@ def detect_bt_interfaces():
|
||||
'type': 'hci',
|
||||
'status': 'up' if is_up else 'down'
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
logger.debug("hciconfig not found")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("hciconfig timed out")
|
||||
except subprocess.SubprocessError as e:
|
||||
logger.warning(f"Error running hciconfig: {e}")
|
||||
|
||||
elif platform.system() == 'Darwin':
|
||||
interfaces.append({
|
||||
@@ -203,18 +254,43 @@ def stream_bt_scan(process, scan_mode):
|
||||
line = re.sub(r'\r', '', line)
|
||||
|
||||
if 'Device' in line:
|
||||
# Check for RSSI update: [CHG] Device XX:XX:XX RSSI: -65
|
||||
rssi_match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}).*RSSI:\s*(-?\d+)', line)
|
||||
if rssi_match:
|
||||
mac = rssi_match.group(1).upper()
|
||||
rssi = int(rssi_match.group(2))
|
||||
if mac in app_module.bt_devices:
|
||||
app_module.bt_devices[mac]['rssi'] = rssi
|
||||
app_module.bt_devices[mac]['last_seen'] = time.time()
|
||||
# Send RSSI update
|
||||
app_module.bt_queue.put({
|
||||
**app_module.bt_devices[mac],
|
||||
'type': 'device',
|
||||
'device_type': app_module.bt_devices[mac].get('type', 'other'),
|
||||
'action': 'update',
|
||||
})
|
||||
continue
|
||||
|
||||
# Check for new device: [NEW] Device XX:XX:XX Name
|
||||
match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s*(.*)', line)
|
||||
if match:
|
||||
mac = match.group(1).upper()
|
||||
name = match.group(2).strip()
|
||||
|
||||
# Extract RSSI from name if present
|
||||
rssi_in_name = re.search(r'RSSI:\s*(-?\d+)', name)
|
||||
initial_rssi = int(rssi_in_name.group(1)) if rssi_in_name else None
|
||||
|
||||
# Remove "RSSI: -XX" from name
|
||||
name = re.sub(r'\s*RSSI:\s*-?\d+\s*', '', name).strip()
|
||||
|
||||
manufacturer = get_manufacturer(mac)
|
||||
device = {
|
||||
'mac': mac,
|
||||
'name': name or '[Unknown]',
|
||||
'manufacturer': manufacturer,
|
||||
'type': classify_bt_device(name, None, None, manufacturer),
|
||||
'rssi': None,
|
||||
'rssi': initial_rssi,
|
||||
'last_seen': time.time()
|
||||
}
|
||||
|
||||
@@ -289,9 +365,14 @@ def start_bt_scan():
|
||||
|
||||
data = request.json
|
||||
scan_mode = data.get('mode', 'hcitool')
|
||||
interface = data.get('interface', 'hci0')
|
||||
scan_ble = data.get('scan_ble', True)
|
||||
|
||||
# Validate Bluetooth interface name
|
||||
try:
|
||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
app_module.bt_interface = interface
|
||||
app_module.bt_devices = {}
|
||||
|
||||
@@ -373,7 +454,12 @@ def stop_bt_scan():
|
||||
def reset_bt_adapter():
|
||||
"""Reset Bluetooth adapter."""
|
||||
data = request.json
|
||||
interface = data.get('interface', 'hci0')
|
||||
|
||||
# Validate Bluetooth interface name
|
||||
try:
|
||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
@@ -476,9 +562,13 @@ def stream_bt():
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.bt_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
msg = app_module.bt_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('bluetooth', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
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.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)
|
||||
- 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'),
|
||||
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]):
|
||||
return jsonify({'error': 'At least one target identifier required (mac_address, name_pattern, irk_hex, or device_id)'}), 400
|
||||
|
||||
# Parse environment
|
||||
env_str = data.get('environment', 'OUTDOOR').upper()
|
||||
try:
|
||||
environment = Environment[env_str]
|
||||
except KeyError:
|
||||
return jsonify({'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 jsonify({'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})"
|
||||
)
|
||||
|
||||
session = start_locate_session(
|
||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||
)
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
return jsonify(session.get_status())
|
||||
|
||||
|
||||
@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 jsonify({'error': 'irk_hex and address are required'}), 400
|
||||
|
||||
try:
|
||||
irk = bytes.fromhex(irk_hex)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid IRK hex string'}), 400
|
||||
|
||||
if len(irk) != 16:
|
||||
return jsonify({'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 jsonify({'error': 'no active session'}), 400
|
||||
|
||||
data = request.get_json() or {}
|
||||
env_str = data.get('environment', '').upper()
|
||||
try:
|
||||
environment = Environment[env_str]
|
||||
except KeyError:
|
||||
return jsonify({'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 jsonify({'error': 'no session'})
|
||||
|
||||
scanner = session._scanner
|
||||
if not scanner:
|
||||
return jsonify({'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,896 @@
|
||||
"""
|
||||
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 json
|
||||
import logging
|
||||
import queue
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Generator
|
||||
|
||||
import requests
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.database import (
|
||||
create_agent, get_agent, get_agent_by_name, list_agents,
|
||||
update_agent, delete_agent, store_push_payload, get_recent_payloads
|
||||
)
|
||||
from utils.agent_client import (
|
||||
AgentClient, AgentHTTPError, AgentConnectionError, create_client_from_agent
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.trilateration import (
|
||||
DeviceLocationTracker, PathLossModel, Trilateration,
|
||||
AgentObservation, estimate_location_from_observations
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.controller')
|
||||
|
||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||
|
||||
# Multi-agent data queue for combined SSE stream
|
||||
agent_data_queue: queue.Queue = queue.Queue(maxsize=1000)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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 jsonify({'status': 'error', 'message': 'Agent name is required'}), 400
|
||||
if not base_url:
|
||||
return jsonify({'status': 'error', 'message': '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 jsonify({'status': 'error', 'message': 'URL must start with http:// or https://'}), 400
|
||||
if not parsed.netloc:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
|
||||
except Exception:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid URL format'}), 400
|
||||
|
||||
# Check if agent already exists
|
||||
existing = get_agent_by_name(name)
|
||||
if existing:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 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 jsonify({'status': 'error', 'message': 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 jsonify({'status': 'error', 'message': '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 jsonify({'status': 'error', 'message': '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 jsonify({'status': 'error', 'message': '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 jsonify({'status': 'error', 'message': '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 jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Agent is not reachable'
|
||||
}), 503
|
||||
|
||||
except (AgentHTTPError, AgentConnectionError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 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 jsonify({'status': 'error', 'message': '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 jsonify({
|
||||
'status': 'error',
|
||||
'message': 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 jsonify({'status': 'error', 'message': '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 jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Cannot connect to agent: {e}'
|
||||
}), 503
|
||||
except AgentHTTPError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 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 jsonify({'status': 'error', 'message': '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 jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Cannot connect to agent: {e}'
|
||||
}), 503
|
||||
except AgentHTTPError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 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 jsonify({'status': 'error', 'message': '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 jsonify({
|
||||
'status': 'error',
|
||||
'message': 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 jsonify({'status': 'error', 'message': '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 jsonify({
|
||||
'status': 'error',
|
||||
'message': 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 jsonify({'status': 'error', 'message': '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 jsonify({'status': 'error', 'message': '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 jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Cannot connect to agent: {e}'
|
||||
}), 503
|
||||
except AgentHTTPError as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 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 jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||
|
||||
agent_name = data.get('agent_name')
|
||||
if not agent_name:
|
||||
return jsonify({'status': 'error', 'message': 'agent_name required'}), 400
|
||||
|
||||
# Find agent
|
||||
agent = get_agent_by_name(agent_name)
|
||||
if not agent:
|
||||
return jsonify({'status': 'error', 'message': '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 jsonify({'status': 'error', 'message': '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
|
||||
try:
|
||||
agent_data_queue.put_nowait({
|
||||
'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()
|
||||
})
|
||||
except queue.Full:
|
||||
logger.warning("Agent data queue full, data may be lost")
|
||||
|
||||
return jsonify({
|
||||
'status': 'accepted',
|
||||
'payload_id': payload_id
|
||||
}), 202
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to store push payload")
|
||||
return jsonify({'status': 'error', 'message': 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.
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = agent_data_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
|
||||
|
||||
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 jsonify({'status': 'error', 'message': 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 jsonify({
|
||||
'status': 'error',
|
||||
'message': '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 jsonify({
|
||||
'status': 'error',
|
||||
'message': '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 jsonify({'status': 'error', 'message': 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 jsonify({'status': 'error', 'message': '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
|
||||
]
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Device correlation routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.correlation import get_correlations
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.correlation')
|
||||
|
||||
correlation_bp = Blueprint('correlation', __name__, url_prefix='/correlation')
|
||||
|
||||
|
||||
@correlation_bp.route('', methods=['GET'])
|
||||
def get_device_correlations() -> Response:
|
||||
"""
|
||||
Get device correlations between WiFi and Bluetooth.
|
||||
|
||||
Query params:
|
||||
min_confidence: Minimum confidence threshold (default 0.5)
|
||||
include_historical: Include database correlations (default true)
|
||||
"""
|
||||
min_confidence = request.args.get('min_confidence', 0.5, type=float)
|
||||
include_historical = request.args.get('include_historical', 'true').lower() == 'true'
|
||||
|
||||
try:
|
||||
# Get current device data
|
||||
wifi_devices = dict(app_module.wifi_networks)
|
||||
wifi_devices.update(dict(app_module.wifi_clients))
|
||||
bt_devices = dict(app_module.bt_devices)
|
||||
|
||||
# Calculate correlations
|
||||
correlations = get_correlations(
|
||||
wifi_devices=wifi_devices,
|
||||
bt_devices=bt_devices,
|
||||
min_confidence=min_confidence,
|
||||
include_historical=include_historical
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'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
|
||||
|
||||
|
||||
@correlation_bp.route('/analyze', methods=['POST'])
|
||||
def analyze_correlation() -> Response:
|
||||
"""
|
||||
Analyze specific device pair for correlation.
|
||||
|
||||
Request body:
|
||||
wifi_mac: WiFi device MAC address
|
||||
bt_mac: Bluetooth device MAC address
|
||||
"""
|
||||
data = request.json or {}
|
||||
wifi_mac = data.get('wifi_mac')
|
||||
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
|
||||
|
||||
try:
|
||||
# Get device data
|
||||
wifi_device = app_module.wifi_networks.get(wifi_mac)
|
||||
if not wifi_device:
|
||||
wifi_device = app_module.wifi_clients.get(wifi_mac)
|
||||
|
||||
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
|
||||
|
||||
if not bt_device:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Bluetooth device {bt_mac} not found'
|
||||
}), 404
|
||||
|
||||
# Calculate correlation for this specific pair
|
||||
correlations = get_correlations(
|
||||
wifi_devices={wifi_mac: wifi_device},
|
||||
bt_devices={bt_mac: bt_device},
|
||||
min_confidence=0.0, # Show even low confidence for analysis
|
||||
include_historical=True
|
||||
)
|
||||
|
||||
if correlations:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'correlation': correlations[0]
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'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
|
||||
@@ -0,0 +1,758 @@
|
||||
"""DMR / P25 / Digital Voice decoding routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import register_process, unregister_process
|
||||
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
QUEUE_MAX_SIZE,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.dmr')
|
||||
|
||||
dmr_bp = Blueprint('dmr', __name__, url_prefix='/dmr')
|
||||
|
||||
# ============================================
|
||||
# GLOBAL STATE
|
||||
# ============================================
|
||||
|
||||
dmr_rtl_process: Optional[subprocess.Popen] = None
|
||||
dmr_dsd_process: Optional[subprocess.Popen] = None
|
||||
dmr_thread: Optional[threading.Thread] = None
|
||||
dmr_running = False
|
||||
dmr_has_audio = False # True when ffmpeg available and dsd outputs audio
|
||||
dmr_lock = threading.Lock()
|
||||
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
dmr_active_device: Optional[int] = None
|
||||
|
||||
# Audio mux: the sole reader of dsd-fme stdout. Fans out bytes to all
|
||||
# active ffmpeg stdin sinks when streaming clients are connected.
|
||||
# This prevents dsd-fme from blocking on stdout (which would also
|
||||
# freeze stderr / text data output).
|
||||
_ffmpeg_sinks: set[object] = set()
|
||||
_ffmpeg_sinks_lock = threading.Lock()
|
||||
|
||||
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
|
||||
|
||||
# Classic dsd flags
|
||||
_DSD_PROTOCOL_FLAGS = {
|
||||
'auto': [],
|
||||
'dmr': ['-fd'],
|
||||
'p25': ['-fp'],
|
||||
'nxdn': ['-fn'],
|
||||
'dstar': ['-fi'],
|
||||
'provoice': ['-fv'],
|
||||
}
|
||||
|
||||
# dsd-fme remapped several flags from classic DSD:
|
||||
# -fs = DMR Simplex (NOT -fd which is D-STAR!),
|
||||
# -fd = D-STAR (NOT DMR!), -fp = ProVoice (NOT P25),
|
||||
# -fi = NXDN48 (NOT D-Star), -f1 = P25 Phase 1,
|
||||
# -ft = XDMA multi-protocol decoder
|
||||
_DSD_FME_PROTOCOL_FLAGS = {
|
||||
'auto': ['-fa'], # Broad auto: P25 (P1/P2), DMR, D-STAR, YSF, X2-TDMA
|
||||
'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!)
|
||||
'p25': ['-ft'], # P25 P1/P2 coverage (also includes DMR in dsd-fme)
|
||||
'nxdn': ['-fn'], # NXDN96
|
||||
'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!)
|
||||
'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv)
|
||||
}
|
||||
|
||||
# Modulation hints: force C4FM for protocols that use it, improving
|
||||
# sync reliability vs letting dsd-fme auto-detect modulation type.
|
||||
_DSD_FME_MODULATION = {
|
||||
'dmr': ['-mc'], # C4FM
|
||||
'nxdn': ['-mc'], # C4FM
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# HELPERS
|
||||
# ============================================
|
||||
|
||||
|
||||
def find_dsd() -> tuple[str | None, bool]:
|
||||
"""Find DSD (Digital Speech Decoder) binary.
|
||||
|
||||
Checks for dsd-fme first (common fork), then falls back to dsd.
|
||||
Returns (path, is_fme) tuple.
|
||||
"""
|
||||
path = shutil.which('dsd-fme')
|
||||
if path:
|
||||
return path, True
|
||||
path = shutil.which('dsd')
|
||||
if path:
|
||||
return path, False
|
||||
return None, False
|
||||
|
||||
|
||||
def find_rtl_fm() -> str | None:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rx_fm() -> str | None:
|
||||
"""Find SoapySDR rx_fm binary."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_ffmpeg() -> str | None:
|
||||
"""Find ffmpeg for audio encoding."""
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
def parse_dsd_output(line: str) -> dict | None:
|
||||
"""Parse a line of DSD stderr output into a structured event.
|
||||
|
||||
Handles output from both classic ``dsd`` and ``dsd-fme`` which use
|
||||
different formatting for talkgroup / source / voice frame lines.
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return None
|
||||
|
||||
# Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.)
|
||||
# Only filter lines that are purely decorative — dsd-fme uses box-drawing
|
||||
# characters (│, ─) as column separators in DATA lines, so we must not
|
||||
# discard lines that also contain alphanumeric content.
|
||||
stripped_of_box = re.sub(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░\s]', '', line)
|
||||
if not stripped_of_box:
|
||||
return None
|
||||
if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line):
|
||||
return None
|
||||
|
||||
ts = datetime.now().strftime('%H:%M:%S')
|
||||
|
||||
# Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1"
|
||||
sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line)
|
||||
if sync_match:
|
||||
return {
|
||||
'type': 'sync',
|
||||
'protocol': sync_match.group(1).strip(),
|
||||
'timestamp': ts,
|
||||
}
|
||||
|
||||
# Talkgroup and Source — check BEFORE slot so "Slot 1 Voice LC, TG: …"
|
||||
# is captured as a call event rather than a bare slot event.
|
||||
# Classic dsd: "TG: 12345 Src: 67890"
|
||||
# dsd-fme: "TG: 12345, Src: 67890" or "Talkgroup: 12345, Source: 67890"
|
||||
# "TGT: 12345 | SRC: 67890" (pipe-delimited variant)
|
||||
tg_match = re.search(
|
||||
r'(?:TGT?|Talkgroup)[:\s]+(\d+)[,|│\s]+(?:Src|Source|SRC)[:\s]+(\d+)', line, re.IGNORECASE
|
||||
)
|
||||
if tg_match:
|
||||
result = {
|
||||
'type': 'call',
|
||||
'talkgroup': int(tg_match.group(1)),
|
||||
'source_id': int(tg_match.group(2)),
|
||||
'timestamp': ts,
|
||||
}
|
||||
# Extract slot if present on the same line
|
||||
slot_inline = re.search(r'Slot\s*(\d+)', line)
|
||||
if slot_inline:
|
||||
result['slot'] = int(slot_inline.group(1))
|
||||
return result
|
||||
|
||||
# P25 NAC (Network Access Code) — check before voice/slot
|
||||
nac_match = re.search(r'NAC[:\s]+([0-9A-Fa-f]+)', line)
|
||||
if nac_match:
|
||||
return {
|
||||
'type': 'nac',
|
||||
'nac': nac_match.group(1),
|
||||
'timestamp': ts,
|
||||
}
|
||||
|
||||
# Voice frame detection — check BEFORE bare slot match
|
||||
# Classic dsd: "Voice" keyword in frame lines
|
||||
# dsd-fme: "voice" or "Voice LC" or "VOICE" in output
|
||||
if re.search(r'\bvoice\b', line, re.IGNORECASE):
|
||||
result = {
|
||||
'type': 'voice',
|
||||
'detail': line,
|
||||
'timestamp': ts,
|
||||
}
|
||||
slot_inline = re.search(r'Slot\s*(\d+)', line)
|
||||
if slot_inline:
|
||||
result['slot'] = int(slot_inline.group(1))
|
||||
return result
|
||||
|
||||
# Bare slot info (only when line is *just* slot info, not voice/call)
|
||||
slot_match = re.match(r'\s*Slot\s*(\d+)\s*$', line)
|
||||
if slot_match:
|
||||
return {
|
||||
'type': 'slot',
|
||||
'slot': int(slot_match.group(1)),
|
||||
'timestamp': ts,
|
||||
}
|
||||
|
||||
# dsd-fme status lines we can surface: "TDMA", "CACH", "PI", "BS", etc.
|
||||
# Also catches "Closing", "Input", and other lifecycle lines.
|
||||
# Forward as raw so the frontend can show decoder is alive.
|
||||
return {
|
||||
'type': 'raw',
|
||||
'text': line[:200],
|
||||
'timestamp': ts,
|
||||
}
|
||||
|
||||
|
||||
_HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle
|
||||
|
||||
# 100ms of silence at 8kHz 16-bit mono = 1600 bytes
|
||||
_SILENCE_CHUNK = b'\x00' * 1600
|
||||
|
||||
|
||||
def _register_audio_sink(sink: object) -> None:
|
||||
"""Register an ffmpeg stdin sink for mux fanout."""
|
||||
with _ffmpeg_sinks_lock:
|
||||
_ffmpeg_sinks.add(sink)
|
||||
|
||||
|
||||
def _unregister_audio_sink(sink: object) -> None:
|
||||
"""Remove an ffmpeg stdin sink from mux fanout."""
|
||||
with _ffmpeg_sinks_lock:
|
||||
_ffmpeg_sinks.discard(sink)
|
||||
|
||||
|
||||
def _get_audio_sinks() -> tuple[object, ...]:
|
||||
"""Snapshot current audio sinks for lock-free iteration."""
|
||||
with _ffmpeg_sinks_lock:
|
||||
return tuple(_ffmpeg_sinks)
|
||||
|
||||
|
||||
def _stop_process(proc: Optional[subprocess.Popen]) -> None:
|
||||
"""Terminate and unregister a subprocess if present."""
|
||||
if not proc:
|
||||
return
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(proc)
|
||||
|
||||
|
||||
def _reset_runtime_state(*, release_device: bool) -> None:
|
||||
"""Reset process + runtime state and optionally release SDR ownership."""
|
||||
global dmr_rtl_process, dmr_dsd_process
|
||||
global dmr_running, dmr_has_audio, dmr_active_device
|
||||
|
||||
_stop_process(dmr_dsd_process)
|
||||
_stop_process(dmr_rtl_process)
|
||||
dmr_rtl_process = None
|
||||
dmr_dsd_process = None
|
||||
dmr_running = False
|
||||
dmr_has_audio = False
|
||||
with _ffmpeg_sinks_lock:
|
||||
_ffmpeg_sinks.clear()
|
||||
|
||||
if release_device and dmr_active_device is not None:
|
||||
app_module.release_sdr_device(dmr_active_device)
|
||||
dmr_active_device = None
|
||||
|
||||
|
||||
def _dsd_audio_mux(dsd_stdout):
|
||||
"""Mux thread: sole reader of dsd-fme stdout.
|
||||
|
||||
Always drains dsd-fme's audio output to prevent the process from
|
||||
blocking on stdout writes (which would also freeze stderr / text
|
||||
data). When streaming clients are connected, forwards data to all
|
||||
active ffmpeg stdin sinks with silence fill during voice gaps.
|
||||
"""
|
||||
try:
|
||||
while dmr_running:
|
||||
ready, _, _ = select.select([dsd_stdout], [], [], 0.1)
|
||||
if ready:
|
||||
data = os.read(dsd_stdout.fileno(), 4096)
|
||||
if not data:
|
||||
break
|
||||
sinks = _get_audio_sinks()
|
||||
for sink in sinks:
|
||||
try:
|
||||
sink.write(data)
|
||||
sink.flush()
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
_unregister_audio_sink(sink)
|
||||
else:
|
||||
# No audio from decoder — feed silence if client connected
|
||||
sinks = _get_audio_sinks()
|
||||
for sink in sinks:
|
||||
try:
|
||||
sink.write(_SILENCE_CHUNK)
|
||||
sink.flush()
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
_unregister_audio_sink(sink)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _queue_put(event: dict):
|
||||
"""Put an event on the DMR queue, dropping oldest if full."""
|
||||
try:
|
||||
dmr_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
try:
|
||||
dmr_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
dmr_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen):
|
||||
"""Read DSD stderr output and push parsed events to the queue.
|
||||
|
||||
Uses select() with a timeout so we can send periodic heartbeat
|
||||
events while readline() would otherwise block indefinitely during
|
||||
silence (no signal being decoded).
|
||||
"""
|
||||
global dmr_running
|
||||
|
||||
try:
|
||||
_queue_put({'type': 'status', 'text': 'started'})
|
||||
last_heartbeat = time.time()
|
||||
|
||||
while dmr_running:
|
||||
if dsd_process.poll() is not None:
|
||||
break
|
||||
|
||||
# Wait up to 1s for data on stderr instead of blocking forever
|
||||
ready, _, _ = select.select([dsd_process.stderr], [], [], 1.0)
|
||||
|
||||
if ready:
|
||||
line = dsd_process.stderr.readline()
|
||||
if not line:
|
||||
if dsd_process.poll() is not None:
|
||||
break
|
||||
continue
|
||||
|
||||
text = line.decode('utf-8', errors='replace').strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
logger.debug("DSD raw: %s", text)
|
||||
parsed = parse_dsd_output(text)
|
||||
if parsed:
|
||||
_queue_put(parsed)
|
||||
last_heartbeat = time.time()
|
||||
else:
|
||||
# No stderr output — send heartbeat so frontend knows
|
||||
# decoder is still alive and listening
|
||||
now = time.time()
|
||||
if now - last_heartbeat >= _HEARTBEAT_INTERVAL:
|
||||
_queue_put({
|
||||
'type': 'heartbeat',
|
||||
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
||||
})
|
||||
last_heartbeat = now
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DSD stream error: {e}")
|
||||
finally:
|
||||
global dmr_active_device, dmr_rtl_process, dmr_dsd_process
|
||||
global dmr_has_audio
|
||||
dmr_running = False
|
||||
dmr_has_audio = False
|
||||
with _ffmpeg_sinks_lock:
|
||||
_ffmpeg_sinks.clear()
|
||||
# Capture exit info for diagnostics
|
||||
rc = dsd_process.poll()
|
||||
reason = 'stopped'
|
||||
detail = ''
|
||||
if rc is not None and rc != 0:
|
||||
reason = 'crashed'
|
||||
try:
|
||||
remaining = dsd_process.stderr.read(1024)
|
||||
if remaining:
|
||||
detail = remaining.decode('utf-8', errors='replace').strip()[:200]
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(f"DSD process exited with code {rc}: {detail}")
|
||||
# Cleanup decoder + demod processes
|
||||
_stop_process(dsd_process)
|
||||
_stop_process(rtl_process)
|
||||
dmr_rtl_process = None
|
||||
dmr_dsd_process = None
|
||||
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
|
||||
# Release SDR device
|
||||
if dmr_active_device is not None:
|
||||
app_module.release_sdr_device(dmr_active_device)
|
||||
dmr_active_device = None
|
||||
logger.info("DSD stream thread stopped")
|
||||
|
||||
|
||||
# ============================================
|
||||
# API ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@dmr_bp.route('/tools')
|
||||
def check_tools() -> Response:
|
||||
"""Check for required tools."""
|
||||
dsd_path, _ = find_dsd()
|
||||
rtl_fm = find_rtl_fm()
|
||||
rx_fm = find_rx_fm()
|
||||
ffmpeg = find_ffmpeg()
|
||||
return jsonify({
|
||||
'dsd': dsd_path is not None,
|
||||
'rtl_fm': rtl_fm is not None,
|
||||
'rx_fm': rx_fm is not None,
|
||||
'ffmpeg': ffmpeg is not None,
|
||||
'available': dsd_path is not None and (rtl_fm is not None or rx_fm is not None),
|
||||
'protocols': VALID_PROTOCOLS,
|
||||
})
|
||||
|
||||
|
||||
@dmr_bp.route('/start', methods=['POST'])
|
||||
def start_dmr() -> Response:
|
||||
"""Start digital voice decoding."""
|
||||
global dmr_rtl_process, dmr_dsd_process, dmr_thread
|
||||
global dmr_running, dmr_has_audio, dmr_active_device
|
||||
|
||||
dsd_path, is_fme = find_dsd()
|
||||
if not dsd_path:
|
||||
return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
try:
|
||||
frequency = validate_frequency(data.get('frequency', 462.5625))
|
||||
gain = int(validate_gain(data.get('gain', 40)))
|
||||
device = validate_device_index(data.get('device', 0))
|
||||
protocol = str(data.get('protocol', 'auto')).lower()
|
||||
ppm = validate_ppm(data.get('ppm', 0))
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if protocol not in VALID_PROTOCOLS:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
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 tools for {sdr_type.value}.'
|
||||
}), 503
|
||||
|
||||
# Clear stale queue
|
||||
try:
|
||||
while True:
|
||||
dmr_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
# Reserve running state before we start claiming resources/processes
|
||||
# so concurrent /start requests cannot race each other.
|
||||
with dmr_lock:
|
||||
if dmr_running:
|
||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||
dmr_running = True
|
||||
dmr_has_audio = False
|
||||
|
||||
# Claim SDR device — use protocol name so the device panel shows
|
||||
# "D-STAR", "P25", etc. instead of always "DMR"
|
||||
mode_label = protocol.upper() if protocol != 'auto' else 'DMR'
|
||||
error = app_module.claim_sdr_device(device, mode_label)
|
||||
if error:
|
||||
with dmr_lock:
|
||||
dmr_running = False
|
||||
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
||||
|
||||
dmr_active_device = device
|
||||
|
||||
# Build FM demodulation command via SDR abstraction.
|
||||
try:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=frequency,
|
||||
sample_rate=48000,
|
||||
gain=float(gain) if gain > 0 else None,
|
||||
ppm=int(ppm) if ppm != 0 else None,
|
||||
modulation='fm',
|
||||
squelch=None,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
# Keep squelch fully open for digital bitstreams.
|
||||
rtl_cmd.extend(['-l', '0'])
|
||||
except Exception as e:
|
||||
_reset_runtime_state(release_device=True)
|
||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||
|
||||
# Build DSD command
|
||||
# Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for
|
||||
# ffmpeg transcoding. Both dsd-fme and classic dsd support '-o -'.
|
||||
# If ffmpeg is unavailable, fall back to discarding audio.
|
||||
ffmpeg_path = find_ffmpeg()
|
||||
if ffmpeg_path:
|
||||
audio_out = '-'
|
||||
else:
|
||||
audio_out = 'null' if is_fme else '-'
|
||||
logger.warning("ffmpeg not found — audio streaming disabled, data-only mode")
|
||||
dsd_cmd = [dsd_path, '-i', '-', '-o', audio_out]
|
||||
if is_fme:
|
||||
dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, []))
|
||||
dsd_cmd.extend(_DSD_FME_MODULATION.get(protocol, []))
|
||||
# Event log to stderr so we capture TG/Source/Voice data that
|
||||
# dsd-fme may not output on stderr by default.
|
||||
dsd_cmd.extend(['-J', '/dev/stderr'])
|
||||
# Relax CRC checks for marginal signals — lets more frames
|
||||
# through at the cost of occasional decode errors.
|
||||
if data.get('relaxCrc', False):
|
||||
dsd_cmd.append('-F')
|
||||
else:
|
||||
dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, []))
|
||||
|
||||
try:
|
||||
dmr_rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
register_process(dmr_rtl_process)
|
||||
|
||||
# DSD stdout → PIPE when ffmpeg available (audio pipeline),
|
||||
# otherwise DEVNULL (data-only mode)
|
||||
dsd_stdout = subprocess.PIPE if ffmpeg_path else subprocess.DEVNULL
|
||||
dmr_dsd_process = subprocess.Popen(
|
||||
dsd_cmd,
|
||||
stdin=dmr_rtl_process.stdout,
|
||||
stdout=dsd_stdout,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
register_process(dmr_dsd_process)
|
||||
|
||||
# Allow rtl_fm to send directly to dsd
|
||||
dmr_rtl_process.stdout.close()
|
||||
|
||||
# Start mux thread: always drains dsd-fme stdout to prevent the
|
||||
# process from blocking (which would freeze stderr / text data).
|
||||
# ffmpeg is started lazily per-client in /dmr/audio/stream.
|
||||
if ffmpeg_path and dmr_dsd_process.stdout:
|
||||
dmr_has_audio = True
|
||||
threading.Thread(
|
||||
target=_dsd_audio_mux,
|
||||
args=(dmr_dsd_process.stdout,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
rtl_rc = dmr_rtl_process.poll()
|
||||
dsd_rc = dmr_dsd_process.poll()
|
||||
if rtl_rc is not None or dsd_rc is not None:
|
||||
# Process died — capture stderr for diagnostics
|
||||
rtl_err = ''
|
||||
if dmr_rtl_process.stderr:
|
||||
rtl_err = dmr_rtl_process.stderr.read().decode('utf-8', errors='replace')[:500]
|
||||
dsd_err = ''
|
||||
if dmr_dsd_process.stderr:
|
||||
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500]
|
||||
logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}")
|
||||
# Terminate surviving processes and release resources.
|
||||
_reset_runtime_state(release_device=True)
|
||||
# Surface a clear error to the user
|
||||
detail = rtl_err.strip() or dsd_err.strip()
|
||||
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
|
||||
msg = f'SDR device {device} is busy — it may be in use by another mode or process. Try a different device.'
|
||||
elif detail:
|
||||
msg = f'Failed to start DSD pipeline: {detail}'
|
||||
else:
|
||||
msg = 'Failed to start DSD pipeline'
|
||||
return jsonify({'status': 'error', 'message': msg}), 500
|
||||
|
||||
# Drain rtl_fm stderr in background to prevent pipe blocking
|
||||
def _drain_rtl_stderr(proc):
|
||||
try:
|
||||
for line in proc.stderr:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start()
|
||||
|
||||
dmr_thread = threading.Thread(
|
||||
target=stream_dsd_output,
|
||||
args=(dmr_rtl_process, dmr_dsd_process),
|
||||
daemon=True,
|
||||
)
|
||||
dmr_thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'protocol': protocol,
|
||||
'sdr_type': sdr_type.value,
|
||||
'has_audio': dmr_has_audio,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start DMR: {e}")
|
||||
_reset_runtime_state(release_device=True)
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@dmr_bp.route('/stop', methods=['POST'])
|
||||
def stop_dmr() -> Response:
|
||||
"""Stop digital voice decoding."""
|
||||
with dmr_lock:
|
||||
_reset_runtime_state(release_device=True)
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@dmr_bp.route('/status')
|
||||
def dmr_status() -> Response:
|
||||
"""Get DMR decoder status."""
|
||||
return jsonify({
|
||||
'running': dmr_running,
|
||||
'device': dmr_active_device,
|
||||
'has_audio': dmr_has_audio,
|
||||
})
|
||||
|
||||
|
||||
@dmr_bp.route('/audio/stream')
|
||||
def stream_dmr_audio() -> Response:
|
||||
"""Stream decoded digital voice audio as WAV.
|
||||
|
||||
Starts a per-client ffmpeg encoder. The global mux thread
|
||||
(_dsd_audio_mux) forwards DSD audio to this ffmpeg's stdin while
|
||||
the client is connected, and discards audio otherwise. This avoids
|
||||
the pipe-buffer deadlock that occurs when ffmpeg is started at
|
||||
decoder launch (its stdout fills up before any HTTP client reads
|
||||
it, back-pressuring the entire pipeline and freezing stderr/text
|
||||
data output).
|
||||
"""
|
||||
if not dmr_running or not dmr_has_audio:
|
||||
return Response(b'', mimetype='audio/wav', status=204)
|
||||
|
||||
ffmpeg_path = find_ffmpeg()
|
||||
if not ffmpeg_path:
|
||||
return Response(b'', mimetype='audio/wav', status=503)
|
||||
|
||||
encoder_cmd = [
|
||||
ffmpeg_path, '-hide_banner', '-loglevel', 'error',
|
||||
'-fflags', 'nobuffer', '-flags', 'low_delay',
|
||||
'-probesize', '32', '-analyzeduration', '0',
|
||||
'-f', 's16le', '-ar', '8000', '-ac', '1', '-i', 'pipe:0',
|
||||
'-acodec', 'pcm_s16le', '-ar', '44100', '-f', 'wav', 'pipe:1',
|
||||
]
|
||||
audio_proc = subprocess.Popen(
|
||||
encoder_cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
# Drain ffmpeg stderr to prevent blocking
|
||||
threading.Thread(
|
||||
target=lambda p: [None for _ in p.stderr],
|
||||
args=(audio_proc,), daemon=True,
|
||||
).start()
|
||||
|
||||
if audio_proc.stdin:
|
||||
_register_audio_sink(audio_proc.stdin)
|
||||
|
||||
def generate():
|
||||
try:
|
||||
while dmr_running and audio_proc.poll() is None:
|
||||
ready, _, _ = select.select([audio_proc.stdout], [], [], 2.0)
|
||||
if ready:
|
||||
chunk = audio_proc.stdout.read(4096)
|
||||
if chunk:
|
||||
yield chunk
|
||||
else:
|
||||
break
|
||||
else:
|
||||
if audio_proc.poll() is not None:
|
||||
break
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"DMR audio stream error: {e}")
|
||||
finally:
|
||||
# Disconnect mux → ffmpeg, then clean up
|
||||
if audio_proc.stdin:
|
||||
_unregister_audio_sink(audio_proc.stdin)
|
||||
try:
|
||||
audio_proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
audio_proc.terminate()
|
||||
audio_proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
audio_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
mimetype='audio/wav',
|
||||
headers={
|
||||
'Content-Type': 'audio/wav',
|
||||
'Cache-Control': 'no-cache, no-store',
|
||||
'X-Accel-Buffering': 'no',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@dmr_bp.route('/stream')
|
||||
def stream_dmr() -> Response:
|
||||
"""SSE stream for DMR decoder events."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
while True:
|
||||
try:
|
||||
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('dmr', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
@@ -0,0 +1,631 @@
|
||||
"""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 json
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
import queue
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.constants import (
|
||||
DSC_VHF_FREQUENCY_MHZ,
|
||||
DSC_SAMPLE_RATE,
|
||||
DSC_TERMINATE_TIMEOUT,
|
||||
)
|
||||
from utils.database import (
|
||||
store_dsc_alert,
|
||||
get_dsc_alerts,
|
||||
get_dsc_alert,
|
||||
acknowledge_dsc_alert,
|
||||
get_dsc_alert_summary,
|
||||
)
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.validation import validate_device_index, validate_gain
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.dependencies import get_tool_path
|
||||
from utils.process import register_process, unregister_process
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 scipy
|
||||
import numpy
|
||||
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
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
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:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
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_device = 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
|
||||
|
||||
# Check if device is available using centralized registry
|
||||
global dsc_active_device
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'dsc')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
dsc_active_device = device_int
|
||||
|
||||
# Clear queue
|
||||
while not app_module.dsc_queue.empty():
|
||||
try:
|
||||
app_module.dsc_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Build rtl_fm command
|
||||
rtl_fm_path = tools['rtl_fm']['path']
|
||||
decoder_path = tools['dsc_decoder']['path']
|
||||
|
||||
# rtl_fm command for DSC decoding
|
||||
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M',
|
||||
'-s', str(DSC_SAMPLE_RATE),
|
||||
'-d', str(device),
|
||||
'-g', str(gain),
|
||||
'-M', 'fm', # FM demodulation
|
||||
'-l', '0', # No squelch for DSC
|
||||
'-E', 'dc' # DC blocking filter
|
||||
]
|
||||
|
||||
# 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:
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release device on failure
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
dsc_active_device = 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:
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release device on failure
|
||||
if dsc_active_device is not None:
|
||||
app_module.release_sdr_device(dsc_active_device)
|
||||
dsc_active_device = 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
|
||||
|
||||
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:
|
||||
try:
|
||||
app_module.dsc_rtl_process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
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:
|
||||
try:
|
||||
app_module.dsc_process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
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_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@dsc_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
"""SSE stream for real-time DSC messages."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.dsc_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('dsc', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
|
||||
|
||||
@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)
|
||||
@@ -1,27 +1,23 @@
|
||||
"""GPS dongle routes for USB GPS device support."""
|
||||
"""GPS routes for gpsd daemon support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Generator
|
||||
from collections.abc 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 (
|
||||
detect_gps_devices,
|
||||
is_serial_available,
|
||||
GPSPosition,
|
||||
GPSSkyData,
|
||||
get_current_position,
|
||||
get_gps_reader,
|
||||
start_gps,
|
||||
start_gpsd,
|
||||
stop_gps,
|
||||
get_current_position,
|
||||
GPSPosition,
|
||||
GPSDClient,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
|
||||
logger = get_logger('intercept.gps')
|
||||
|
||||
@@ -34,103 +30,66 @@ _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
|
||||
|
||||
|
||||
@gps_bp.route('/available')
|
||||
def check_gps_available():
|
||||
"""Check if GPS dongle support is available."""
|
||||
return jsonify({
|
||||
'available': is_serial_available(),
|
||||
'message': None if is_serial_available() else 'pyserial not installed - run: pip install pyserial'
|
||||
})
|
||||
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
|
||||
|
||||
|
||||
@gps_bp.route('/gpsd/check')
|
||||
def check_gpsd_available():
|
||||
"""Check if gpsd is reachable."""
|
||||
@gps_bp.route('/auto-connect', methods=['POST'])
|
||||
def auto_connect_gps():
|
||||
"""
|
||||
Automatically connect to gpsd if available.
|
||||
|
||||
Called on page load to seamlessly enable GPS if gpsd is running.
|
||||
Returns current status if already connected.
|
||||
"""
|
||||
import socket
|
||||
|
||||
host = request.args.get('host', 'localhost')
|
||||
port = int(request.args.get('port', 2947))
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
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,
|
||||
'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(2.0)
|
||||
sock.settimeout(1.0)
|
||||
sock.connect((host, port))
|
||||
sock.close()
|
||||
except Exception:
|
||||
return jsonify({
|
||||
'available': True,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'message': f'gpsd reachable at {host}:{port}'
|
||||
'status': 'unavailable',
|
||||
'message': 'gpsd not running'
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'available': False,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'message': f'Cannot connect to gpsd at {host}:{port}: {e}'
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/devices')
|
||||
def list_gps_devices():
|
||||
"""List available GPS serial devices."""
|
||||
if not is_serial_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'pyserial not installed'
|
||||
}), 503
|
||||
|
||||
devices = detect_gps_devices()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'devices': devices
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/start', methods=['POST'])
|
||||
def start_gps_reader():
|
||||
"""Start GPS reader on specified device."""
|
||||
if not is_serial_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'pyserial not installed'
|
||||
}), 503
|
||||
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GPS reader already running'
|
||||
}), 409
|
||||
|
||||
data = request.json or {}
|
||||
device_path = data.get('device')
|
||||
baudrate = data.get('baudrate', 9600)
|
||||
|
||||
if not device_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Device path required'
|
||||
}), 400
|
||||
|
||||
# Validate baudrate
|
||||
valid_baudrates = [4800, 9600, 19200, 38400, 57600, 115200]
|
||||
if baudrate not in valid_baudrates:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid baudrate. Valid options: {valid_baudrates}'
|
||||
}), 400
|
||||
|
||||
# Clear the queue
|
||||
while not _gps_queue.empty():
|
||||
@@ -139,83 +98,33 @@ def start_gps_reader():
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Start the GPS reader with callback pre-registered (avoids race condition)
|
||||
success = start_gps(device_path, baudrate, callback=_position_callback)
|
||||
# Start the gpsd client
|
||||
success = start_gpsd(host, port,
|
||||
callback=_position_callback,
|
||||
sky_callback=_sky_callback)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'device': device_path,
|
||||
'baudrate': baudrate,
|
||||
'source': 'serial'
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
'has_fix': False,
|
||||
'position': None,
|
||||
'sky': None,
|
||||
})
|
||||
else:
|
||||
reader = get_gps_reader()
|
||||
error = reader.error if reader else 'Unknown error'
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to start GPS reader: {error}'
|
||||
}), 500
|
||||
|
||||
|
||||
@gps_bp.route('/gpsd/start', methods=['POST'])
|
||||
def start_gpsd_client():
|
||||
"""Start GPS client connected to gpsd."""
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GPS reader already running'
|
||||
}), 409
|
||||
|
||||
data = request.json or {}
|
||||
host = data.get('host', 'localhost')
|
||||
port = data.get('port', 2947)
|
||||
|
||||
# Validate port
|
||||
try:
|
||||
port = int(port)
|
||||
if not (1 <= port <= 65535):
|
||||
raise ValueError("Port out of range")
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid port number'
|
||||
}), 400
|
||||
|
||||
# Clear the queue
|
||||
while not _gps_queue.empty():
|
||||
try:
|
||||
_gps_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Start the gpsd client with callback pre-registered
|
||||
success = start_gpsd(host, port, callback=_position_callback)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'host': host,
|
||||
'port': port,
|
||||
'source': 'gpsd'
|
||||
'status': 'unavailable',
|
||||
'message': 'Failed to connect to gpsd'
|
||||
})
|
||||
else:
|
||||
reader = get_gps_reader()
|
||||
error = reader.error if reader else 'Unknown error'
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to connect to gpsd: {error}'
|
||||
}), 500
|
||||
|
||||
|
||||
@gps_bp.route('/stop', methods=['POST'])
|
||||
def stop_gps_reader():
|
||||
"""Stop GPS reader."""
|
||||
"""Stop GPS client."""
|
||||
reader = get_gps_reader()
|
||||
if reader:
|
||||
reader.remove_callback(_position_callback)
|
||||
reader.remove_sky_callback(_sky_callback)
|
||||
|
||||
stop_gps()
|
||||
|
||||
@@ -224,7 +133,7 @@ def stop_gps_reader():
|
||||
|
||||
@gps_bp.route('/status')
|
||||
def get_gps_status():
|
||||
"""Get current GPS reader status."""
|
||||
"""Get current GPS client status."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader:
|
||||
@@ -232,15 +141,18 @@ def get_gps_status():
|
||||
'running': False,
|
||||
'device': None,
|
||||
'position': None,
|
||||
'sky': None,
|
||||
'error': None,
|
||||
'message': 'GPS reader not started'
|
||||
'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
|
||||
@@ -262,7 +174,7 @@ def get_position():
|
||||
if not reader or not reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GPS reader not running'
|
||||
'message': 'GPS client not running'
|
||||
}), 400
|
||||
else:
|
||||
return jsonify({
|
||||
@@ -271,44 +183,42 @@ def get_position():
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/debug')
|
||||
def debug_gps():
|
||||
"""Debug endpoint showing GPS reader 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 reader initialized'
|
||||
})
|
||||
'status': 'error',
|
||||
'message': 'GPS client not running'
|
||||
}), 400
|
||||
|
||||
position = reader.position
|
||||
source = 'gpsd' if isinstance(reader, GPSDClient) else 'serial'
|
||||
return jsonify({
|
||||
'running': reader.is_running,
|
||||
'source': source,
|
||||
'device': reader.device_path,
|
||||
'baudrate': reader.baudrate,
|
||||
'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."""
|
||||
"""SSE stream of GPS position and sky updates."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
position = _gps_queue.get(timeout=1)
|
||||
data = _gps_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse({'type': 'position', **position})
|
||||
yield format_sse(data)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Offline mode routes - Asset management and settings for offline operation.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from utils.database import get_setting, set_setting
|
||||
import os
|
||||
|
||||
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
||||
|
||||
# Default offline settings
|
||||
OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
'offline.assets_source': 'cdn',
|
||||
'offline.fonts_source': 'cdn',
|
||||
'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 jsonify({
|
||||
'status': 'success',
|
||||
'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 jsonify({'status': 'error', 'message': 'Missing key or value'}), 400
|
||||
|
||||
key = data['key']
|
||||
value = data['value']
|
||||
|
||||
# Validate key is an allowed setting
|
||||
if key not in OFFLINE_DEFAULTS:
|
||||
return jsonify({'status': 'error', 'message': 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 jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid value type for {key}'
|
||||
}), 400
|
||||
|
||||
set_setting(key, value)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'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 jsonify({
|
||||
'status': 'success',
|
||||
'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 jsonify({'status': 'error', 'message': 'Missing path parameter'}), 400
|
||||
|
||||
# Security: only allow checking within static/vendor
|
||||
if not path.startswith('/static/vendor/'):
|
||||
return jsonify({'status': 'error', 'message': '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 jsonify({
|
||||
'status': 'success',
|
||||
'path': path,
|
||||
'exists': exists
|
||||
})
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import pty
|
||||
import queue
|
||||
import select
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -23,11 +25,16 @@ from utils.validation import (
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.process import safe_terminate, register_process
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||
from utils.dependencies import get_tool_path
|
||||
|
||||
pager_bp = Blueprint('pager', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
pager_active_device: int | None = None
|
||||
|
||||
|
||||
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
"""Parse multimon-ng output line."""
|
||||
@@ -101,6 +108,62 @@ def log_message(msg: dict[str, Any]) -> None:
|
||||
logger.error(f"Failed to log message: {e}")
|
||||
|
||||
|
||||
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 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,
|
||||
})
|
||||
except (struct.error, ValueError, queue.Full):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"Audio relay error: {e}")
|
||||
finally:
|
||||
try:
|
||||
multimon_stdin.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream decoder output to queue using PTY for unbuffered output."""
|
||||
try:
|
||||
@@ -142,18 +205,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:
|
||||
global pager_active_device
|
||||
try:
|
||||
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:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
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_device = None
|
||||
|
||||
|
||||
@pager_bp.route('/start', methods=['POST'])
|
||||
def start_decoding() -> Response:
|
||||
global pager_active_device
|
||||
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||
@@ -177,10 +265,29 @@ def start_decoding() -> Response:
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': '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)
|
||||
|
||||
# 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')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
pager_active_device = device_int
|
||||
|
||||
# Validate protocols
|
||||
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
||||
protocols = data.get('protocols', valid_protocols)
|
||||
if not isinstance(protocols, list):
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
pager_active_device = None
|
||||
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
||||
protocols = [p for p in protocols if p in valid_protocols]
|
||||
if not protocols:
|
||||
@@ -212,10 +319,6 @@ def start_decoding() -> Response:
|
||||
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:
|
||||
@@ -233,6 +336,7 @@ def start_decoding() -> Response:
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build FM demodulation command
|
||||
bias_t = data.get('bias_t', False)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
@@ -240,10 +344,14 @@ def start_decoding() -> Response:
|
||||
gain=float(gain) if gain and gain != '0' else None,
|
||||
ppm=int(ppm) if ppm and ppm != '0' else None,
|
||||
modulation='fm',
|
||||
squelch=squelch if squelch and squelch != 0 else None
|
||||
squelch=squelch if squelch and squelch != 0 else None,
|
||||
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 jsonify({'status': 'error', 'message': '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}")
|
||||
@@ -255,6 +363,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():
|
||||
@@ -273,18 +382,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))
|
||||
@@ -296,15 +417,47 @@ def start_decoding() -> Response:
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
# Kill orphaned rtl_fm process
|
||||
try:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release device on failure
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
pager_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||
except Exception as e:
|
||||
# Kill orphaned rtl_fm process if it was started
|
||||
try:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
# Release device on failure
|
||||
if pager_active_device is not None:
|
||||
app_module.release_sdr_device(pager_active_device)
|
||||
pager_active_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@pager_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoding() -> Response:
|
||||
global pager_active_device
|
||||
|
||||
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:
|
||||
@@ -331,6 +484,12 @@ 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_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
@@ -391,6 +550,10 @@ def stream() -> Response:
|
||||
try:
|
||||
msg = app_module.output_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('pager', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Session recording API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
|
||||
from utils.recording import get_recording_manager, RECORDING_ROOT
|
||||
|
||||
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 jsonify({'status': 'error', 'message': '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 jsonify({
|
||||
'status': 'success',
|
||||
'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 jsonify({'status': 'error', 'message': 'No active recording found'}), 404
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'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 jsonify({
|
||||
'status': 'success',
|
||||
'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 jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
||||
return jsonify({'status': 'success', '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 jsonify({'status': 'error', 'message': '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 jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||
except Exception:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||
|
||||
if not file_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
|
||||
|
||||
return send_file(
|
||||
file_path,
|
||||
mimetype='application/x-ndjson',
|
||||
as_attachment=True,
|
||||
download_name=file_path.name,
|
||||
)
|
||||
@@ -0,0 +1,314 @@
|
||||
"""RTLAMR utility meter 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
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
# Ensure rtlamr process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
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:
|
||||
try:
|
||||
rtl_tcp_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
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_device = None
|
||||
|
||||
|
||||
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
|
||||
def start_rtlamr() -> Response:
|
||||
global rtl_tcp_process, rtlamr_active_device
|
||||
|
||||
with app_module.rtlamr_lock:
|
||||
if app_module.rtlamr_process:
|
||||
return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# 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 jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'rtlamr')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
rtlamr_active_device = device_int
|
||||
|
||||
# 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
|
||||
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)
|
||||
|
||||
# Wait a moment for rtl_tcp to start
|
||||
time.sleep(3)
|
||||
|
||||
logger.info(f"rtl_tcp started: {' '.join(rtl_tcp_cmd)}")
|
||||
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".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_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
|
||||
|
||||
# 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_device = None
|
||||
return jsonify({'status': 'error', 'message': '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_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
|
||||
def stop_rtlamr() -> Response:
|
||||
global rtl_tcp_process, rtlamr_active_device
|
||||
|
||||
with app_module.rtlamr_lock:
|
||||
if app_module.rtlamr_process:
|
||||
app_module.rtlamr_process.terminate()
|
||||
try:
|
||||
app_module.rtlamr_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.rtlamr_process.kill()
|
||||
app_module.rtlamr_process = None
|
||||
|
||||
# Also stop rtl_tcp
|
||||
with rtl_tcp_lock:
|
||||
if rtl_tcp_process:
|
||||
rtl_tcp_process.terminate()
|
||||
try:
|
||||
rtl_tcp_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
rtl_tcp_process.kill()
|
||||
rtl_tcp_process = None
|
||||
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_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@rtlamr_bp.route('/stream_rtlamr')
|
||||
def stream_rtlamr() -> Response:
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.rtlamr_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('rtlamr', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
@@ -3,14 +3,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from flask import Blueprint, jsonify, request, render_template, Response
|
||||
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
|
||||
from data.satellites import TLE_SATELLITES
|
||||
from utils.database import (
|
||||
get_tracked_satellites,
|
||||
add_tracked_satellite,
|
||||
bulk_add_tracked_satellites,
|
||||
update_tracked_satellite,
|
||||
remove_tracked_satellite,
|
||||
)
|
||||
from utils.logging import satellite_logger as logger
|
||||
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
|
||||
|
||||
@@ -26,10 +38,138 @@ 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: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
|
||||
"""
|
||||
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')
|
||||
return render_template(
|
||||
'satellite_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
)
|
||||
|
||||
|
||||
@satellite_bp.route('/predict', methods=['POST'])
|
||||
@@ -57,15 +197,11 @@ def predict_passes():
|
||||
|
||||
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,10 +212,6 @@ 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'
|
||||
}
|
||||
@@ -216,10 +348,6 @@ def get_satellite_position():
|
||||
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
25338: 'NOAA-15',
|
||||
28654: 'NOAA-18',
|
||||
33591: 'NOAA-19',
|
||||
43013: 'NOAA-20',
|
||||
40069: 'METEOR-M2',
|
||||
57166: 'METEOR-M2-3'
|
||||
}
|
||||
@@ -239,6 +367,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 +449,72 @@ 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'
|
||||
}
|
||||
|
||||
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 jsonify({'status': 'error', 'message': 'TLE update failed'})
|
||||
|
||||
|
||||
@satellite_bp.route('/celestrak/<category>')
|
||||
@@ -397,4 +568,77 @@ def fetch_celestrak(category):
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
logger.error(f"Error fetching CelesTrak data: {e}")
|
||||
return jsonify({'status': 'error', 'message': '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.json
|
||||
if not data:
|
||||
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||
|
||||
# Accept a single satellite dict or a list
|
||||
sat_list = data if isinstance(data, list) else [data]
|
||||
|
||||
added = 0
|
||||
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)
|
||||
|
||||
if add_tracked_satellite(norad_id, name, tle1, tle2, enabled):
|
||||
added += 1
|
||||
|
||||
# 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)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'added': added,
|
||||
'satellites': get_tracked_satellites(),
|
||||
})
|
||||
|
||||
|
||||
@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 jsonify({'status': 'error', 'message': 'Missing enabled field'}), 400
|
||||
|
||||
ok = update_tracked_satellite(str(norad_id), bool(enabled))
|
||||
if ok:
|
||||
return jsonify({'status': 'success'})
|
||||
return jsonify({'status': 'error', 'message': '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 jsonify({'status': 'error', 'message': msg}), status_code
|
||||
|
||||
@@ -19,11 +19,15 @@ from utils.validation import (
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.process import safe_terminate, register_process
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
sensor_bp = Blueprint('sensor', __name__)
|
||||
|
||||
# Track which device is being used
|
||||
sensor_active_device: int | None = None
|
||||
|
||||
|
||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
@@ -41,6 +45,21 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# 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:
|
||||
app_module.sensor_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rssi': rssi if rssi is not None else 0,
|
||||
'snr': snr if snr is not None else 0,
|
||||
'noise': noise if noise is not None else 0,
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
@@ -56,14 +75,38 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
except Exception as e:
|
||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
process.wait()
|
||||
global sensor_active_device
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
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_device = 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
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
||||
@@ -79,6 +122,22 @@ def start_sensor() -> Response:
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': 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)
|
||||
|
||||
# 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')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
sensor_active_device = device_int
|
||||
|
||||
# Clear queue
|
||||
while not app_module.sensor_queue.empty():
|
||||
try:
|
||||
@@ -93,10 +152,6 @@ def start_sensor() -> Response:
|
||||
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:
|
||||
@@ -114,23 +169,29 @@ def start_sensor() -> Response:
|
||||
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
|
||||
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,
|
||||
bufsize=1
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
register_process(app_module.sensor_process)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||
@@ -154,13 +215,23 @@ def start_sensor() -> Response:
|
||||
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_device = None
|
||||
return jsonify({'status': 'error', 'message': '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_device = None
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||
def stop_sensor() -> Response:
|
||||
global sensor_active_device
|
||||
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
app_module.sensor_process.terminate()
|
||||
@@ -169,6 +240,12 @@ def stop_sensor() -> Response:
|
||||
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_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
@@ -184,6 +261,10 @@ def stream_sensor() -> Response:
|
||||
try:
|
||||
msg = app_module.sensor_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('sensor', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Settings management routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.database import (
|
||||
get_setting,
|
||||
set_setting,
|
||||
delete_setting,
|
||||
get_all_settings,
|
||||
get_correlations,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.settings')
|
||||
|
||||
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
|
||||
|
||||
|
||||
@settings_bp.route('', methods=['GET'])
|
||||
def get_settings() -> Response:
|
||||
"""Get all settings."""
|
||||
try:
|
||||
settings = get_all_settings()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'settings': settings
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting settings: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@settings_bp.route('', methods=['POST'])
|
||||
def save_settings() -> Response:
|
||||
"""Save one or more settings."""
|
||||
data = request.json or {}
|
||||
|
||||
if not data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No settings provided'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
saved = []
|
||||
for key, value in data.items():
|
||||
# Validate key (alphanumeric, underscores, dots, hyphens)
|
||||
if not key or not all(c.isalnum() or c in '_.-' for c in key):
|
||||
continue
|
||||
|
||||
set_setting(key, value)
|
||||
saved.append(key)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'saved': saved
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving settings: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['GET'])
|
||||
def get_single_setting(key: str) -> Response:
|
||||
"""Get a single setting by key."""
|
||||
try:
|
||||
value = get_setting(key)
|
||||
if value is None:
|
||||
return jsonify({
|
||||
'status': 'not_found',
|
||||
'key': key
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'value': value
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['PUT'])
|
||||
def update_single_setting(key: str) -> Response:
|
||||
"""Update a single setting."""
|
||||
data = request.json or {}
|
||||
value = data.get('value')
|
||||
|
||||
if value is None and 'value' not in data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Value is required'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
set_setting(key, value)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'value': value
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['DELETE'])
|
||||
def delete_single_setting(key: str) -> Response:
|
||||
"""Delete a setting."""
|
||||
try:
|
||||
deleted = delete_setting(key)
|
||||
if deleted:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'deleted': True
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'not_found',
|
||||
'key': key
|
||||
}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Device Correlation Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@settings_bp.route('/correlations', methods=['GET'])
|
||||
def get_device_correlations() -> Response:
|
||||
"""Get device correlations between WiFi and Bluetooth."""
|
||||
min_confidence = request.args.get('min_confidence', 0.5, type=float)
|
||||
|
||||
try:
|
||||
correlations = get_correlations(min_confidence)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'correlations': correlations
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting correlations: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 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': '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, 'r') 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 jsonify({
|
||||
'status': 'error',
|
||||
'message': '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 jsonify({
|
||||
'status': 'error',
|
||||
'message': '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
|
||||
})
|
||||
@@ -0,0 +1,625 @@
|
||||
"""Spy Stations routes - Number stations and diplomatic HF networks."""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
spy_stations_bp = Blueprint('spy_stations', __name__, url_prefix='/spy-stations')
|
||||
|
||||
# Active spy stations data from priyom.org
|
||||
STATIONS = [
|
||||
# Number Stations (Intelligence)
|
||||
{
|
||||
"id": "e06",
|
||||
"name": "E06",
|
||||
"nickname": "English Man",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4310, "primary": True},
|
||||
{"freq_khz": 4800, "primary": False},
|
||||
{"freq_khz": 5370, "primary": False},
|
||||
],
|
||||
"mode": "USB+carrier",
|
||||
"description": "Russian intelligence number station operated by 'Russian 6'. Male voice reads 5-figure groups. Broadcasts from Moscow, Orenburg, Smolensk, and Chita.",
|
||||
"operator": "Russian 6",
|
||||
"schedule": "Weekdays, 2 transmissions 1 hour apart",
|
||||
"source_url": "https://priyom.org/number-stations/english/e06"
|
||||
},
|
||||
{
|
||||
"id": "s06",
|
||||
"name": "S06",
|
||||
"nickname": "Russian Man",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4310, "primary": True},
|
||||
{"freq_khz": 4800, "primary": False},
|
||||
{"freq_khz": 5370, "primary": False},
|
||||
],
|
||||
"mode": "USB+carrier",
|
||||
"description": "Russian language mode of the Russian 6 operator. Male voice reads 5-figure groups in Russian.",
|
||||
"operator": "Russian 6",
|
||||
"schedule": "Same schedule as E06, alternating languages",
|
||||
"source_url": "https://priyom.org/number-stations/russian/s06"
|
||||
},
|
||||
{
|
||||
"id": "uvb76",
|
||||
"name": "UVB-76",
|
||||
"nickname": "The Buzzer",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4625, "primary": True},
|
||||
{"freq_khz": 5779, "primary": False},
|
||||
{"freq_khz": 6810, "primary": False},
|
||||
{"freq_khz": 7490, "primary": False},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Russian military command network. Continuous buzzing tone with occasional voice messages. Active since 1982. One of the most famous number stations.",
|
||||
"operator": "Russian Military",
|
||||
"schedule": "24/7 continuous operation",
|
||||
"source_url": "https://priyom.org/number-stations/russia/uvb-76"
|
||||
},
|
||||
{
|
||||
"id": "hm01",
|
||||
"name": "HM01",
|
||||
"nickname": "Cuban Numbers",
|
||||
"type": "number",
|
||||
"country": "Cuba",
|
||||
"country_code": "CU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 9065, "primary": True},
|
||||
{"freq_khz": 9155, "primary": False},
|
||||
{"freq_khz": 9240, "primary": False},
|
||||
{"freq_khz": 9330, "primary": False},
|
||||
{"freq_khz": 10345, "primary": False},
|
||||
{"freq_khz": 10715, "primary": False},
|
||||
{"freq_khz": 10860, "primary": False},
|
||||
{"freq_khz": 11435, "primary": False},
|
||||
{"freq_khz": 11462, "primary": False},
|
||||
{"freq_khz": 11530, "primary": False},
|
||||
{"freq_khz": 11635, "primary": False},
|
||||
{"freq_khz": 12180, "primary": False},
|
||||
{"freq_khz": 13435, "primary": False},
|
||||
{"freq_khz": 14375, "primary": False},
|
||||
{"freq_khz": 16180, "primary": False},
|
||||
{"freq_khz": 17480, "primary": False},
|
||||
],
|
||||
"mode": "AM/OFDM",
|
||||
"description": "Cuban DGI intelligence station. Spanish female voice 'Atencion' followed by number groups. Also uses RDFT OFDM digital mode.",
|
||||
"operator": "DGI (Cuban Intelligence)",
|
||||
"schedule": "Multiple daily transmissions",
|
||||
"source_url": "https://priyom.org/number-stations/cuba/hm01"
|
||||
},
|
||||
{
|
||||
"id": "e07",
|
||||
"name": "E07",
|
||||
"nickname": "7-dash",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5292, "primary": True},
|
||||
{"freq_khz": 6388, "primary": False},
|
||||
{"freq_khz": 7482, "primary": False},
|
||||
{"freq_khz": 8576, "primary": False},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Russian intelligence station using distinctive 7-dash interval signal. Female voice reading 5-figure groups in English. Part of the 'Russian 7' operator network.",
|
||||
"operator": "Russian 7",
|
||||
"schedule": "Irregular, typically evenings UTC",
|
||||
"source_url": "https://priyom.org/number-stations/english/e07"
|
||||
},
|
||||
{
|
||||
"id": "e11",
|
||||
"name": "E11",
|
||||
"nickname": "Mazielka",
|
||||
"type": "number",
|
||||
"country": "Poland",
|
||||
"country_code": "PL",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4030, "primary": True},
|
||||
{"freq_khz": 5240, "primary": False},
|
||||
{"freq_khz": 6910, "primary": False},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Polish intelligence number station. Female voice reads 5-figure groups in English. Named after distinctive melody interval signal.",
|
||||
"operator": "ABW (Polish Intelligence)",
|
||||
"schedule": "Weekly transmissions",
|
||||
"source_url": "https://priyom.org/number-stations/english/e11"
|
||||
},
|
||||
{
|
||||
"id": "e17z",
|
||||
"name": "E17z",
|
||||
"nickname": "Israeli Numbers",
|
||||
"type": "number",
|
||||
"country": "Israel",
|
||||
"country_code": "IL",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4779, "primary": True},
|
||||
{"freq_khz": 5091, "primary": False},
|
||||
{"freq_khz": 6446, "primary": False},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Israeli intelligence number station. Female voice with distinctive Hebrew-accented English. Transmits 5-figure groups with phonetic alphabet.",
|
||||
"operator": "Mossad (suspected)",
|
||||
"schedule": "Irregular schedule",
|
||||
"source_url": "https://priyom.org/number-stations/english/e17z"
|
||||
},
|
||||
{
|
||||
"id": "g06",
|
||||
"name": "G06",
|
||||
"nickname": "Russian German",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4310, "primary": True},
|
||||
{"freq_khz": 4800, "primary": False},
|
||||
{"freq_khz": 5370, "primary": False},
|
||||
],
|
||||
"mode": "USB+carrier",
|
||||
"description": "German language mode of Russian 6 operator. Male synthesized voice reads 5-figure groups in German. Shares frequencies with E06/S06.",
|
||||
"operator": "Russian 6",
|
||||
"schedule": "Same schedule as E06",
|
||||
"source_url": "https://priyom.org/number-stations/german/g06"
|
||||
},
|
||||
{
|
||||
"id": "v02a",
|
||||
"name": "V02a",
|
||||
"nickname": "Cuban Spy Numbers",
|
||||
"type": "number",
|
||||
"country": "Cuba",
|
||||
"country_code": "CU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5855, "primary": True},
|
||||
{"freq_khz": 9330, "primary": False},
|
||||
{"freq_khz": 11635, "primary": False},
|
||||
],
|
||||
"mode": "AM",
|
||||
"description": "Cuban intelligence station using AM mode. Female Spanish voice reading 4-figure groups. Related to HM01 but separate schedule.",
|
||||
"operator": "DGI (Cuban Intelligence)",
|
||||
"schedule": "Evening transmissions, weekdays",
|
||||
"source_url": "https://priyom.org/number-stations/spanish/v02a"
|
||||
},
|
||||
{
|
||||
"id": "v07",
|
||||
"name": "V07",
|
||||
"nickname": "Russian 7 Voice",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 3756, "primary": True},
|
||||
{"freq_khz": 4625, "primary": False},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Russian voice number station. Female voice reads 5-figure groups in Russian. Part of Russian 7 operator network. Often shares 4625 kHz with UVB-76.",
|
||||
"operator": "Russian 7",
|
||||
"schedule": "Irregular transmissions",
|
||||
"source_url": "https://priyom.org/number-stations/russian/v07"
|
||||
},
|
||||
{
|
||||
"id": "s11a",
|
||||
"name": "S11a",
|
||||
"nickname": "Russian Phonetic",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 4560, "primary": True},
|
||||
{"freq_khz": 5200, "primary": False},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Russian phonetic alphabet number station. Male voice reads 5-letter groups using Russian phonetic alphabet (Anna, Boris, etc.).",
|
||||
"operator": "GRU (suspected)",
|
||||
"schedule": "Weekly scheduled transmissions",
|
||||
"source_url": "https://priyom.org/number-stations/russian/s11a"
|
||||
},
|
||||
{
|
||||
"id": "v13",
|
||||
"name": "V13",
|
||||
"nickname": "The Pip",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 3756, "primary": True},
|
||||
{"freq_khz": 5448, "primary": False},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Russian military channel marker known as 'The Pip'. Continuous short beep every 1 second with occasional voice messages. Sister station to UVB-76.",
|
||||
"operator": "Russian Military",
|
||||
"schedule": "24/7 continuous operation",
|
||||
"source_url": "https://priyom.org/military-stations/russia/the-pip"
|
||||
},
|
||||
{
|
||||
"id": "v24",
|
||||
"name": "V24",
|
||||
"nickname": "Air Horn",
|
||||
"type": "number",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 3243, "primary": True},
|
||||
],
|
||||
"mode": "USB",
|
||||
"description": "Russian channel marker known as 'Air Horn' due to distinctive foghorn-like sound. Continuous tone with occasional voice messages in Russian.",
|
||||
"operator": "Russian Military",
|
||||
"schedule": "24/7 continuous operation",
|
||||
"source_url": "https://priyom.org/military-stations/russia/the-air-horn"
|
||||
},
|
||||
{
|
||||
"id": "vc01",
|
||||
"name": "VC01",
|
||||
"nickname": "Chinese Robot",
|
||||
"type": "number",
|
||||
"country": "China",
|
||||
"country_code": "CN",
|
||||
"frequencies": [
|
||||
{"freq_khz": 8300, "primary": True},
|
||||
{"freq_khz": 9725, "primary": False},
|
||||
{"freq_khz": 11430, "primary": False},
|
||||
{"freq_khz": 13750, "primary": False},
|
||||
],
|
||||
"mode": "AM",
|
||||
"description": "Chinese intelligence number station. Robotic female voice reading 4-figure groups in Chinese. Distinctive electronic music interval signal.",
|
||||
"operator": "MSS (Chinese Intelligence)",
|
||||
"schedule": "Daily transmissions",
|
||||
"source_url": "https://priyom.org/number-stations/chinese/vc01"
|
||||
},
|
||||
{
|
||||
"id": "v22",
|
||||
"name": "V22",
|
||||
"nickname": "Chinese Lady",
|
||||
"type": "number",
|
||||
"country": "China",
|
||||
"country_code": "CN",
|
||||
"frequencies": [
|
||||
{"freq_khz": 7883, "primary": True},
|
||||
{"freq_khz": 9170, "primary": False},
|
||||
],
|
||||
"mode": "AM",
|
||||
"description": "Chinese number station using female voice. Reads 4-figure groups in Mandarin Chinese. Often reported in Southeast Asian target areas.",
|
||||
"operator": "MSS (Chinese Intelligence)",
|
||||
"schedule": "Evening transmissions UTC",
|
||||
"source_url": "https://priyom.org/number-stations/chinese/v22"
|
||||
},
|
||||
# Diplomatic Stations
|
||||
{
|
||||
"id": "bulgaria_mfa",
|
||||
"name": "Bulgaria MFA",
|
||||
"nickname": "Sofia Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Bulgaria",
|
||||
"country_code": "BG",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5145, "primary": True},
|
||||
{"freq_khz": 6755, "primary": False},
|
||||
{"freq_khz": 7670, "primary": False},
|
||||
{"freq_khz": 9155, "primary": False},
|
||||
{"freq_khz": 10175, "primary": False},
|
||||
{"freq_khz": 11445, "primary": False},
|
||||
{"freq_khz": 14725, "primary": False},
|
||||
{"freq_khz": 18520, "primary": False},
|
||||
],
|
||||
"mode": "RFSM-8000/MIL-STD-188-110",
|
||||
"description": "Bulgarian Ministry of Foreign Affairs diplomatic network. Sofia to 14 embassies worldwide. Uses RFSM-8000 modem with MIL-STD-188-110.",
|
||||
"operator": "Bulgarian MFA",
|
||||
"schedule": "Daily scheduled transmissions",
|
||||
"source_url": "https://priyom.org/diplomatic/bulgaria"
|
||||
},
|
||||
{
|
||||
"id": "czechia_mfa",
|
||||
"name": "Czechia MFA",
|
||||
"nickname": "Czech Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Czechia",
|
||||
"country_code": "CZ",
|
||||
"frequencies": [
|
||||
{"freq_khz": 6830, "primary": True},
|
||||
{"freq_khz": 8130, "primary": False},
|
||||
{"freq_khz": 10232, "primary": False},
|
||||
{"freq_khz": 13890, "primary": False},
|
||||
],
|
||||
"mode": "PACTOR-III",
|
||||
"description": "Czech diplomatic network using PACTOR-III. Callsigns OLZ52-OLZ88. MoD station OL1A also active.",
|
||||
"operator": "Czech MFA / MoD",
|
||||
"schedule": "Regular scheduled traffic",
|
||||
"source_url": "https://priyom.org/diplomatic/czechia"
|
||||
},
|
||||
{
|
||||
"id": "egypt_mfa",
|
||||
"name": "Egypt MFA",
|
||||
"nickname": "Egyptian Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Egypt",
|
||||
"country_code": "EG",
|
||||
"frequencies": [
|
||||
{"freq_khz": 7830, "primary": True},
|
||||
{"freq_khz": 9048, "primary": False},
|
||||
{"freq_khz": 10780, "primary": False},
|
||||
{"freq_khz": 13950, "primary": False},
|
||||
],
|
||||
"mode": "SITOR/Codan 3012",
|
||||
"description": "Egyptian diplomatic network. 5-digit station IDs (66601=Washington, 11107=London). Uses SITOR and Codan 3012 modems.",
|
||||
"operator": "Egyptian MFA",
|
||||
"schedule": "Daily traffic windows",
|
||||
"source_url": "https://priyom.org/diplomatic/egypt"
|
||||
},
|
||||
{
|
||||
"id": "dprk_mfa",
|
||||
"name": "DPRK MFA",
|
||||
"nickname": "North Korea Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "North Korea",
|
||||
"country_code": "KP",
|
||||
"frequencies": [
|
||||
{"freq_khz": 7200, "primary": True},
|
||||
{"freq_khz": 9450, "primary": False},
|
||||
{"freq_khz": 11475, "primary": False},
|
||||
{"freq_khz": 13785, "primary": False},
|
||||
{"freq_khz": 15245, "primary": False},
|
||||
{"freq_khz": 17550, "primary": False},
|
||||
{"freq_khz": 21680, "primary": False},
|
||||
{"freq_khz": 25120, "primary": False},
|
||||
],
|
||||
"mode": "DPRK-ARQ (LSB/BFSK 600Bd/MSK 1200Bd)",
|
||||
"description": "North Korean diplomatic network spanning 7-25 MHz. Uses proprietary DPRK-ARQ protocol. Daily encrypted traffic to embassies.",
|
||||
"operator": "DPRK MFA",
|
||||
"schedule": "Daily, multiple time slots",
|
||||
"source_url": "https://priyom.org/diplomatic/north-korea"
|
||||
},
|
||||
{
|
||||
"id": "russia_mfa",
|
||||
"name": "Russia MFA",
|
||||
"nickname": "Russian Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Russia",
|
||||
"country_code": "RU",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5154, "primary": True},
|
||||
{"freq_khz": 7654, "primary": False},
|
||||
{"freq_khz": 9045, "primary": False},
|
||||
{"freq_khz": 10755, "primary": False},
|
||||
{"freq_khz": 13455, "primary": False},
|
||||
{"freq_khz": 16354, "primary": False},
|
||||
{"freq_khz": 18954, "primary": False},
|
||||
],
|
||||
"mode": "Perelivt/Serdolik/X06/OFDM",
|
||||
"description": "Extensive Russian diplomatic network using multiple proprietary modes including Perelivt, Serdolik, and OFDM variants.",
|
||||
"operator": "Russian MFA",
|
||||
"schedule": "24/7 network operations",
|
||||
"source_url": "https://priyom.org/diplomatic/russia"
|
||||
},
|
||||
{
|
||||
"id": "tunisia_mfa",
|
||||
"name": "Tunisia MFA",
|
||||
"nickname": "Tunisian Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Tunisia",
|
||||
"country_code": "TN",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5810, "primary": True},
|
||||
{"freq_khz": 7954, "primary": False},
|
||||
{"freq_khz": 8014, "primary": False},
|
||||
{"freq_khz": 8180, "primary": False},
|
||||
{"freq_khz": 10113, "primary": False},
|
||||
{"freq_khz": 10176, "primary": False},
|
||||
{"freq_khz": 11111, "primary": False},
|
||||
{"freq_khz": 12140, "primary": False},
|
||||
{"freq_khz": 13945, "primary": False},
|
||||
{"freq_khz": 14700, "primary": False},
|
||||
{"freq_khz": 14724, "primary": False},
|
||||
{"freq_khz": 15635, "primary": False},
|
||||
{"freq_khz": 16125, "primary": False},
|
||||
{"freq_khz": 16285, "primary": False},
|
||||
{"freq_khz": 16290, "primary": False},
|
||||
{"freq_khz": 18295, "primary": False},
|
||||
{"freq_khz": 19675, "primary": False},
|
||||
{"freq_khz": 23540, "primary": False},
|
||||
{"freq_khz": 24080, "primary": False},
|
||||
{"freq_khz": 24170, "primary": False},
|
||||
{"freq_khz": 26890, "primary": False},
|
||||
],
|
||||
"mode": "2G ALE/PACTOR-II",
|
||||
"description": "Tunisian MFA network. Callsigns STAT151-155. Uses 2G ALE for linking and PACTOR-II for traffic. MAPI email format.",
|
||||
"operator": "Tunisian MFA",
|
||||
"schedule": "Regular diplomatic traffic",
|
||||
"source_url": "https://priyom.org/diplomatic/tunisia"
|
||||
},
|
||||
{
|
||||
"id": "usa_state",
|
||||
"name": "US State Dept",
|
||||
"nickname": "American Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "United States",
|
||||
"country_code": "US",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5749, "primary": True},
|
||||
{"freq_khz": 6903, "primary": False},
|
||||
{"freq_khz": 8059, "primary": False},
|
||||
{"freq_khz": 10734, "primary": False},
|
||||
{"freq_khz": 11169, "primary": False},
|
||||
{"freq_khz": 13504, "primary": False},
|
||||
{"freq_khz": 16284, "primary": False},
|
||||
{"freq_khz": 18249, "primary": False},
|
||||
{"freq_khz": 20811, "primary": False},
|
||||
{"freq_khz": 24884, "primary": False},
|
||||
],
|
||||
"mode": "2G ALE (MIL-STD-188-141A)",
|
||||
"description": "US State Department diplomatic network. 140+ embassy callsigns (KWX57=Warsaw, KRH50=Tokyo, etc.). Uses 2G ALE linking.",
|
||||
"operator": "US State Department",
|
||||
"schedule": "24/7 global network",
|
||||
"source_url": "https://priyom.org/diplomatic/united-states"
|
||||
},
|
||||
{
|
||||
"id": "morocco_mfa",
|
||||
"name": "Morocco MFA",
|
||||
"nickname": "Moroccan Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Morocco",
|
||||
"country_code": "MA",
|
||||
"frequencies": [
|
||||
{"freq_khz": 8010, "primary": True},
|
||||
{"freq_khz": 11205, "primary": False},
|
||||
{"freq_khz": 14620, "primary": False},
|
||||
],
|
||||
"mode": "PACTOR-II/ALE",
|
||||
"description": "Moroccan Ministry of Foreign Affairs diplomatic network. Links Rabat with embassies in Europe and Africa. Uses PACTOR-II and 2G ALE.",
|
||||
"operator": "Moroccan MFA",
|
||||
"schedule": "Daily scheduled traffic",
|
||||
"source_url": "https://priyom.org/diplomatic/morocco"
|
||||
},
|
||||
{
|
||||
"id": "poland_mfa",
|
||||
"name": "Poland MFA",
|
||||
"nickname": "Polish Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Poland",
|
||||
"country_code": "PL",
|
||||
"frequencies": [
|
||||
{"freq_khz": 6825, "primary": True},
|
||||
{"freq_khz": 9250, "primary": False},
|
||||
{"freq_khz": 13485, "primary": False},
|
||||
],
|
||||
"mode": "STANAG-4285/ALE",
|
||||
"description": "Polish Ministry of Foreign Affairs HF network. Uses NATO STANAG-4285 modem with 2G ALE linking. Connects Warsaw with global embassies.",
|
||||
"operator": "Polish MFA",
|
||||
"schedule": "Regular diplomatic traffic",
|
||||
"source_url": "https://priyom.org/diplomatic/poland"
|
||||
},
|
||||
{
|
||||
"id": "france_mfa",
|
||||
"name": "France MFA",
|
||||
"nickname": "French Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "France",
|
||||
"country_code": "FR",
|
||||
"frequencies": [
|
||||
{"freq_khz": 6910, "primary": True},
|
||||
{"freq_khz": 10640, "primary": False},
|
||||
{"freq_khz": 13870, "primary": False},
|
||||
{"freq_khz": 16840, "primary": False},
|
||||
],
|
||||
"mode": "MIL-STD-188-110/ALE",
|
||||
"description": "French Ministry of Foreign Affairs network. Extensive global coverage with Paris hub. Uses MIL-STD-188-110 with 2G/3G ALE linking protocols.",
|
||||
"operator": "French MFA",
|
||||
"schedule": "24/7 network operations",
|
||||
"source_url": "https://priyom.org/diplomatic/france"
|
||||
},
|
||||
{
|
||||
"id": "romania_mfa",
|
||||
"name": "Romania MFA",
|
||||
"nickname": "Romanian Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Romania",
|
||||
"country_code": "RO",
|
||||
"frequencies": [
|
||||
{"freq_khz": 5390, "primary": True},
|
||||
{"freq_khz": 8158, "primary": False},
|
||||
{"freq_khz": 11555, "primary": False},
|
||||
],
|
||||
"mode": "PACTOR-III/ALE",
|
||||
"description": "Romanian diplomatic network linking Bucharest with embassies. Uses PACTOR-III for traffic and 2G ALE for channel establishment.",
|
||||
"operator": "Romanian MFA",
|
||||
"schedule": "Scheduled daily windows",
|
||||
"source_url": "https://priyom.org/diplomatic/romania"
|
||||
},
|
||||
{
|
||||
"id": "algeria_mfa",
|
||||
"name": "Algeria MFA",
|
||||
"nickname": "Algerian Diplomatic",
|
||||
"type": "diplomatic",
|
||||
"country": "Algeria",
|
||||
"country_code": "DZ",
|
||||
"frequencies": [
|
||||
{"freq_khz": 7706, "primary": True},
|
||||
{"freq_khz": 10235, "primary": False},
|
||||
{"freq_khz": 14385, "primary": False},
|
||||
],
|
||||
"mode": "SITOR-B/PACTOR",
|
||||
"description": "Algerian Ministry of Foreign Affairs network. Links Algiers with African and European embassies. Uses SITOR-B and PACTOR modes.",
|
||||
"operator": "Algerian MFA",
|
||||
"schedule": "Daily scheduled transmissions",
|
||||
"source_url": "https://priyom.org/diplomatic/algeria"
|
||||
},
|
||||
{
|
||||
"id": "egypt_mfa_m14a",
|
||||
"name": "Egypt MFA M14a",
|
||||
"nickname": "Egyptian Extended",
|
||||
"type": "diplomatic",
|
||||
"country": "Egypt",
|
||||
"country_code": "EG",
|
||||
"frequencies": [
|
||||
{"freq_khz": 12175, "primary": True},
|
||||
{"freq_khz": 16360, "primary": False},
|
||||
],
|
||||
"mode": "Codan 3012/SITOR",
|
||||
"description": "Extended Egyptian diplomatic network frequencies. Higher frequency allocations for long-distance embassy communications to Asia and Americas.",
|
||||
"operator": "Egyptian MFA",
|
||||
"schedule": "Daily traffic windows",
|
||||
"source_url": "https://priyom.org/diplomatic/egypt"
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@spy_stations_bp.route('/stations')
|
||||
def get_stations():
|
||||
"""Return all spy stations, optionally filtered."""
|
||||
station_type = request.args.get('type')
|
||||
country = request.args.get('country')
|
||||
mode = request.args.get('mode')
|
||||
|
||||
filtered = STATIONS
|
||||
|
||||
if station_type:
|
||||
filtered = [s for s in filtered if s['type'] == station_type]
|
||||
|
||||
if country:
|
||||
filtered = [s for s in filtered if s['country_code'].upper() == country.upper()]
|
||||
|
||||
if mode:
|
||||
mode_lower = mode.lower()
|
||||
filtered = [s for s in filtered if mode_lower in s['mode'].lower()]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'count': len(filtered),
|
||||
'stations': filtered
|
||||
})
|
||||
|
||||
|
||||
@spy_stations_bp.route('/stations/<station_id>')
|
||||
def get_station(station_id):
|
||||
"""Get a single station by ID."""
|
||||
for station in STATIONS:
|
||||
if station['id'] == station_id:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'station': station
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Station not found'
|
||||
}), 404
|
||||
|
||||
|
||||
@spy_stations_bp.route('/filters')
|
||||
def get_filters():
|
||||
"""Return available filter options."""
|
||||
types = list(set(s['type'] for s in STATIONS))
|
||||
countries = sorted(list(set((s['country'], s['country_code']) for s in STATIONS)))
|
||||
modes = sorted(list(set(s['mode'].split('/')[0] for s in STATIONS)))
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'filters': {
|
||||
'types': types,
|
||||
'countries': [{'name': c[0], 'code': c[1]} for c in countries],
|
||||
'modes': modes
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,722 @@
|
||||
"""ISS SSTV (Slow-Scan Television) decoder routes.
|
||||
|
||||
Provides endpoints for decoding SSTV images from the International Space Station.
|
||||
ISS SSTV events occur during special commemorations and typically transmit on 145.800 MHz FM.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, send_file
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sstv import (
|
||||
get_sstv_decoder,
|
||||
is_sstv_available,
|
||||
ISS_SSTV_FREQ,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.sstv')
|
||||
|
||||
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
||||
|
||||
# Queue for SSE progress streaming
|
||||
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
# Track which device is being used
|
||||
sstv_active_device: int | None = None
|
||||
|
||||
|
||||
def _progress_callback(data: dict) -> None:
|
||||
"""Callback to queue progress/scope updates for SSE stream."""
|
||||
try:
|
||||
_sstv_queue.put_nowait(data)
|
||||
except queue.Full:
|
||||
try:
|
||||
_sstv_queue.get_nowait()
|
||||
_sstv_queue.put_nowait(data)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
@sstv_bp.route('/status')
|
||||
def get_status():
|
||||
"""
|
||||
Get SSTV decoder status.
|
||||
|
||||
Returns:
|
||||
JSON with decoder availability and current status.
|
||||
"""
|
||||
available = is_sstv_available()
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
result = {
|
||||
'available': available,
|
||||
'decoder': decoder.decoder_available,
|
||||
'running': decoder.is_running,
|
||||
'iss_frequency': ISS_SSTV_FREQ,
|
||||
'image_count': len(decoder.get_images()),
|
||||
'doppler_enabled': decoder.doppler_enabled,
|
||||
}
|
||||
|
||||
# Include Doppler info if available
|
||||
doppler_info = decoder.last_doppler_info
|
||||
if doppler_info:
|
||||
result['doppler'] = doppler_info.to_dict()
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@sstv_bp.route('/start', methods=['POST'])
|
||||
def start_decoder():
|
||||
"""
|
||||
Start SSTV decoder.
|
||||
|
||||
JSON body (optional):
|
||||
{
|
||||
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
||||
"device": 0, // RTL-SDR device index
|
||||
"latitude": 40.7128, // Observer latitude for Doppler correction
|
||||
"longitude": -74.0060 // Observer longitude for Doppler correction
|
||||
}
|
||||
|
||||
If latitude and longitude are provided, real-time Doppler shift compensation
|
||||
will be enabled, which improves reception by tracking the ISS frequency shift
|
||||
as it passes overhead (up to ±3.5 kHz at 145.800 MHz).
|
||||
|
||||
Returns:
|
||||
JSON with start status.
|
||||
"""
|
||||
if not is_sstv_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow'
|
||||
}), 400
|
||||
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
if decoder.is_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'frequency': ISS_SSTV_FREQ,
|
||||
'doppler_enabled': decoder.doppler_enabled
|
||||
})
|
||||
|
||||
# Clear queue
|
||||
while not _sstv_queue.empty():
|
||||
try:
|
||||
_sstv_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Get parameters
|
||||
data = request.get_json(silent=True) or {}
|
||||
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
||||
device_index = data.get('device', 0)
|
||||
latitude = data.get('latitude')
|
||||
longitude = data.get('longitude')
|
||||
|
||||
# Validate frequency
|
||||
try:
|
||||
frequency = float(frequency)
|
||||
if not (100 <= frequency <= 500): # VHF range
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Frequency must be between 100-500 MHz'
|
||||
}), 400
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid frequency'
|
||||
}), 400
|
||||
|
||||
# Validate location if provided
|
||||
if latitude is not None and longitude is not None:
|
||||
try:
|
||||
latitude = float(latitude)
|
||||
longitude = float(longitude)
|
||||
if not (-90 <= latitude <= 90):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Latitude must be between -90 and 90'
|
||||
}), 400
|
||||
if not (-180 <= longitude <= 180):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Longitude must be between -180 and 180'
|
||||
}), 400
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid latitude or longitude'
|
||||
}), 400
|
||||
else:
|
||||
latitude = None
|
||||
longitude = None
|
||||
|
||||
# Claim SDR device
|
||||
global sstv_active_device
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'sstv')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
# Set callback and start
|
||||
decoder.set_callback(_progress_callback)
|
||||
success = decoder.start(
|
||||
frequency=frequency,
|
||||
device_index=device_index,
|
||||
latitude=latitude,
|
||||
longitude=longitude
|
||||
)
|
||||
|
||||
if success:
|
||||
sstv_active_device = device_int
|
||||
|
||||
result = {
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'device': device_index,
|
||||
'doppler_enabled': decoder.doppler_enabled
|
||||
}
|
||||
|
||||
# Include initial Doppler info if available
|
||||
if decoder.doppler_enabled and decoder.last_doppler_info:
|
||||
result['doppler'] = decoder.last_doppler_info.to_dict()
|
||||
|
||||
return jsonify(result)
|
||||
else:
|
||||
# Release device on failure
|
||||
app_module.release_sdr_device(device_int)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder'
|
||||
}), 500
|
||||
|
||||
|
||||
@sstv_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoder():
|
||||
"""
|
||||
Stop SSTV decoder.
|
||||
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
global sstv_active_device
|
||||
decoder = get_sstv_decoder()
|
||||
decoder.stop()
|
||||
|
||||
# Release device from registry
|
||||
if sstv_active_device is not None:
|
||||
app_module.release_sdr_device(sstv_active_device)
|
||||
sstv_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@sstv_bp.route('/doppler')
|
||||
def get_doppler():
|
||||
"""
|
||||
Get current Doppler shift information.
|
||||
|
||||
Returns real-time Doppler shift data if tracking is enabled.
|
||||
|
||||
Returns:
|
||||
JSON with Doppler shift information.
|
||||
"""
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
if not decoder.doppler_enabled:
|
||||
return jsonify({
|
||||
'status': 'disabled',
|
||||
'message': 'Doppler tracking not enabled. Provide latitude/longitude when starting decoder.'
|
||||
})
|
||||
|
||||
doppler_info = decoder.last_doppler_info
|
||||
if not doppler_info:
|
||||
return jsonify({
|
||||
'status': 'unavailable',
|
||||
'message': 'Doppler data not yet available'
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'doppler': doppler_info.to_dict(),
|
||||
'nominal_frequency_mhz': ISS_SSTV_FREQ,
|
||||
'corrected_frequency_mhz': doppler_info.frequency_hz / 1_000_000
|
||||
})
|
||||
|
||||
|
||||
@sstv_bp.route('/images')
|
||||
def list_images():
|
||||
"""
|
||||
Get list of decoded SSTV images.
|
||||
|
||||
Query parameters:
|
||||
limit: Maximum number of images to return (default: all)
|
||||
|
||||
Returns:
|
||||
JSON with list of decoded images.
|
||||
"""
|
||||
decoder = get_sstv_decoder()
|
||||
images = decoder.get_images()
|
||||
|
||||
limit = request.args.get('limit', type=int)
|
||||
if limit and limit > 0:
|
||||
images = images[-limit:]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': [img.to_dict() for img in images],
|
||||
'count': len(images)
|
||||
})
|
||||
|
||||
|
||||
@sstv_bp.route('/images/<filename>')
|
||||
def get_image(filename: str):
|
||||
"""
|
||||
Get a decoded SSTV image file.
|
||||
|
||||
Args:
|
||||
filename: Image filename
|
||||
|
||||
Returns:
|
||||
Image file or 404.
|
||||
"""
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
|
||||
# Find image in decoder's output directory
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
return send_file(image_path, mimetype='image/png')
|
||||
|
||||
|
||||
@sstv_bp.route('/images/<filename>/download')
|
||||
def download_image(filename: str):
|
||||
"""
|
||||
Download a decoded SSTV image file.
|
||||
|
||||
Args:
|
||||
filename: Image filename
|
||||
|
||||
Returns:
|
||||
Image file as attachment or 404.
|
||||
"""
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
||||
|
||||
|
||||
@sstv_bp.route('/images/<filename>', methods=['DELETE'])
|
||||
def delete_image(filename: str):
|
||||
"""
|
||||
Delete a decoded SSTV image.
|
||||
|
||||
Args:
|
||||
filename: Image filename
|
||||
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
decoder = get_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'ok'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
|
||||
@sstv_bp.route('/images', methods=['DELETE'])
|
||||
def delete_all_images():
|
||||
"""
|
||||
Delete all decoded SSTV images.
|
||||
|
||||
Returns:
|
||||
JSON with count of deleted images.
|
||||
"""
|
||||
decoder = get_sstv_decoder()
|
||||
count = decoder.delete_all_images()
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
|
||||
|
||||
@sstv_bp.route('/stream')
|
||||
def stream_progress():
|
||||
"""
|
||||
SSE stream of SSTV decode progress.
|
||||
|
||||
Provides real-time Server-Sent Events stream of decode progress.
|
||||
|
||||
Event format:
|
||||
data: {"type": "sstv_progress", "status": "decoding", "mode": "PD120", ...}
|
||||
|
||||
Returns:
|
||||
SSE stream (text/event-stream)
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
progress = _sstv_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('sstv', progress, progress.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(progress)
|
||||
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
|
||||
|
||||
|
||||
@sstv_bp.route('/iss-schedule')
|
||||
def iss_schedule():
|
||||
"""
|
||||
Get ISS pass schedule for SSTV reception.
|
||||
|
||||
Calculates ISS passes directly using skyfield.
|
||||
|
||||
Query parameters:
|
||||
latitude: Observer latitude (required)
|
||||
longitude: Observer longitude (required)
|
||||
hours: Hours to look ahead (default: 48)
|
||||
|
||||
Returns:
|
||||
JSON with ISS pass schedule.
|
||||
"""
|
||||
lat = request.args.get('latitude', type=float)
|
||||
lon = request.args.get('longitude', type=float)
|
||||
hours = request.args.get('hours', 48, type=int)
|
||||
|
||||
if lat is None or lon is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'latitude and longitude parameters required'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
from skyfield.api import load, wgs84, EarthSatellite
|
||||
from skyfield.almanac import find_discrete
|
||||
from datetime import timedelta
|
||||
from data.satellites import TLE_SATELLITES
|
||||
|
||||
# Get ISS TLE
|
||||
iss_tle = TLE_SATELLITES.get('ISS')
|
||||
if not iss_tle:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'ISS TLE data not available'
|
||||
}), 500
|
||||
|
||||
ts = load.timescale()
|
||||
satellite = EarthSatellite(iss_tle[1], iss_tle[2], iss_tle[0], ts)
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
|
||||
t0 = ts.now()
|
||||
t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours))
|
||||
|
||||
def above_horizon(t):
|
||||
diff = satellite - observer
|
||||
topocentric = diff.at(t)
|
||||
alt, _, _ = topocentric.altaz()
|
||||
return alt.degrees > 0
|
||||
|
||||
above_horizon.step_days = 1/720
|
||||
|
||||
times, events = find_discrete(t0, t1, above_horizon)
|
||||
|
||||
passes = []
|
||||
i = 0
|
||||
while i < len(times):
|
||||
if i < len(events) and events[i]: # Rising
|
||||
rise_time = times[i]
|
||||
set_time = None
|
||||
|
||||
for j in range(i + 1, len(times)):
|
||||
if not events[j]: # Setting
|
||||
set_time = times[j]
|
||||
i = j
|
||||
break
|
||||
else:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if set_time is None:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Calculate max elevation
|
||||
max_el = 0
|
||||
duration_seconds = (set_time.utc_datetime() - rise_time.utc_datetime()).total_seconds()
|
||||
duration_minutes = int(duration_seconds / 60)
|
||||
|
||||
for k in range(30):
|
||||
frac = k / 29
|
||||
t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
|
||||
diff = satellite - observer
|
||||
topocentric = diff.at(t_point)
|
||||
alt, _, _ = topocentric.altaz()
|
||||
if alt.degrees > max_el:
|
||||
max_el = alt.degrees
|
||||
|
||||
if max_el >= 10: # Min elevation filter
|
||||
passes.append({
|
||||
'satellite': 'ISS',
|
||||
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
|
||||
'startTimeISO': rise_time.utc_datetime().isoformat(),
|
||||
'maxEl': round(max_el, 1),
|
||||
'duration': duration_minutes,
|
||||
'color': '#00ffff'
|
||||
})
|
||||
|
||||
i += 1
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'passes': passes,
|
||||
'count': len(passes),
|
||||
'sstv_frequency': ISS_SSTV_FREQ,
|
||||
'note': 'ISS SSTV events are not continuous. Check ARISS.org for scheduled events.'
|
||||
})
|
||||
|
||||
except ImportError:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'skyfield library not installed'
|
||||
}), 503
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ISS schedule: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@sstv_bp.route('/iss-position')
|
||||
def iss_position():
|
||||
"""
|
||||
Get current ISS position from real-time API.
|
||||
|
||||
Uses the Open Notify API for accurate real-time position,
|
||||
with fallback to "Where The ISS At" API.
|
||||
|
||||
Query parameters:
|
||||
latitude: Observer latitude (optional, for elevation calc)
|
||||
longitude: Observer longitude (optional, for elevation calc)
|
||||
|
||||
Returns:
|
||||
JSON with ISS current position.
|
||||
"""
|
||||
import requests
|
||||
from datetime import datetime
|
||||
|
||||
observer_lat = request.args.get('latitude', type=float)
|
||||
observer_lon = request.args.get('longitude', type=float)
|
||||
|
||||
# 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'])
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'lat': iss_lat,
|
||||
'lon': iss_lon,
|
||||
'altitude': float(data.get('altitude', 420)),
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'source': 'wheretheiss'
|
||||
}
|
||||
|
||||
# Calculate observer-relative data if location provided
|
||||
if observer_lat is not None and observer_lon is not None:
|
||||
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
||||
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.warning(f"Where The ISS At API failed: {e}")
|
||||
|
||||
# Try fallback API: Open Notify
|
||||
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'])
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'lat': iss_lat,
|
||||
'lon': iss_lon,
|
||||
'altitude': 420, # Approximate ISS altitude in km
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'source': 'open-notify'
|
||||
}
|
||||
|
||||
# Calculate observer-relative data if location provided
|
||||
if observer_lat is not None and observer_lon is not None:
|
||||
result.update(_calculate_observer_data(iss_lat, iss_lon, observer_lat, observer_lon))
|
||||
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.warning(f"Open Notify API failed: {e}")
|
||||
|
||||
# Both APIs failed
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Unable to fetch ISS position from real-time APIs'
|
||||
}), 503
|
||||
|
||||
|
||||
def _calculate_observer_data(iss_lat: float, iss_lon: float, obs_lat: float, obs_lon: float) -> dict:
|
||||
"""Calculate elevation, azimuth, and distance from observer to ISS."""
|
||||
import math
|
||||
|
||||
# ISS altitude in km
|
||||
iss_alt_km = 420
|
||||
|
||||
# Earth radius in km
|
||||
earth_radius = 6371
|
||||
|
||||
# Convert to radians
|
||||
lat1 = math.radians(obs_lat)
|
||||
lat2 = math.radians(iss_lat)
|
||||
lon1 = math.radians(obs_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 elevation angle (simplified)
|
||||
# Using spherical geometry approximation
|
||||
iss_height = iss_alt_km
|
||||
slant_range = math.sqrt(ground_distance**2 + iss_height**2)
|
||||
|
||||
if ground_distance > 0:
|
||||
elevation = math.degrees(math.atan2(iss_height - (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
|
||||
|
||||
return {
|
||||
'elevation': round(elevation, 1),
|
||||
'azimuth': round(azimuth, 1),
|
||||
'distance': round(slant_range, 1)
|
||||
}
|
||||
|
||||
|
||||
@sstv_bp.route('/decode-file', methods=['POST'])
|
||||
def decode_file():
|
||||
"""
|
||||
Decode SSTV from an uploaded audio file.
|
||||
|
||||
Expects multipart/form-data with 'audio' file field.
|
||||
|
||||
Returns:
|
||||
JSON with decoded images.
|
||||
"""
|
||||
if 'audio' not in request.files:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No audio file provided'
|
||||
}), 400
|
||||
|
||||
audio_file = request.files['audio']
|
||||
|
||||
if not audio_file.filename:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No file selected'
|
||||
}), 400
|
||||
|
||||
# Save to temp file
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
||||
audio_file.save(tmp.name)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
decoder = get_sstv_decoder()
|
||||
images = decoder.decode_file(tmp_path)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': [img.to_dict() for img in images],
|
||||
'count': len(images)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error decoding file: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
finally:
|
||||
# Clean up temp file
|
||||
try:
|
||||
Path(tmp_path).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,338 @@
|
||||
"""General SSTV (Slow-Scan Television) decoder routes.
|
||||
|
||||
Provides endpoints for decoding terrestrial SSTV images on common HF/VHF/UHF
|
||||
frequencies used by amateur radio operators worldwide.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import time
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sstv import (
|
||||
get_general_sstv_decoder,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.sstv_general')
|
||||
|
||||
sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general')
|
||||
|
||||
# Queue for SSE progress streaming
|
||||
_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
# Predefined SSTV frequencies
|
||||
SSTV_FREQUENCIES = [
|
||||
{'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'},
|
||||
{'band': '80 m', 'frequency': 3.730, 'modulation': 'lsb', 'notes': 'Europe primary (analog/digital variants)', 'type': 'Terrestrial HF'},
|
||||
{'band': '40 m', 'frequency': 7.171, 'modulation': 'lsb', 'notes': 'Common international/US/EU SSTV activity', 'type': 'Terrestrial HF'},
|
||||
{'band': '40 m', 'frequency': 7.040, 'modulation': 'lsb', 'notes': 'Alternative US/Europe calling', 'type': 'Terrestrial HF'},
|
||||
{'band': '30 m', 'frequency': 10.132, 'modulation': 'usb', 'notes': 'Narrowband SSTV (e.g., MP73-N digital)', 'type': 'Terrestrial HF'},
|
||||
{'band': '20 m', 'frequency': 14.230, 'modulation': 'usb', 'notes': 'Most popular international SSTV frequency', 'type': 'Terrestrial HF'},
|
||||
{'band': '20 m', 'frequency': 14.233, 'modulation': 'usb', 'notes': 'Digital SSTV calling / alternative activity', 'type': 'Terrestrial HF'},
|
||||
{'band': '20 m', 'frequency': 14.240, 'modulation': 'usb', 'notes': 'Europe alternative', 'type': 'Terrestrial HF'},
|
||||
{'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
||||
{'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
||||
{'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'},
|
||||
{'band': '2 m', 'frequency': 145.625, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
|
||||
{'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'},
|
||||
]
|
||||
|
||||
# Build a lookup for auto-detecting modulation from frequency
|
||||
_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES}
|
||||
|
||||
|
||||
def _progress_callback(data: dict) -> None:
|
||||
"""Callback to queue progress/scope updates for SSE stream."""
|
||||
try:
|
||||
_sstv_general_queue.put_nowait(data)
|
||||
except queue.Full:
|
||||
try:
|
||||
_sstv_general_queue.get_nowait()
|
||||
_sstv_general_queue.put_nowait(data)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
@sstv_general_bp.route('/frequencies')
|
||||
def get_frequencies():
|
||||
"""Return the predefined SSTV frequency table."""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'frequencies': SSTV_FREQUENCIES,
|
||||
})
|
||||
|
||||
|
||||
@sstv_general_bp.route('/status')
|
||||
def get_status():
|
||||
"""Get general SSTV decoder status."""
|
||||
decoder = get_general_sstv_decoder()
|
||||
|
||||
return jsonify({
|
||||
'available': decoder.decoder_available is not None,
|
||||
'decoder': decoder.decoder_available,
|
||||
'running': decoder.is_running,
|
||||
'image_count': len(decoder.get_images()),
|
||||
})
|
||||
|
||||
|
||||
@sstv_general_bp.route('/start', methods=['POST'])
|
||||
def start_decoder():
|
||||
"""
|
||||
Start general SSTV decoder.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"frequency": 14.230, // Frequency in MHz (required)
|
||||
"modulation": "usb", // fm, usb, or lsb (auto-detected from frequency table if omitted)
|
||||
"device": 0 // RTL-SDR device index
|
||||
}
|
||||
"""
|
||||
decoder = get_general_sstv_decoder()
|
||||
|
||||
if decoder.decoder_available is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow',
|
||||
}), 400
|
||||
|
||||
if decoder.is_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
})
|
||||
|
||||
# Clear queue
|
||||
while not _sstv_general_queue.empty():
|
||||
try:
|
||||
_sstv_general_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
frequency = data.get('frequency')
|
||||
modulation = data.get('modulation')
|
||||
device_index = data.get('device', 0)
|
||||
|
||||
# Validate frequency
|
||||
if frequency is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Frequency is required',
|
||||
}), 400
|
||||
|
||||
try:
|
||||
frequency = float(frequency)
|
||||
if not (1 <= frequency <= 500):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)',
|
||||
}), 400
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid frequency',
|
||||
}), 400
|
||||
|
||||
# Auto-detect modulation from frequency table if not specified
|
||||
if not modulation:
|
||||
modulation = _FREQ_MODULATION_MAP.get(frequency, 'usb')
|
||||
|
||||
# Validate modulation
|
||||
if modulation not in ('fm', 'usb', 'lsb'):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Modulation must be fm, usb, or lsb',
|
||||
}), 400
|
||||
|
||||
# Set callback and start
|
||||
decoder.set_callback(_progress_callback)
|
||||
success = decoder.start(
|
||||
frequency=frequency,
|
||||
device_index=device_index,
|
||||
modulation=modulation,
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'modulation': modulation,
|
||||
'device': device_index,
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder',
|
||||
}), 500
|
||||
|
||||
|
||||
@sstv_general_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoder():
|
||||
"""Stop general SSTV decoder."""
|
||||
decoder = get_general_sstv_decoder()
|
||||
decoder.stop()
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@sstv_general_bp.route('/images')
|
||||
def list_images():
|
||||
"""Get list of decoded SSTV images."""
|
||||
decoder = get_general_sstv_decoder()
|
||||
images = decoder.get_images()
|
||||
|
||||
limit = request.args.get('limit', type=int)
|
||||
if limit and limit > 0:
|
||||
images = images[-limit:]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': [img.to_dict() for img in images],
|
||||
'count': len(images),
|
||||
})
|
||||
|
||||
|
||||
@sstv_general_bp.route('/images/<filename>')
|
||||
def get_image(filename: str):
|
||||
"""Get a decoded SSTV image file."""
|
||||
decoder = get_general_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
return send_file(image_path, mimetype='image/png')
|
||||
|
||||
|
||||
@sstv_general_bp.route('/images/<filename>/download')
|
||||
def download_image(filename: str):
|
||||
"""Download a decoded SSTV image file."""
|
||||
decoder = get_general_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
||||
|
||||
|
||||
@sstv_general_bp.route('/images/<filename>', methods=['DELETE'])
|
||||
def delete_image(filename: str):
|
||||
"""Delete a decoded SSTV image."""
|
||||
decoder = get_general_sstv_decoder()
|
||||
|
||||
# Security: only allow alphanumeric filenames with .png extension
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not filename.endswith('.png'):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'ok'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
|
||||
@sstv_general_bp.route('/images', methods=['DELETE'])
|
||||
def delete_all_images():
|
||||
"""Delete all decoded SSTV images."""
|
||||
decoder = get_general_sstv_decoder()
|
||||
count = decoder.delete_all_images()
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
|
||||
|
||||
@sstv_general_bp.route('/stream')
|
||||
def stream_progress():
|
||||
"""SSE stream of SSTV decode progress."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
progress = _sstv_general_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('sstv_general', progress, progress.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(progress)
|
||||
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
|
||||
|
||||
|
||||
@sstv_general_bp.route('/decode-file', methods=['POST'])
|
||||
def decode_file():
|
||||
"""Decode SSTV from an uploaded audio file."""
|
||||
if 'audio' not in request.files:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No audio file provided',
|
||||
}), 400
|
||||
|
||||
audio_file = request.files['audio']
|
||||
|
||||
if not audio_file.filename:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No file selected',
|
||||
}), 400
|
||||
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
||||
audio_file.save(tmp.name)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
decoder = get_general_sstv_decoder()
|
||||
images = decoder.decode_file(tmp_path)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': [img.to_dict() for img in images],
|
||||
'count': len(images),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error decoding file: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
}), 500
|
||||
|
||||
finally:
|
||||
try:
|
||||
Path(tmp_path).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,424 @@
|
||||
"""SubGHz transceiver routes.
|
||||
|
||||
Provides endpoints for HackRF-based SubGHz signal capture, protocol decoding,
|
||||
signal replay/transmit, and wideband spectrum analysis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, send_file
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream
|
||||
from utils.subghz import get_subghz_manager
|
||||
from utils.constants import (
|
||||
SUBGHZ_FREQ_MIN_MHZ,
|
||||
SUBGHZ_FREQ_MAX_MHZ,
|
||||
SUBGHZ_LNA_GAIN_MAX,
|
||||
SUBGHZ_VGA_GAIN_MAX,
|
||||
SUBGHZ_TX_VGA_GAIN_MAX,
|
||||
SUBGHZ_TX_MAX_DURATION,
|
||||
SUBGHZ_SAMPLE_RATES,
|
||||
SUBGHZ_PRESETS,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.subghz')
|
||||
|
||||
subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz')
|
||||
|
||||
# SSE queue for streaming events to frontend
|
||||
_subghz_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||
|
||||
|
||||
def _event_callback(event: dict) -> None:
|
||||
"""Forward SubGhzManager events to the SSE queue."""
|
||||
try:
|
||||
_subghz_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
try:
|
||||
_subghz_queue.get_nowait()
|
||||
_subghz_queue.put_nowait(event)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
def _validate_frequency_hz(data: dict, key: str = 'frequency_hz') -> tuple[int | None, str | None]:
|
||||
"""Validate frequency in Hz from request data. Returns (freq_hz, error_msg)."""
|
||||
raw = data.get(key)
|
||||
if raw is None:
|
||||
return None, f'{key} is required'
|
||||
try:
|
||||
freq_hz = int(raw)
|
||||
freq_mhz = freq_hz / 1_000_000
|
||||
if not (SUBGHZ_FREQ_MIN_MHZ <= freq_mhz <= SUBGHZ_FREQ_MAX_MHZ):
|
||||
return None, f'Frequency must be between {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'
|
||||
return freq_hz, None
|
||||
except (ValueError, TypeError):
|
||||
return None, f'Invalid {key}'
|
||||
|
||||
|
||||
def _validate_serial(data: dict) -> str | None:
|
||||
"""Extract and validate optional HackRF device serial."""
|
||||
serial = data.get('device_serial', '')
|
||||
if not serial or not isinstance(serial, str):
|
||||
return None
|
||||
# HackRF serials are hex strings
|
||||
serial = serial.strip()
|
||||
if serial and all(c in '0123456789abcdefABCDEF' for c in serial):
|
||||
return serial
|
||||
return None
|
||||
|
||||
|
||||
def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int) -> int:
|
||||
"""Validate integer parameter with bounds clamping."""
|
||||
try:
|
||||
val = int(data.get(key, default))
|
||||
return max(min_val, min(max_val, val))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _validate_decode_profile(data: dict, default: str = 'weather') -> str:
|
||||
profile = data.get('decode_profile', default)
|
||||
if not isinstance(profile, str):
|
||||
return default
|
||||
profile = profile.strip().lower()
|
||||
if profile in {'weather', 'all'}:
|
||||
return profile
|
||||
return default
|
||||
|
||||
|
||||
def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]:
|
||||
raw = data.get(key)
|
||||
if raw is None or raw == '':
|
||||
return None, None
|
||||
try:
|
||||
return float(raw), None
|
||||
except (ValueError, TypeError):
|
||||
return None, f'Invalid {key}'
|
||||
|
||||
|
||||
def _validate_bool(data: dict, key: str, default: bool = False) -> bool:
|
||||
raw = data.get(key, default)
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if isinstance(raw, (int, float)):
|
||||
return bool(raw)
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'}
|
||||
return default
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# STATUS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/status')
|
||||
def get_status():
|
||||
manager = get_subghz_manager()
|
||||
return jsonify(manager.get_status())
|
||||
|
||||
|
||||
@subghz_bp.route('/presets')
|
||||
def get_presets():
|
||||
return jsonify({'presets': SUBGHZ_PRESETS, 'sample_rates': SUBGHZ_SAMPLE_RATES})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RECEIVE
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/receive/start', methods=['POST'])
|
||||
def start_receive():
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
freq_hz, err = _validate_frequency_hz(data)
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
|
||||
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
||||
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
||||
trigger_enabled = _validate_bool(data, 'trigger_enabled', False)
|
||||
trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000)
|
||||
trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000)
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.start_receive(
|
||||
frequency_hz=freq_hz,
|
||||
sample_rate=sample_rate,
|
||||
lna_gain=lna_gain,
|
||||
vga_gain=vga_gain,
|
||||
trigger_enabled=trigger_enabled,
|
||||
trigger_pre_ms=trigger_pre_ms,
|
||||
trigger_post_ms=trigger_post_ms,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 409
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/receive/stop', methods=['POST'])
|
||||
def stop_receive():
|
||||
manager = get_subghz_manager()
|
||||
result = manager.stop_receive()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DECODE
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/decode/start', methods=['POST'])
|
||||
def start_decode():
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
freq_hz, err = _validate_frequency_hz(data)
|
||||
if err:
|
||||
return jsonify({'status': 'error', 'message': err}), 400
|
||||
|
||||
sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000)
|
||||
lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX)
|
||||
vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX)
|
||||
decode_profile = _validate_decode_profile(data)
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.start_decode(
|
||||
frequency_hz=freq_hz,
|
||||
sample_rate=sample_rate,
|
||||
lna_gain=lna_gain,
|
||||
vga_gain=vga_gain,
|
||||
decode_profile=decode_profile,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 409
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/decode/stop', methods=['POST'])
|
||||
def stop_decode():
|
||||
manager = get_subghz_manager()
|
||||
result = manager.stop_decode()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# TRANSMIT
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/transmit', methods=['POST'])
|
||||
def start_transmit():
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
capture_id = data.get('capture_id')
|
||||
if not capture_id or not isinstance(capture_id, str):
|
||||
return jsonify({'status': 'error', 'message': 'capture_id is required'}), 400
|
||||
|
||||
# Sanitize capture_id
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX)
|
||||
max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION)
|
||||
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||
if start_err:
|
||||
return jsonify({'status': 'error', 'message': start_err}), 400
|
||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||
if duration_err:
|
||||
return jsonify({'status': 'error', 'message': duration_err}), 400
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.transmit(
|
||||
capture_id=capture_id,
|
||||
tx_gain=tx_gain,
|
||||
max_duration=max_duration,
|
||||
start_seconds=start_seconds,
|
||||
duration_seconds=duration_seconds,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 400
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/transmit/stop', methods=['POST'])
|
||||
def stop_transmit():
|
||||
manager = get_subghz_manager()
|
||||
result = manager.stop_transmit()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SWEEP
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/sweep/start', methods=['POST'])
|
||||
def start_sweep():
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
try:
|
||||
freq_start = float(data.get('freq_start_mhz', 300))
|
||||
freq_end = float(data.get('freq_end_mhz', 928))
|
||||
if freq_start >= freq_end:
|
||||
return jsonify({'status': 'error', 'message': 'freq_start must be less than freq_end'}), 400
|
||||
if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ:
|
||||
return jsonify({'status': 'error', 'message': f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz'}), 400
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid frequency range'}), 400
|
||||
|
||||
bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000)
|
||||
device_serial = _validate_serial(data)
|
||||
|
||||
manager = get_subghz_manager()
|
||||
manager.set_callback(_event_callback)
|
||||
|
||||
result = manager.start_sweep(
|
||||
freq_start_mhz=freq_start,
|
||||
freq_end_mhz=freq_end,
|
||||
bin_width=bin_width,
|
||||
device_serial=device_serial,
|
||||
)
|
||||
|
||||
status_code = 200 if result.get('status') != 'error' else 409
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/sweep/stop', methods=['POST'])
|
||||
def stop_sweep():
|
||||
manager = get_subghz_manager()
|
||||
result = manager.stop_sweep()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CAPTURES LIBRARY
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/captures')
|
||||
def list_captures():
|
||||
manager = get_subghz_manager()
|
||||
captures = manager.list_captures()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'captures': [c.to_dict() for c in captures],
|
||||
'count': len(captures),
|
||||
})
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>')
|
||||
def get_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
capture = manager.get_capture(capture_id)
|
||||
if not capture:
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
|
||||
return jsonify({'status': 'ok', 'capture': capture.to_dict()})
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>/download')
|
||||
def download_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
path = manager.get_capture_path(capture_id)
|
||||
if not path:
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
|
||||
return send_file(
|
||||
path,
|
||||
mimetype='application/octet-stream',
|
||||
as_attachment=True,
|
||||
download_name=path.name,
|
||||
)
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>/trim', methods=['POST'])
|
||||
def trim_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
start_seconds, start_err = _validate_optional_float(data, 'start_seconds')
|
||||
if start_err:
|
||||
return jsonify({'status': 'error', 'message': start_err}), 400
|
||||
duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds')
|
||||
if duration_err:
|
||||
return jsonify({'status': 'error', 'message': duration_err}), 400
|
||||
|
||||
label = data.get('label', '')
|
||||
if label is None:
|
||||
label = ''
|
||||
if not isinstance(label, str) or len(label) > 100:
|
||||
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
result = manager.trim_capture(
|
||||
capture_id=capture_id,
|
||||
start_seconds=start_seconds,
|
||||
duration_seconds=duration_seconds,
|
||||
label=label,
|
||||
)
|
||||
|
||||
if result.get('status') == 'ok':
|
||||
return jsonify(result), 200
|
||||
message = str(result.get('message') or 'Trim failed')
|
||||
status_code = 404 if 'not found' in message.lower() else 400
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>', methods=['DELETE'])
|
||||
def delete_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
if manager.delete_capture(capture_id):
|
||||
return jsonify({'status': 'deleted', 'id': capture_id})
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
|
||||
|
||||
@subghz_bp.route('/captures/<capture_id>', methods=['PATCH'])
|
||||
def update_capture(capture_id: str):
|
||||
if not capture_id.isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture_id'}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
label = data.get('label', '')
|
||||
|
||||
if not isinstance(label, str) or len(label) > 100:
|
||||
return jsonify({'status': 'error', 'message': 'Label must be a string (max 100 chars)'}), 400
|
||||
|
||||
manager = get_subghz_manager()
|
||||
if manager.update_capture_label(capture_id, label):
|
||||
return jsonify({'status': 'updated', 'id': capture_id, 'label': label})
|
||||
return jsonify({'status': 'error', 'message': 'Capture not found'}), 404
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SSE STREAM
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subghz_bp.route('/stream')
|
||||
def stream():
|
||||
response = Response(sse_stream(_subghz_queue), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Updater routes - GitHub update checking and application updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.updater import (
|
||||
check_for_updates,
|
||||
dismiss_update,
|
||||
get_update_status,
|
||||
perform_update,
|
||||
restart_application,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.routes.updater')
|
||||
|
||||
updater_bp = Blueprint('updater', __name__, url_prefix='/updater')
|
||||
|
||||
|
||||
@updater_bp.route('/check', methods=['GET'])
|
||||
def check_updates() -> Response:
|
||||
"""
|
||||
Check for updates from GitHub.
|
||||
|
||||
Uses caching to avoid excessive API calls. Will only hit GitHub
|
||||
if the cache is stale (default: 6 hours).
|
||||
|
||||
Query parameters:
|
||||
force: Set to 'true' to bypass cache and check GitHub directly
|
||||
|
||||
Returns:
|
||||
JSON with update status information
|
||||
"""
|
||||
force = request.args.get('force', '').lower() == 'true'
|
||||
|
||||
try:
|
||||
result = check_for_updates(force=force)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking for updates: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@updater_bp.route('/status', methods=['GET'])
|
||||
def update_status() -> Response:
|
||||
"""
|
||||
Get current update status from cache.
|
||||
|
||||
This endpoint does NOT trigger a GitHub check - it only returns
|
||||
cached data. Use /check to trigger a fresh check.
|
||||
|
||||
Returns:
|
||||
JSON with cached update status
|
||||
"""
|
||||
try:
|
||||
result = get_update_status()
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting update status: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@updater_bp.route('/update', methods=['POST'])
|
||||
def do_update() -> Response:
|
||||
"""
|
||||
Perform a git pull to update the application.
|
||||
|
||||
Request body (JSON):
|
||||
stash_changes: If true, stash local changes before pulling
|
||||
|
||||
Returns:
|
||||
JSON with update result information
|
||||
"""
|
||||
data = request.json or {}
|
||||
stash_changes = data.get('stash_changes', False)
|
||||
|
||||
try:
|
||||
result = perform_update(stash_changes=stash_changes)
|
||||
|
||||
if result.get('success'):
|
||||
return jsonify(result)
|
||||
else:
|
||||
# Return appropriate status code based on error type
|
||||
error = result.get('error', '')
|
||||
if error == 'local_changes':
|
||||
return jsonify(result), 409 # Conflict
|
||||
elif error == 'merge_conflict':
|
||||
return jsonify(result), 409
|
||||
elif result.get('manual_update'):
|
||||
return jsonify(result), 400
|
||||
else:
|
||||
return jsonify(result), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error performing update: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@updater_bp.route('/dismiss', methods=['POST'])
|
||||
def dismiss_notification() -> Response:
|
||||
"""
|
||||
Dismiss update notification for a specific version.
|
||||
|
||||
The notification will not be shown again until a newer version
|
||||
is available.
|
||||
|
||||
Request body (JSON):
|
||||
version: The version to dismiss notifications for
|
||||
|
||||
Returns:
|
||||
JSON with success status
|
||||
"""
|
||||
data = request.json or {}
|
||||
version = data.get('version')
|
||||
|
||||
if not version:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Version is required'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
result = dismiss_update(version)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error dismissing update: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@updater_bp.route('/restart', methods=['POST'])
|
||||
def restart_app() -> Response:
|
||||
"""
|
||||
Restart the application.
|
||||
|
||||
This endpoint triggers a graceful restart of the application:
|
||||
1. Stops all running decoder processes
|
||||
2. Cleans up global state
|
||||
3. Replaces the current process with a fresh instance
|
||||
|
||||
The response may not be received by the client since the process
|
||||
is replaced immediately. Clients should poll /health until the
|
||||
server responds again.
|
||||
|
||||
Returns:
|
||||
JSON with restart status (may not be delivered)
|
||||
"""
|
||||
import threading
|
||||
|
||||
logger.info("Restart requested via API")
|
||||
|
||||
# Send response before restarting
|
||||
# Use a short delay to allow the response to be sent
|
||||
def delayed_restart():
|
||||
import time
|
||||
time.sleep(0.5) # Allow response to be sent
|
||||
restart_application()
|
||||
|
||||
# Start restart in a background thread so we can return a response
|
||||
restart_thread = threading.Thread(target=delayed_restart, daemon=False)
|
||||
restart_thread.start()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Application is restarting. Please wait...',
|
||||
'action': 'restart'
|
||||
})
|
||||
@@ -0,0 +1,386 @@
|
||||
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
|
||||
|
||||
import json
|
||||
import queue
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from flask import Flask
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
Sock = None
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.waterfall_fft import (
|
||||
build_binary_frame,
|
||||
compute_power_spectrum,
|
||||
cu8_to_complex,
|
||||
quantize_to_uint8,
|
||||
)
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sdr.base import SDRCapabilities, SDRDevice
|
||||
|
||||
logger = get_logger('intercept.waterfall_ws')
|
||||
|
||||
# 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 _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
||||
"""Convert client sdr_type string to SDRType enum."""
|
||||
mapping = {
|
||||
'rtlsdr': SDRType.RTL_SDR,
|
||||
'rtl_sdr': SDRType.RTL_SDR,
|
||||
'hackrf': SDRType.HACKRF,
|
||||
'limesdr': SDRType.LIME_SDR,
|
||||
'lime_sdr': 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:
|
||||
"""Build a minimal SDRDevice for command building."""
|
||||
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 init_waterfall_websocket(app: Flask):
|
||||
"""Initialize WebSocket waterfall streaming."""
|
||||
if not WEBSOCKET_AVAILABLE:
|
||||
logger.warning("flask-sock not installed, WebSocket waterfall disabled")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/waterfall')
|
||||
def waterfall_stream(ws):
|
||||
"""WebSocket endpoint for real-time waterfall streaming."""
|
||||
logger.info("WebSocket waterfall client connected")
|
||||
|
||||
# Import app module for device claiming
|
||||
import app as app_module
|
||||
|
||||
iq_process = None
|
||||
reader_thread = None
|
||||
stop_event = threading.Event()
|
||||
claimed_device = None
|
||||
# Queue for outgoing messages — only the main loop touches ws.send()
|
||||
send_queue = queue.Queue(maxsize=120)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Drain send queue first (non-blocking)
|
||||
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.1)
|
||||
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:
|
||||
# simple-websocket returns None on timeout AND on
|
||||
# close; check ws.connected to tell them apart.
|
||||
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_device = None
|
||||
stop_event.clear()
|
||||
# Flush stale frames from previous capture
|
||||
while not send_queue.empty():
|
||||
try:
|
||||
send_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
# Allow USB device to be released by the kernel
|
||||
if was_restarting:
|
||||
time.sleep(0.5)
|
||||
|
||||
# Parse config
|
||||
center_freq = float(data.get('center_freq', 100.0))
|
||||
span_mhz = float(data.get('span_mhz', 2.0))
|
||||
gain = data.get('gain')
|
||||
if gain is not None:
|
||||
gain = float(gain)
|
||||
device_index = int(data.get('device', 0))
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
fft_size = int(data.get('fft_size', 1024))
|
||||
fps = int(data.get('fps', 25))
|
||||
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))
|
||||
|
||||
# Clamp FFT size to valid powers of 2
|
||||
fft_size = max(256, min(8192, fft_size))
|
||||
|
||||
# Resolve SDR type and bandwidth
|
||||
sdr_type = _resolve_sdr_type(sdr_type_str)
|
||||
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
|
||||
span_hz = int(span_mhz * 1e6)
|
||||
sample_rate = min(span_hz, max_bw)
|
||||
|
||||
# Compute effective frequency range
|
||||
effective_span_mhz = sample_rate / 1e6
|
||||
start_freq = center_freq - effective_span_mhz / 2
|
||||
end_freq = center_freq + effective_span_mhz / 2
|
||||
|
||||
# Claim the device
|
||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
||||
if claim_err:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': claim_err,
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
}))
|
||||
continue
|
||||
claimed_device = device_index
|
||||
|
||||
# Build I/Q capture command
|
||||
try:
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
device = _build_dummy_device(device_index, sdr_type)
|
||||
iq_cmd = builder.build_iq_capture_command(
|
||||
device=device,
|
||||
frequency_mhz=center_freq,
|
||||
sample_rate=sample_rate,
|
||||
gain=gain,
|
||||
ppm=ppm,
|
||||
bias_t=bias_t,
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
app_module.release_sdr_device(device_index)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
}))
|
||||
continue
|
||||
|
||||
# Spawn I/Q capture process (retry to handle USB release lag)
|
||||
max_attempts = 3 if was_restarting else 1
|
||||
try:
|
||||
for attempt in range(max_attempts):
|
||||
logger.info(
|
||||
f"Starting I/Q capture: {center_freq} MHz, "
|
||||
f"span={effective_span_mhz:.1f} MHz, "
|
||||
f"sr={sample_rate}, fft={fft_size}"
|
||||
)
|
||||
iq_process = subprocess.Popen(
|
||||
iq_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
bufsize=0,
|
||||
)
|
||||
register_process(iq_process)
|
||||
|
||||
# Brief check that process started
|
||||
time.sleep(0.3)
|
||||
if iq_process.poll() is not None:
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if attempt < max_attempts - 1:
|
||||
logger.info(
|
||||
f"I/Q process exited immediately, "
|
||||
f"retrying ({attempt + 1}/{max_attempts})..."
|
||||
)
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
raise RuntimeError(
|
||||
"I/Q capture process exited immediately"
|
||||
)
|
||||
break # Process started successfully
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start 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)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Failed to start I/Q capture: {e}',
|
||||
}))
|
||||
continue
|
||||
|
||||
# Send started confirmation
|
||||
ws.send(json.dumps({
|
||||
'status': 'started',
|
||||
'start_freq': start_freq,
|
||||
'end_freq': end_freq,
|
||||
'fft_size': fft_size,
|
||||
'sample_rate': sample_rate,
|
||||
}))
|
||||
|
||||
# Start reader thread — puts frames on queue, never calls ws.send()
|
||||
def fft_reader(
|
||||
proc, _send_q, stop_evt,
|
||||
_fft_size, _avg_count, _fps,
|
||||
_start_freq, _end_freq,
|
||||
):
|
||||
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
||||
bytes_per_frame = _fft_size * _avg_count * 2
|
||||
frame_interval = 1.0 / _fps
|
||||
|
||||
try:
|
||||
while not stop_evt.is_set():
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
|
||||
frame_start = time.monotonic()
|
||||
|
||||
# Read raw I/Q bytes
|
||||
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
|
||||
|
||||
# Process FFT pipeline
|
||||
samples = cu8_to_complex(raw)
|
||||
power_db = compute_power_spectrum(
|
||||
samples,
|
||||
fft_size=_fft_size,
|
||||
avg_count=_avg_count,
|
||||
)
|
||||
quantized = quantize_to_uint8(power_db)
|
||||
frame = build_binary_frame(
|
||||
_start_freq, _end_freq, quantized,
|
||||
)
|
||||
|
||||
try:
|
||||
_send_q.put_nowait(frame)
|
||||
except queue.Full:
|
||||
# Drop frame if main loop can't keep up
|
||||
pass
|
||||
|
||||
# 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"FFT reader stopped: {e}")
|
||||
|
||||
reader_thread = threading.Thread(
|
||||
target=fft_reader,
|
||||
args=(
|
||||
iq_process, send_queue, stop_event,
|
||||
fft_size, avg_count, fps,
|
||||
start_freq, end_freq,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
reader_thread.start()
|
||||
|
||||
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_device = None
|
||||
stop_event.clear()
|
||||
ws.send(json.dumps({'status': 'stopped'}))
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"WebSocket waterfall closed: {e}")
|
||||
finally:
|
||||
# Cleanup
|
||||
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)
|
||||
# Complete WebSocket close handshake, then shut down the
|
||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||
# on top of the WebSocket stream (which browsers see as
|
||||
# "Invalid frame header").
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ws.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("WebSocket waterfall client disconnected")
|
||||
@@ -0,0 +1,626 @@
|
||||
"""Weather Satellite decoder routes.
|
||||
|
||||
Provides endpoints for capturing and decoding weather satellite images
|
||||
from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, send_file
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream
|
||||
from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation
|
||||
from utils.weather_sat import (
|
||||
get_weather_sat_decoder,
|
||||
is_weather_sat_available,
|
||||
CaptureProgress,
|
||||
WEATHER_SATELLITES,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.weather_sat')
|
||||
|
||||
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
|
||||
|
||||
# Queue for SSE progress streaming
|
||||
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
|
||||
def _progress_callback(progress: CaptureProgress) -> None:
|
||||
"""Callback to queue progress updates for SSE stream."""
|
||||
try:
|
||||
_weather_sat_queue.put_nowait(progress.to_dict())
|
||||
except queue.Full:
|
||||
try:
|
||||
_weather_sat_queue.get_nowait()
|
||||
_weather_sat_queue.put_nowait(progress.to_dict())
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
@weather_sat_bp.route('/status')
|
||||
def get_status():
|
||||
"""Get weather satellite decoder status.
|
||||
|
||||
Returns:
|
||||
JSON with decoder availability and current status.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
return jsonify(decoder.get_status())
|
||||
|
||||
|
||||
@weather_sat_bp.route('/satellites')
|
||||
def list_satellites():
|
||||
"""Get list of supported weather satellites with frequencies.
|
||||
|
||||
Returns:
|
||||
JSON with satellite definitions.
|
||||
"""
|
||||
satellites = []
|
||||
for key, info in WEATHER_SATELLITES.items():
|
||||
satellites.append({
|
||||
'key': key,
|
||||
'name': info['name'],
|
||||
'frequency': info['frequency'],
|
||||
'mode': info['mode'],
|
||||
'description': info['description'],
|
||||
'active': info['active'],
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'satellites': satellites,
|
||||
})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/start', methods=['POST'])
|
||||
def start_capture():
|
||||
"""Start weather satellite capture and decode.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"satellite": "NOAA-18", // Required: satellite key
|
||||
"device": 0, // RTL-SDR device index (default: 0)
|
||||
"gain": 40.0, // SDR gain in dB (default: 40)
|
||||
"bias_t": false // Enable bias-T for LNA (default: false)
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON with start status.
|
||||
"""
|
||||
if not is_weather_sat_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'SatDump not installed. Build from source: https://github.com/SatDump/SatDump'
|
||||
}), 400
|
||||
|
||||
decoder = get_weather_sat_decoder()
|
||||
|
||||
if decoder.is_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'satellite': decoder.current_satellite,
|
||||
'frequency': decoder.current_frequency,
|
||||
})
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# Validate satellite
|
||||
satellite = data.get('satellite')
|
||||
if not satellite or satellite not in WEATHER_SATELLITES:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}'
|
||||
}), 400
|
||||
|
||||
# Validate device index and gain
|
||||
try:
|
||||
device_index = validate_device_index(data.get('device', 0))
|
||||
gain = validate_gain(data.get('gain', 40.0))
|
||||
except ValueError as e:
|
||||
logger.warning('Invalid parameter in start_capture: %s', e)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid parameter value'
|
||||
}), 400
|
||||
|
||||
bias_t = bool(data.get('bias_t', False))
|
||||
|
||||
# Claim SDR device
|
||||
try:
|
||||
import app as app_module
|
||||
error = app_module.claim_sdr_device(device_index, 'weather_sat')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Clear queue
|
||||
while not _weather_sat_queue.empty():
|
||||
try:
|
||||
_weather_sat_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Set callback and on-complete handler for SDR release
|
||||
decoder.set_callback(_progress_callback)
|
||||
|
||||
def _release_device():
|
||||
try:
|
||||
import app as app_module
|
||||
app_module.release_sdr_device(device_index)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
decoder.set_on_complete(_release_device)
|
||||
|
||||
success = decoder.start(
|
||||
satellite=satellite,
|
||||
device_index=device_index,
|
||||
gain=gain,
|
||||
bias_t=bias_t,
|
||||
)
|
||||
|
||||
if success:
|
||||
sat_info = WEATHER_SATELLITES[satellite]
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'satellite': satellite,
|
||||
'frequency': sat_info['frequency'],
|
||||
'mode': sat_info['mode'],
|
||||
'device': device_index,
|
||||
})
|
||||
else:
|
||||
# Release device on failure
|
||||
_release_device()
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start capture'
|
||||
}), 500
|
||||
|
||||
|
||||
@weather_sat_bp.route('/test-decode', methods=['POST'])
|
||||
def test_decode():
|
||||
"""Start weather satellite decode from a pre-recorded file.
|
||||
|
||||
No SDR hardware is required — decodes an IQ baseband or WAV file
|
||||
using SatDump offline mode.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"satellite": "NOAA-18", // Required: satellite key
|
||||
"input_file": "/path/to/file", // Required: server-side file path
|
||||
"sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON with start status.
|
||||
"""
|
||||
if not is_weather_sat_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'SatDump not installed. Build from source: https://github.com/SatDump/SatDump'
|
||||
}), 400
|
||||
|
||||
decoder = get_weather_sat_decoder()
|
||||
|
||||
if decoder.is_running:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'satellite': decoder.current_satellite,
|
||||
'frequency': decoder.current_frequency,
|
||||
})
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# Validate satellite
|
||||
satellite = data.get('satellite')
|
||||
if not satellite or satellite not in WEATHER_SATELLITES:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}'
|
||||
}), 400
|
||||
|
||||
# Validate input file
|
||||
input_file = data.get('input_file')
|
||||
if not input_file:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'input_file is required'
|
||||
}), 400
|
||||
|
||||
from pathlib import Path
|
||||
input_path = Path(input_file)
|
||||
|
||||
# Security: restrict to data directory (anchored to app root, not CWD)
|
||||
allowed_base = Path(__file__).resolve().parent.parent / 'data'
|
||||
try:
|
||||
resolved = input_path.resolve()
|
||||
if not resolved.is_relative_to(allowed_base):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'input_file must be under the data/ directory'
|
||||
}), 403
|
||||
except (OSError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid file path'
|
||||
}), 400
|
||||
|
||||
if not input_path.is_file():
|
||||
logger.warning("Test-decode file not found")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'File not found'
|
||||
}), 404
|
||||
|
||||
# Validate sample rate
|
||||
sample_rate = data.get('sample_rate', 1000000)
|
||||
try:
|
||||
sample_rate = int(sample_rate)
|
||||
if sample_rate < 1000 or sample_rate > 20000000:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid sample_rate (1000-20000000)'
|
||||
}), 400
|
||||
|
||||
# Clear queue
|
||||
while not _weather_sat_queue.empty():
|
||||
try:
|
||||
_weather_sat_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Set callback — no on_complete needed (no SDR to release)
|
||||
decoder.set_callback(_progress_callback)
|
||||
decoder.set_on_complete(None)
|
||||
|
||||
success = decoder.start_from_file(
|
||||
satellite=satellite,
|
||||
input_file=input_file,
|
||||
sample_rate=sample_rate,
|
||||
)
|
||||
|
||||
if success:
|
||||
sat_info = WEATHER_SATELLITES[satellite]
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'satellite': satellite,
|
||||
'frequency': sat_info['frequency'],
|
||||
'mode': sat_info['mode'],
|
||||
'source': 'file',
|
||||
'input_file': str(input_file),
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start file decode'
|
||||
}), 500
|
||||
|
||||
|
||||
@weather_sat_bp.route('/stop', methods=['POST'])
|
||||
def stop_capture():
|
||||
"""Stop weather satellite capture.
|
||||
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
device_index = decoder.device_index
|
||||
|
||||
decoder.stop()
|
||||
|
||||
# Release SDR device
|
||||
try:
|
||||
import app as app_module
|
||||
app_module.release_sdr_device(device_index)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images')
|
||||
def list_images():
|
||||
"""Get list of decoded weather satellite images.
|
||||
|
||||
Query parameters:
|
||||
limit: Maximum number of images (default: all)
|
||||
satellite: Filter by satellite key (optional)
|
||||
|
||||
Returns:
|
||||
JSON with list of decoded images.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
images = decoder.get_images()
|
||||
|
||||
# Filter by satellite if specified
|
||||
satellite_filter = request.args.get('satellite')
|
||||
if satellite_filter:
|
||||
images = [img for img in images if img.satellite == satellite_filter]
|
||||
|
||||
# Apply limit
|
||||
limit = request.args.get('limit', type=int)
|
||||
if limit and limit > 0:
|
||||
images = images[-limit:]
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'images': [img.to_dict() for img in images],
|
||||
'count': len(images),
|
||||
})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images/<filename>')
|
||||
def get_image(filename: str):
|
||||
"""Serve a decoded weather satellite image file.
|
||||
|
||||
Args:
|
||||
filename: Image filename
|
||||
|
||||
Returns:
|
||||
Image file or 404.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
|
||||
# Security: only allow safe filenames
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')):
|
||||
return jsonify({'status': 'error', 'message': 'Only PNG/JPG files supported'}), 400
|
||||
|
||||
image_path = decoder._output_dir / filename
|
||||
|
||||
if not image_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
|
||||
return send_file(image_path, mimetype=mimetype)
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images/<filename>', methods=['DELETE'])
|
||||
def delete_image(filename: str):
|
||||
"""Delete a decoded image.
|
||||
|
||||
Args:
|
||||
filename: Image filename
|
||||
|
||||
Returns:
|
||||
JSON confirmation.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
|
||||
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||
|
||||
if decoder.delete_image(filename):
|
||||
return jsonify({'status': 'deleted', 'filename': filename})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||
|
||||
|
||||
@weather_sat_bp.route('/images', methods=['DELETE'])
|
||||
def delete_all_images():
|
||||
"""Delete all decoded weather satellite images.
|
||||
|
||||
Returns:
|
||||
JSON with count of deleted images.
|
||||
"""
|
||||
decoder = get_weather_sat_decoder()
|
||||
count = decoder.delete_all_images()
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/stream')
|
||||
def stream_progress():
|
||||
"""SSE stream of capture/decode progress.
|
||||
|
||||
Returns:
|
||||
SSE stream (text/event-stream)
|
||||
"""
|
||||
response = Response(sse_stream(_weather_sat_queue), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@weather_sat_bp.route('/passes')
|
||||
def get_passes():
|
||||
"""Get upcoming weather satellite passes for observer location.
|
||||
|
||||
Query parameters:
|
||||
latitude: Observer latitude (required)
|
||||
longitude: Observer longitude (required)
|
||||
hours: Hours to predict ahead (default: 24, max: 72)
|
||||
min_elevation: Minimum elevation in degrees (default: 15)
|
||||
trajectory: Include az/el trajectory points (default: false)
|
||||
ground_track: Include lat/lon ground track points (default: false)
|
||||
|
||||
Returns:
|
||||
JSON with upcoming passes for all weather satellites.
|
||||
"""
|
||||
include_trajectory = request.args.get('trajectory', 'false').lower() in ('true', '1')
|
||||
include_ground_track = request.args.get('ground_track', 'false').lower() in ('true', '1')
|
||||
|
||||
raw_lat = request.args.get('latitude')
|
||||
raw_lon = request.args.get('longitude')
|
||||
|
||||
if raw_lat is None or raw_lon is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'latitude and longitude parameters required'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
lat = validate_latitude(raw_lat)
|
||||
lon = validate_longitude(raw_lon)
|
||||
except ValueError as e:
|
||||
logger.warning('Invalid coordinates in get_passes: %s', e)
|
||||
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
||||
|
||||
hours = max(1, min(request.args.get('hours', 24, type=int), 72))
|
||||
min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90))
|
||||
|
||||
try:
|
||||
from utils.weather_sat_predict import predict_passes
|
||||
|
||||
all_passes = predict_passes(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
hours=hours,
|
||||
min_elevation=min_elevation,
|
||||
include_trajectory=include_trajectory,
|
||||
include_ground_track=include_ground_track,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'passes': all_passes,
|
||||
'count': len(all_passes),
|
||||
'observer': {'latitude': lat, 'longitude': lon},
|
||||
'prediction_hours': hours,
|
||||
'min_elevation': min_elevation,
|
||||
})
|
||||
|
||||
except ImportError:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'skyfield library not installed'
|
||||
}), 503
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error predicting passes: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Pass prediction failed'
|
||||
}), 500
|
||||
|
||||
|
||||
# ========================
|
||||
# Auto-Scheduler Endpoints
|
||||
# ========================
|
||||
|
||||
|
||||
def _scheduler_event_callback(event: dict) -> None:
|
||||
"""Forward scheduler events to the SSE queue."""
|
||||
try:
|
||||
_weather_sat_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
try:
|
||||
_weather_sat_queue.get_nowait()
|
||||
_weather_sat_queue.put_nowait(event)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
@weather_sat_bp.route('/schedule/enable', methods=['POST'])
|
||||
def enable_schedule():
|
||||
"""Enable auto-scheduling of weather satellite captures.
|
||||
|
||||
JSON body:
|
||||
{
|
||||
"latitude": 51.5, // Required
|
||||
"longitude": -0.1, // Required
|
||||
"min_elevation": 15, // Minimum pass elevation (default: 15)
|
||||
"device": 0, // RTL-SDR device index (default: 0)
|
||||
"gain": 40.0, // SDR gain (default: 40)
|
||||
"bias_t": false // Enable bias-T (default: false)
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON with scheduler status.
|
||||
"""
|
||||
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
if data.get('latitude') is None or data.get('longitude') is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'latitude and longitude required'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
lat = validate_latitude(data.get('latitude'))
|
||||
lon = validate_longitude(data.get('longitude'))
|
||||
min_elev = validate_elevation(data.get('min_elevation', 15))
|
||||
device = validate_device_index(data.get('device', 0))
|
||||
gain_val = validate_gain(data.get('gain', 40.0))
|
||||
except ValueError as e:
|
||||
logger.warning('Invalid parameter in enable_schedule: %s', e)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid parameter value'
|
||||
}), 400
|
||||
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||
|
||||
result = scheduler.enable(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
min_elevation=min_elev,
|
||||
device=device,
|
||||
gain=gain_val,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
|
||||
return jsonify({'status': 'ok', **result})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/schedule/disable', methods=['POST'])
|
||||
def disable_schedule():
|
||||
"""Disable auto-scheduling."""
|
||||
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
result = scheduler.disable()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@weather_sat_bp.route('/schedule/status')
|
||||
def schedule_status():
|
||||
"""Get current scheduler state."""
|
||||
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
return jsonify(scheduler.get_status())
|
||||
|
||||
|
||||
@weather_sat_bp.route('/schedule/passes')
|
||||
def schedule_passes():
|
||||
"""List scheduled passes."""
|
||||
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
passes = scheduler.get_passes()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'passes': passes,
|
||||
'count': len(passes),
|
||||
})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/schedule/skip/<pass_id>', methods=['POST'])
|
||||
def skip_pass(pass_id: str):
|
||||
"""Skip a scheduled pass."""
|
||||
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
||||
|
||||
if not pass_id.replace('_', '').replace('-', '').isalnum():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid pass ID'}), 400
|
||||
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
if scheduler.skip_pass(pass_id):
|
||||
return jsonify({'status': 'skipped', 'pass_id': pass_id})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Pass not found or already processed'}), 404
|
||||
@@ -0,0 +1,504 @@
|
||||
"""HF/Shortwave WebSDR Integration - KiwiSDR network access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import queue
|
||||
import re
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from flask import Blueprint, Flask, jsonify, request, Response
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
|
||||
from utils.kiwisdr import KiwiSDRClient, KIWI_SAMPLE_RATE, VALID_MODES, parse_host_port
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.websdr')
|
||||
|
||||
websdr_bp = Blueprint('websdr', __name__, url_prefix='/websdr')
|
||||
|
||||
# ============================================
|
||||
# RECEIVER CACHE
|
||||
# ============================================
|
||||
|
||||
_receiver_cache: list[dict] = []
|
||||
_cache_lock = threading.Lock()
|
||||
_cache_timestamp: float = 0
|
||||
CACHE_TTL = 3600 # 1 hour
|
||||
|
||||
|
||||
def _parse_gps_coord(coord_str: str) -> Optional[float]:
|
||||
"""Parse a GPS coordinate string like '51.5074' or '(-33.87)' into a float."""
|
||||
if not coord_str:
|
||||
return None
|
||||
# Remove parentheses and whitespace
|
||||
cleaned = coord_str.strip().strip('()').strip()
|
||||
try:
|
||||
return float(cleaned)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Calculate distance in km between two GPS coordinates."""
|
||||
R = 6371 # Earth radius in km
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = (math.sin(dlat / 2) ** 2 +
|
||||
math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) *
|
||||
math.sin(dlon / 2) ** 2)
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
return R * c
|
||||
|
||||
|
||||
KIWI_DATA_URLS = [
|
||||
'https://rx.skywavelinux.com/kiwisdr_com.js',
|
||||
'http://rx.linkfanel.net/kiwisdr_com.js',
|
||||
]
|
||||
|
||||
|
||||
def _fetch_kiwi_receivers() -> list[dict]:
|
||||
"""Fetch the KiwiSDR receiver list from the public directory."""
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
receivers = []
|
||||
raw = None
|
||||
|
||||
# Try each data source until one works
|
||||
for data_url in KIWI_DATA_URLS:
|
||||
try:
|
||||
req = urllib.request.Request(data_url, headers={
|
||||
'User-Agent': 'INTERCEPT-SIGINT/1.0',
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
raw = resp.read().decode('utf-8', errors='replace')
|
||||
if raw and len(raw) > 100:
|
||||
logger.info(f"Fetched KiwiSDR data from {data_url}")
|
||||
break
|
||||
raw = None
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch from {data_url}: {e}")
|
||||
continue
|
||||
|
||||
if not raw:
|
||||
logger.error("All KiwiSDR data sources failed")
|
||||
return receivers
|
||||
|
||||
# The JS file contains: var kiwisdr_com = [ {...}, {...}, ... ];
|
||||
# Extract the JSON array
|
||||
match = re.search(r'var\s+kiwisdr_com\s*=\s*(\[.*\])\s*;?', raw, re.DOTALL)
|
||||
if not match:
|
||||
# Try bare array
|
||||
match = re.search(r'(\[\s*\{.*\}\s*\])', raw, re.DOTALL)
|
||||
if not match:
|
||||
logger.warning("Could not find receiver array in KiwiSDR data")
|
||||
return receivers
|
||||
|
||||
arr_str = match.group(1)
|
||||
|
||||
# Parse JSON
|
||||
try:
|
||||
raw_list = json.loads(arr_str)
|
||||
except json.JSONDecodeError:
|
||||
# Fix common JS → JSON issues (trailing commas)
|
||||
fixed = re.sub(r',\s*}', '}', arr_str)
|
||||
fixed = re.sub(r',\s*]', ']', fixed)
|
||||
try:
|
||||
raw_list = json.loads(fixed)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Failed to parse KiwiSDR JSON")
|
||||
return receivers
|
||||
|
||||
for entry in raw_list:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
# Skip offline receivers
|
||||
if entry.get('offline') == 'yes' or entry.get('status') != 'active':
|
||||
continue
|
||||
|
||||
name = entry.get('name', 'Unknown')
|
||||
url = entry.get('url', '')
|
||||
gps = entry.get('gps', '')
|
||||
antenna = entry.get('antenna', '')
|
||||
location = entry.get('loc', '')
|
||||
|
||||
# Parse users (strings in actual data)
|
||||
try:
|
||||
users = int(entry.get('users', 0))
|
||||
except (ValueError, TypeError):
|
||||
users = 0
|
||||
try:
|
||||
users_max = int(entry.get('users_max', 4))
|
||||
except (ValueError, TypeError):
|
||||
users_max = 4
|
||||
|
||||
# Parse bands field: "0-30000000" (Hz) → freq_lo/freq_hi in kHz
|
||||
bands_str = entry.get('bands', '0-30000000')
|
||||
freq_lo = 0
|
||||
freq_hi = 30000
|
||||
if bands_str and '-' in str(bands_str):
|
||||
try:
|
||||
parts = str(bands_str).split('-')
|
||||
freq_lo = int(parts[0]) / 1000 # Hz to kHz
|
||||
freq_hi = int(parts[1]) / 1000 # Hz to kHz
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Parse GPS: "(51.317266, -2.950479)" format
|
||||
lat, lon = None, None
|
||||
if gps:
|
||||
parts = str(gps).replace('(', '').replace(')', '').split(',')
|
||||
if len(parts) >= 2:
|
||||
lat = _parse_gps_coord(parts[0])
|
||||
lon = _parse_gps_coord(parts[1])
|
||||
|
||||
if not url:
|
||||
continue
|
||||
|
||||
# Ensure URL has protocol
|
||||
if not url.startswith('http'):
|
||||
url = 'http://' + url
|
||||
|
||||
receivers.append({
|
||||
'name': name,
|
||||
'url': url.rstrip('/'),
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'location': location,
|
||||
'users': users,
|
||||
'users_max': users_max,
|
||||
'antenna': antenna,
|
||||
'bands': bands_str,
|
||||
'freq_lo': freq_lo,
|
||||
'freq_hi': freq_hi,
|
||||
'available': users < users_max,
|
||||
})
|
||||
|
||||
return receivers
|
||||
|
||||
|
||||
def get_receivers(force_refresh: bool = False) -> list[dict]:
|
||||
"""Get cached receiver list, refreshing if stale."""
|
||||
global _receiver_cache, _cache_timestamp
|
||||
|
||||
with _cache_lock:
|
||||
now = time.time()
|
||||
if force_refresh or not _receiver_cache or (now - _cache_timestamp) > CACHE_TTL:
|
||||
logger.info("Refreshing KiwiSDR receiver list...")
|
||||
_receiver_cache = _fetch_kiwi_receivers()
|
||||
_cache_timestamp = now
|
||||
logger.info(f"Loaded {len(_receiver_cache)} KiwiSDR receivers")
|
||||
|
||||
return _receiver_cache
|
||||
|
||||
|
||||
# ============================================
|
||||
# API ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@websdr_bp.route('/receivers')
|
||||
def list_receivers() -> Response:
|
||||
"""List KiwiSDR receivers, with optional filters."""
|
||||
freq_khz = request.args.get('freq_khz', type=float)
|
||||
available = request.args.get('available', type=str)
|
||||
refresh = request.args.get('refresh', type=str)
|
||||
|
||||
receivers = get_receivers(force_refresh=(refresh == 'true'))
|
||||
|
||||
filtered = receivers
|
||||
if available == 'true':
|
||||
filtered = [r for r in filtered if r.get('available', True)]
|
||||
|
||||
if freq_khz is not None:
|
||||
filtered = [
|
||||
r for r in filtered
|
||||
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000)
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'receivers': filtered[:100],
|
||||
'total': len(filtered),
|
||||
'cached_total': len(receivers),
|
||||
})
|
||||
|
||||
|
||||
@websdr_bp.route('/receivers/nearest')
|
||||
def nearest_receivers() -> Response:
|
||||
"""Find receivers nearest to a given location."""
|
||||
lat = request.args.get('lat', type=float)
|
||||
lon = request.args.get('lon', type=float)
|
||||
freq_khz = request.args.get('freq_khz', type=float)
|
||||
|
||||
if lat is None or lon is None:
|
||||
return jsonify({'status': 'error', 'message': 'lat and lon are required'}), 400
|
||||
|
||||
receivers = get_receivers()
|
||||
|
||||
# Filter by frequency if specified
|
||||
if freq_khz is not None:
|
||||
receivers = [
|
||||
r for r in receivers
|
||||
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000)
|
||||
]
|
||||
|
||||
# Calculate distances and sort
|
||||
with_distance = []
|
||||
for r in receivers:
|
||||
if r.get('lat') is not None and r.get('lon') is not None:
|
||||
dist = _haversine(lat, lon, r['lat'], r['lon'])
|
||||
entry = dict(r)
|
||||
entry['distance_km'] = round(dist, 1)
|
||||
with_distance.append(entry)
|
||||
|
||||
with_distance.sort(key=lambda x: x['distance_km'])
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'receivers': with_distance[:10],
|
||||
})
|
||||
|
||||
|
||||
@websdr_bp.route('/spy-station/<station_id>/receivers')
|
||||
def spy_station_receivers(station_id: str) -> Response:
|
||||
"""Find receivers that can tune to a spy station's frequency."""
|
||||
try:
|
||||
from routes.spy_stations import STATIONS
|
||||
except ImportError:
|
||||
return jsonify({'status': 'error', 'message': 'Spy stations module not available'}), 503
|
||||
|
||||
# Find the station
|
||||
station = None
|
||||
for s in STATIONS:
|
||||
if s.get('id') == station_id:
|
||||
station = s
|
||||
break
|
||||
|
||||
if not station:
|
||||
return jsonify({'status': 'error', 'message': 'Station not found'}), 404
|
||||
|
||||
# Get primary frequency
|
||||
freq_khz = None
|
||||
for f in station.get('frequencies', []):
|
||||
if f.get('primary'):
|
||||
freq_khz = f.get('freq_khz')
|
||||
break
|
||||
if freq_khz is None and station.get('frequencies'):
|
||||
freq_khz = station['frequencies'][0].get('freq_khz')
|
||||
|
||||
if freq_khz is None:
|
||||
return jsonify({'status': 'error', 'message': 'No frequency found for station'}), 404
|
||||
|
||||
receivers = get_receivers()
|
||||
|
||||
# Filter receivers that cover this frequency and are available
|
||||
matching = [
|
||||
r for r in receivers
|
||||
if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) and r.get('available', True)
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'station': {
|
||||
'id': station['id'],
|
||||
'name': station.get('name', ''),
|
||||
'nickname': station.get('nickname', ''),
|
||||
'freq_khz': freq_khz,
|
||||
'mode': station.get('mode', 'USB'),
|
||||
},
|
||||
'receivers': matching[:20],
|
||||
'total': len(matching),
|
||||
})
|
||||
|
||||
|
||||
@websdr_bp.route('/status')
|
||||
def websdr_status() -> Response:
|
||||
"""Get WebSDR connection and cache status."""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'cached_receivers': len(_receiver_cache),
|
||||
'cache_age_seconds': round(time.time() - _cache_timestamp, 0) if _cache_timestamp > 0 else None,
|
||||
'cache_ttl': CACHE_TTL,
|
||||
'audio_connected': _kiwi_client is not None and _kiwi_client.connected if _kiwi_client else False,
|
||||
})
|
||||
|
||||
|
||||
# ============================================
|
||||
# KIWISDR AUDIO PROXY
|
||||
# ============================================
|
||||
|
||||
_kiwi_client: Optional[KiwiSDRClient] = None
|
||||
_kiwi_lock = threading.Lock()
|
||||
_kiwi_audio_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||
|
||||
|
||||
def _disconnect_kiwi() -> None:
|
||||
"""Disconnect active KiwiSDR client."""
|
||||
global _kiwi_client
|
||||
with _kiwi_lock:
|
||||
if _kiwi_client:
|
||||
_kiwi_client.disconnect()
|
||||
_kiwi_client = None
|
||||
# Drain audio queue
|
||||
while not _kiwi_audio_queue.empty():
|
||||
try:
|
||||
_kiwi_audio_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
|
||||
def _handle_kiwi_command(ws, cmd: str, data: dict) -> None:
|
||||
"""Handle a command from the browser client."""
|
||||
global _kiwi_client
|
||||
|
||||
if cmd == 'connect':
|
||||
receiver_url = data.get('url', '')
|
||||
host = data.get('host', '')
|
||||
port = int(data.get('port', 8073))
|
||||
freq_khz = float(data.get('freq_khz', 7000))
|
||||
mode = data.get('mode', 'am').lower()
|
||||
password = data.get('password', '')
|
||||
|
||||
# Parse host/port from URL if provided
|
||||
if receiver_url and not host:
|
||||
host, port = parse_host_port(receiver_url)
|
||||
|
||||
if mode not in VALID_MODES:
|
||||
ws.send(json.dumps({'type': 'error', 'message': f'Invalid mode: {mode}'}))
|
||||
return
|
||||
|
||||
if not host or ';' in host or '&' in host or '|' in host:
|
||||
ws.send(json.dumps({'type': 'error', 'message': 'Invalid host'}))
|
||||
return
|
||||
|
||||
_disconnect_kiwi()
|
||||
|
||||
def on_audio(pcm_bytes, smeter):
|
||||
# Package: 2 bytes smeter (big-endian int16) + PCM data
|
||||
header = struct.pack('>h', smeter)
|
||||
try:
|
||||
_kiwi_audio_queue.put_nowait(header + pcm_bytes)
|
||||
except queue.Full:
|
||||
try:
|
||||
_kiwi_audio_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
_kiwi_audio_queue.put_nowait(header + pcm_bytes)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
def on_error(msg):
|
||||
try:
|
||||
ws.send(json.dumps({'type': 'error', 'message': msg}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_disconnect():
|
||||
try:
|
||||
ws.send(json.dumps({'type': 'disconnected'}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with _kiwi_lock:
|
||||
_kiwi_client = KiwiSDRClient(
|
||||
host=host, port=port,
|
||||
on_audio=on_audio,
|
||||
on_error=on_error,
|
||||
on_disconnect=on_disconnect,
|
||||
password=password,
|
||||
)
|
||||
success = _kiwi_client.connect(freq_khz, mode)
|
||||
|
||||
if success:
|
||||
ws.send(json.dumps({
|
||||
'type': 'connected',
|
||||
'host': host,
|
||||
'port': port,
|
||||
'freq_khz': freq_khz,
|
||||
'mode': mode,
|
||||
'sample_rate': KIWI_SAMPLE_RATE,
|
||||
}))
|
||||
else:
|
||||
ws.send(json.dumps({'type': 'error', 'message': 'Connection to KiwiSDR failed'}))
|
||||
_disconnect_kiwi()
|
||||
|
||||
elif cmd == 'tune':
|
||||
freq_khz = float(data.get('freq_khz', 0))
|
||||
mode = data.get('mode', '').lower() or None
|
||||
|
||||
with _kiwi_lock:
|
||||
if _kiwi_client and _kiwi_client.connected:
|
||||
success = _kiwi_client.tune(
|
||||
freq_khz,
|
||||
mode or _kiwi_client.mode
|
||||
)
|
||||
if success:
|
||||
ws.send(json.dumps({
|
||||
'type': 'tuned',
|
||||
'freq_khz': freq_khz,
|
||||
'mode': mode or _kiwi_client.mode,
|
||||
}))
|
||||
else:
|
||||
ws.send(json.dumps({'type': 'error', 'message': 'Retune failed'}))
|
||||
else:
|
||||
ws.send(json.dumps({'type': 'error', 'message': 'Not connected'}))
|
||||
|
||||
elif cmd == 'disconnect':
|
||||
_disconnect_kiwi()
|
||||
ws.send(json.dumps({'type': 'disconnected'}))
|
||||
|
||||
|
||||
def init_websdr_audio(app: Flask) -> None:
|
||||
"""Initialize WebSocket audio proxy for KiwiSDR. Called from app.py."""
|
||||
if not WEBSOCKET_AVAILABLE:
|
||||
logger.warning("flask-sock not installed, KiwiSDR audio proxy disabled")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/kiwi-audio')
|
||||
def kiwi_audio_stream(ws):
|
||||
"""WebSocket endpoint: proxy audio between browser and KiwiSDR."""
|
||||
logger.info("KiwiSDR audio client connected")
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Check for commands from browser
|
||||
try:
|
||||
msg = ws.receive(timeout=0.005)
|
||||
if msg:
|
||||
data = json.loads(msg)
|
||||
cmd = data.get('cmd', '')
|
||||
_handle_kiwi_command(ws, cmd, data)
|
||||
except TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
if 'closed' in str(e).lower():
|
||||
break
|
||||
if 'timed out' not in str(e).lower():
|
||||
logger.error(f"KiwiSDR WS receive error: {e}")
|
||||
|
||||
# Forward audio from KiwiSDR to browser
|
||||
try:
|
||||
audio_data = _kiwi_audio_queue.get_nowait()
|
||||
ws.send(audio_data)
|
||||
except queue.Empty:
|
||||
time.sleep(0.005)
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"KiwiSDR WS closed: {e}")
|
||||
finally:
|
||||
_disconnect_kiwi()
|
||||
logger.info("KiwiSDR audio client disconnected")
|
||||
@@ -0,0 +1,538 @@
|
||||
"""
|
||||
WiFi v2 API routes.
|
||||
|
||||
New unified WiFi scanning API with Quick Scan and Deep Scan modes,
|
||||
channel analysis, hidden SSID correlation, and SSE streaming.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.wifi import (
|
||||
get_wifi_scanner,
|
||||
analyze_channels,
|
||||
get_hidden_correlator,
|
||||
SCAN_MODE_QUICK,
|
||||
SCAN_MODE_DEEP,
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.validation import validate_wifi_channel
|
||||
from utils.event_pipeline import process_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
wifi_v2_bp = Blueprint('wifi_v2', __name__, url_prefix='/wifi/v2')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Capabilities
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/capabilities', methods=['GET'])
|
||||
def get_capabilities():
|
||||
"""
|
||||
Get WiFi scanning capabilities.
|
||||
|
||||
Returns available tools, interfaces, and scan mode support.
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
caps = scanner.check_capabilities()
|
||||
return jsonify(caps.to_dict())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Quick Scan
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/scan/quick', methods=['POST'])
|
||||
def quick_scan():
|
||||
"""
|
||||
Perform a quick one-shot WiFi scan.
|
||||
|
||||
Uses system tools (nmcli, iw, iwlist, airport) without monitor mode.
|
||||
|
||||
Request body:
|
||||
interface: Optional interface name
|
||||
timeout: Optional scan timeout in seconds (default 15)
|
||||
|
||||
Returns:
|
||||
WiFiScanResult with discovered networks and channel analysis.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
interface = data.get('interface')
|
||||
timeout = float(data.get('timeout', 15))
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
result = scanner.quick_scan(interface=interface, timeout=timeout)
|
||||
|
||||
return jsonify(result.to_dict())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Deep Scan (Monitor Mode)
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/scan/start', methods=['POST'])
|
||||
def start_deep_scan():
|
||||
"""
|
||||
Start a deep scan using airodump-ng.
|
||||
|
||||
Requires monitor mode interface and root privileges.
|
||||
|
||||
Request body:
|
||||
interface: Monitor mode interface (e.g., 'wlan0mon')
|
||||
band: Band to scan ('2.4', '5', 'all')
|
||||
channel: Optional specific channel to monitor
|
||||
channels: Optional list or comma-separated channels to monitor
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
interface = data.get('interface')
|
||||
band = data.get('band', 'all')
|
||||
channel = data.get('channel')
|
||||
channels = data.get('channels')
|
||||
|
||||
channel_list = None
|
||||
if channels:
|
||||
if isinstance(channels, str):
|
||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
||||
elif isinstance(channels, (list, tuple, set)):
|
||||
channel_list = list(channels)
|
||||
else:
|
||||
channel_list = [channels]
|
||||
try:
|
||||
channel_list = [validate_wifi_channel(c) for c in channel_list]
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'error': 'Invalid channels'}), 400
|
||||
|
||||
if channel:
|
||||
try:
|
||||
channel = validate_wifi_channel(channel)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid channel'}), 400
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
success = scanner.start_deep_scan(
|
||||
interface=interface,
|
||||
band=band,
|
||||
channel=channel,
|
||||
channels=channel_list,
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'mode': SCAN_MODE_DEEP,
|
||||
'interface': interface or scanner._capabilities.monitor_interface,
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error': scanner._status.error,
|
||||
}), 400
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/scan/stop', methods=['POST'])
|
||||
def stop_deep_scan():
|
||||
"""Stop the deep scan."""
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.stop_deep_scan()
|
||||
|
||||
return jsonify({
|
||||
'status': 'stopped',
|
||||
})
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/scan/status', methods=['GET'])
|
||||
def get_scan_status():
|
||||
"""Get current scan status."""
|
||||
scanner = get_wifi_scanner()
|
||||
status = scanner.get_status()
|
||||
return jsonify(status.to_dict())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Data Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/networks', methods=['GET'])
|
||||
def get_networks():
|
||||
"""
|
||||
Get all discovered networks.
|
||||
|
||||
Query params:
|
||||
band: Filter by band ('2.4GHz', '5GHz', '6GHz')
|
||||
security: Filter by security type ('Open', 'WEP', 'WPA', 'WPA2', 'WPA3')
|
||||
hidden: Filter hidden networks only (true/false)
|
||||
min_rssi: Minimum RSSI threshold
|
||||
sort: Sort field ('rssi', 'channel', 'essid', 'last_seen')
|
||||
order: Sort order ('asc', 'desc')
|
||||
format: Response format ('full', 'summary')
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
networks = scanner.access_points
|
||||
|
||||
# Apply filters
|
||||
band = request.args.get('band')
|
||||
if band:
|
||||
networks = [n for n in networks if n.band == band]
|
||||
|
||||
security = request.args.get('security')
|
||||
if security:
|
||||
networks = [n for n in networks if n.security == security]
|
||||
|
||||
hidden = request.args.get('hidden')
|
||||
if hidden == 'true':
|
||||
networks = [n for n in networks if n.is_hidden]
|
||||
elif hidden == 'false':
|
||||
networks = [n for n in networks if not n.is_hidden]
|
||||
|
||||
min_rssi = request.args.get('min_rssi')
|
||||
if min_rssi:
|
||||
try:
|
||||
min_rssi = int(min_rssi)
|
||||
networks = [n for n in networks if n.rssi_current and n.rssi_current >= min_rssi]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Apply sorting
|
||||
sort_field = request.args.get('sort', 'rssi')
|
||||
order = request.args.get('order', 'desc')
|
||||
reverse = order == 'desc'
|
||||
|
||||
sort_key_map = {
|
||||
'rssi': lambda n: n.rssi_current or -100,
|
||||
'channel': lambda n: n.channel or 0,
|
||||
'essid': lambda n: (n.essid or '').lower(),
|
||||
'last_seen': lambda n: n.last_seen,
|
||||
'clients': lambda n: n.client_count,
|
||||
}
|
||||
|
||||
if sort_field in sort_key_map:
|
||||
networks.sort(key=sort_key_map[sort_field], reverse=reverse)
|
||||
|
||||
# Format output
|
||||
output_format = request.args.get('format', 'summary')
|
||||
if output_format == 'full':
|
||||
return jsonify([n.to_dict() for n in networks])
|
||||
else:
|
||||
return jsonify([n.to_summary_dict() for n in networks])
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/networks/<bssid>', methods=['GET'])
|
||||
def get_network(bssid):
|
||||
"""Get a specific network by BSSID."""
|
||||
scanner = get_wifi_scanner()
|
||||
network = scanner.get_network(bssid)
|
||||
|
||||
if network:
|
||||
return jsonify(network.to_dict())
|
||||
else:
|
||||
return jsonify({'error': 'Network not found'}), 404
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/clients', methods=['GET'])
|
||||
def get_clients():
|
||||
"""
|
||||
Get all discovered clients.
|
||||
|
||||
Query params:
|
||||
associated: Filter by association status (true/false)
|
||||
bssid: Filter by associated BSSID
|
||||
min_rssi: Minimum RSSI threshold
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
clients = scanner.clients
|
||||
|
||||
# Apply filters
|
||||
associated = request.args.get('associated')
|
||||
if associated == 'true':
|
||||
clients = [c for c in clients if c.is_associated]
|
||||
elif associated == 'false':
|
||||
clients = [c for c in clients if not c.is_associated]
|
||||
|
||||
bssid = request.args.get('bssid')
|
||||
if bssid:
|
||||
clients = [c for c in clients if c.associated_bssid == bssid.upper()]
|
||||
|
||||
min_rssi = request.args.get('min_rssi')
|
||||
if min_rssi:
|
||||
try:
|
||||
min_rssi = int(min_rssi)
|
||||
clients = [c for c in clients if c.rssi_current and c.rssi_current >= min_rssi]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return jsonify([c.to_dict() for c in clients])
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/clients/<mac>', methods=['GET'])
|
||||
def get_client(mac):
|
||||
"""Get a specific client by MAC address."""
|
||||
scanner = get_wifi_scanner()
|
||||
client = scanner.get_client(mac)
|
||||
|
||||
if client:
|
||||
return jsonify(client.to_dict())
|
||||
else:
|
||||
return jsonify({'error': 'Client not found'}), 404
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/probes', methods=['GET'])
|
||||
def get_probes():
|
||||
"""
|
||||
Get captured probe requests.
|
||||
|
||||
Query params:
|
||||
client_mac: Filter by client MAC
|
||||
ssid: Filter by probed SSID
|
||||
limit: Maximum number of results
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
probes = scanner.probe_requests
|
||||
|
||||
# Apply filters
|
||||
client_mac = request.args.get('client_mac')
|
||||
if client_mac:
|
||||
probes = [p for p in probes if p.client_mac == client_mac.upper()]
|
||||
|
||||
ssid = request.args.get('ssid')
|
||||
if ssid:
|
||||
probes = [p for p in probes if p.probed_ssid == ssid]
|
||||
|
||||
# Apply limit
|
||||
limit = request.args.get('limit')
|
||||
if limit:
|
||||
try:
|
||||
limit = int(limit)
|
||||
probes = probes[-limit:] # Most recent
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return jsonify([p.to_dict() for p in probes])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Channel Analysis
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/channels', methods=['GET'])
|
||||
def get_channel_stats():
|
||||
"""
|
||||
Get channel utilization statistics and recommendations.
|
||||
|
||||
Query params:
|
||||
include_dfs: Include DFS channels in recommendations (true/false)
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
include_dfs = request.args.get('include_dfs', 'false') == 'true'
|
||||
|
||||
stats, recommendations = analyze_channels(
|
||||
scanner.access_points,
|
||||
include_dfs=include_dfs,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'stats': [s.to_dict() for s in stats],
|
||||
'recommendations': [r.to_dict() for r in recommendations],
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Hidden SSID Correlation
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/hidden', methods=['GET'])
|
||||
def get_hidden_correlations():
|
||||
"""
|
||||
Get revealed hidden SSIDs from correlation.
|
||||
|
||||
Returns mapping of BSSID -> revealed SSID.
|
||||
"""
|
||||
correlator = get_hidden_correlator()
|
||||
return jsonify(correlator.get_all_revealed())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Baseline Management
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/baseline/set', methods=['POST'])
|
||||
def set_baseline():
|
||||
"""Mark current networks as baseline (known networks)."""
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.set_baseline()
|
||||
|
||||
return jsonify({
|
||||
'status': 'baseline_set',
|
||||
'network_count': len(scanner._baseline_networks),
|
||||
'set_at': datetime.now().isoformat(),
|
||||
})
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/baseline/clear', methods=['POST'])
|
||||
def clear_baseline():
|
||||
"""Clear the baseline."""
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.clear_baseline()
|
||||
|
||||
return jsonify({
|
||||
'status': 'baseline_cleared',
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SSE Streaming
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/stream', methods=['GET'])
|
||||
def event_stream():
|
||||
"""
|
||||
Server-Sent Events stream for real-time updates.
|
||||
|
||||
Events:
|
||||
- network_update: Network discovered/updated
|
||||
- client_update: Client discovered/updated
|
||||
- probe_request: Probe request detected
|
||||
- hidden_revealed: Hidden SSID revealed
|
||||
- scan_started, scan_stopped, scan_error
|
||||
- keepalive: Periodic keepalive
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
scanner = get_wifi_scanner()
|
||||
|
||||
for event in scanner.get_event_stream():
|
||||
try:
|
||||
process_event('wifi', event, event.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(event)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Data Management
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/clear', methods=['POST'])
|
||||
def clear_data():
|
||||
"""Clear all discovered data."""
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.clear_data()
|
||||
|
||||
return jsonify({
|
||||
'status': 'cleared',
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Export
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/export', methods=['GET'])
|
||||
def export_data():
|
||||
"""
|
||||
Export scan data.
|
||||
|
||||
Query params:
|
||||
format: 'json' or 'csv' (default: json)
|
||||
type: 'networks', 'clients', 'probes', 'all' (default: all)
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
export_format = request.args.get('format', 'json')
|
||||
export_type = request.args.get('type', 'all')
|
||||
|
||||
if export_format == 'csv':
|
||||
return _export_csv(scanner, export_type)
|
||||
else:
|
||||
return _export_json(scanner, export_type)
|
||||
|
||||
|
||||
def _export_json(scanner, export_type: str) -> Response:
|
||||
"""Export data as JSON."""
|
||||
data = {}
|
||||
|
||||
if export_type in ('networks', 'all'):
|
||||
data['networks'] = [n.to_dict() for n in scanner.access_points]
|
||||
|
||||
if export_type in ('clients', 'all'):
|
||||
data['clients'] = [c.to_dict() for c in scanner.clients]
|
||||
|
||||
if export_type in ('probes', 'all'):
|
||||
data['probes'] = [p.to_dict() for p in scanner.probe_requests]
|
||||
|
||||
data['exported_at'] = datetime.now().isoformat()
|
||||
data['network_count'] = len(scanner.access_points)
|
||||
data['client_count'] = len(scanner.clients)
|
||||
|
||||
response = Response(
|
||||
json.dumps(data, indent=2),
|
||||
mimetype='application/json',
|
||||
)
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=wifi_scan_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
|
||||
return response
|
||||
|
||||
|
||||
def _export_csv(scanner, export_type: str) -> Response:
|
||||
"""Export data as CSV."""
|
||||
output = io.StringIO()
|
||||
|
||||
if export_type in ('networks', 'all'):
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
'BSSID', 'ESSID', 'Channel', 'Band', 'RSSI', 'Security',
|
||||
'Cipher', 'Auth', 'Vendor', 'Clients', 'First Seen', 'Last Seen'
|
||||
])
|
||||
|
||||
for n in scanner.access_points:
|
||||
writer.writerow([
|
||||
n.bssid,
|
||||
n.essid or '[Hidden]',
|
||||
n.channel,
|
||||
n.band,
|
||||
n.rssi_current,
|
||||
n.security,
|
||||
n.cipher,
|
||||
n.auth,
|
||||
n.vendor or '',
|
||||
n.client_count,
|
||||
n.first_seen.isoformat(),
|
||||
n.last_seen.isoformat(),
|
||||
])
|
||||
|
||||
if export_type == 'all':
|
||||
writer.writerow([]) # Blank line separator
|
||||
|
||||
if export_type in ('clients', 'all'):
|
||||
writer = csv.writer(output)
|
||||
if export_type == 'clients':
|
||||
writer.writerow([
|
||||
'MAC', 'Vendor', 'RSSI', 'Associated BSSID', 'Probed SSIDs',
|
||||
'First Seen', 'Last Seen'
|
||||
])
|
||||
|
||||
for c in scanner.clients:
|
||||
writer.writerow([
|
||||
c.mac,
|
||||
c.vendor or '',
|
||||
c.rssi_current,
|
||||
c.associated_bssid or '',
|
||||
', '.join(c.probed_ssids),
|
||||
c.first_seen.isoformat(),
|
||||
c.last_seen.isoformat(),
|
||||
])
|
||||
|
||||
response = Response(output.getvalue(), mimetype='text/csv')
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=wifi_scan_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
|
||||
return response
|
||||
@@ -0,0 +1,633 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--bg-dark: #0b1118;
|
||||
--bg-panel: #101823;
|
||||
--bg-card: #151f2b;
|
||||
--border-color: #263246;
|
||||
--border-glow: rgba(74, 163, 255, 0.4);
|
||||
--text-primary: #d7e0ee;
|
||||
--text-secondary: #9fb0c7;
|
||||
--text-dim: #6f7f94;
|
||||
--accent-cyan: #4aa3ff;
|
||||
--accent-green: #38c180;
|
||||
--accent-amber: #d6a85e;
|
||||
--grid-line: rgba(74, 163, 255, 0.1);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23ffffff'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.radar-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
var(--noise-image),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 40px 40px, 50px 50px, 50px 50px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.scanline {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
color: var(--accent-cyan);
|
||||
animation: scan 6s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.25;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% { top: -4px; }
|
||||
100% { top: 100vh; }
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 12px 20px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--accent-cyan);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.history-shell {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 16px 18px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.session-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
background: linear-gradient(120deg, rgba(15, 18, 24, 0.95), rgba(20, 26, 36, 0.95));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
box-shadow: 0 0 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.session-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
box-shadow: 0 0 12px rgba(75, 85, 99, 0.6);
|
||||
}
|
||||
|
||||
.status-dot.active {
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 14px rgba(34, 197, 94, 0.8);
|
||||
}
|
||||
|
||||
.session-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
|
||||
.session-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.session-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#sessionNotice {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.session-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.session-controls select {
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.primary-btn.stop {
|
||||
background: var(--accent-amber);
|
||||
color: #0a0c10;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
box-shadow: 0 0 14px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.3px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
align-items: flex-end;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
|
||||
.control-group input,
|
||||
.control-group select {
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--accent-cyan);
|
||||
border: none;
|
||||
color: #0a0c10;
|
||||
font-weight: 600;
|
||||
padding: 10px 18px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 14px rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--accent-amber);
|
||||
color: var(--accent-amber);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
letter-spacing: 1.6px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 12px 14px;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.aircraft-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.aircraft-table th,
|
||||
.aircraft-table td {
|
||||
padding: 8px 6px;
|
||||
border-bottom: 1px solid rgba(31, 41, 55, 0.6);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.aircraft-table th {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.aircraft-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.aircraft-row:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.empty-row td,
|
||||
.empty-row {
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: 18px 10px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
#altitudeChart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#speedChart,
|
||||
#headingChart,
|
||||
#verticalChart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.timeline-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(31, 41, 55, 0.6);
|
||||
border-radius: 6px;
|
||||
background: rgba(15, 18, 24, 0.6);
|
||||
}
|
||||
|
||||
.squawk-list {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(5, 8, 15, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal-backdrop.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
width: min(820px, 92vw);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
border: 1px solid rgba(74, 158, 255, 0.4);
|
||||
color: var(--accent-cyan);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal-photo {
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.photo-fallback {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 18px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(20, 26, 36, 0.6);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(31, 41, 55, 0.6);
|
||||
}
|
||||
|
||||
.detail-row span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.detail-row strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.control-group input,
|
||||
.control-group select {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.session-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* Agents Management CSS
|
||||
* Styles for the remote agent management interface
|
||||
* Inherits CSS variables from core/variables.css
|
||||
*/
|
||||
|
||||
/* Agent indicator in navigation */
|
||||
.agent-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.agent-indicator:hover {
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.agent-indicator-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 6px var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-indicator-dot.remote {
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 6px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.agent-indicator-dot.multiple {
|
||||
background: var(--accent-orange);
|
||||
box-shadow: 0 0 6px var(--accent-orange);
|
||||
}
|
||||
|
||||
.agent-indicator-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.agent-indicator-count {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Agent selector dropdown */
|
||||
.agent-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.agent-selector-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
min-width: 280px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-selector-dropdown.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.agent-selector-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.agent-selector-header h4 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.agent-selector-manage {
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.agent-selector-manage:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.agent-selector-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.agent-selector-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.agent-selector-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.agent-selector-item:hover {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
.agent-selector-item.selected {
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
border-left: 3px solid var(--accent-cyan);
|
||||
}
|
||||
|
||||
.agent-selector-item.local {
|
||||
border-left: 3px solid var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-selector-item-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-selector-item-status.online {
|
||||
background: var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-selector-item-status.offline {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
|
||||
.agent-selector-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-selector-item-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-selector-item-url {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-selector-item-check {
|
||||
color: var(--accent-green);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.agent-selector-item.selected .agent-selector-item-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Agent badge in data displays */
|
||||
.agent-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
border-radius: 10px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.agent-badge.local,
|
||||
.agent-badge.agent-local {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-badge.agent-remote {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* WiFi table agent column */
|
||||
.wifi-networks-table .col-agent {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wifi-networks-table th.col-agent {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Bluetooth table agent column */
|
||||
.bt-devices-table .col-agent {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.agent-badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
/* Agent column in data tables */
|
||||
.data-table .agent-col {
|
||||
width: 120px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
/* Multi-agent stream indicator */
|
||||
.multi-agent-indicator {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.multi-agent-indicator.active {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.multi-agent-indicator-pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-cyan);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.8); }
|
||||
}
|
||||
|
||||
/* Agent connection status toast */
|
||||
.agent-toast {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
padding: 10px 15px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
z-index: 1001;
|
||||
animation: slideInRight 0.3s ease;
|
||||
}
|
||||
|
||||
.agent-toast.connected {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.agent-toast.disconnected {
|
||||
border-color: var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.agent-indicator {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.agent-indicator-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-selector-dropdown {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
border-radius: 16px 16px 0 0;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.agents-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
/**
|
||||
* Activity Timeline Component
|
||||
* Reusable, configuration-driven timeline visualization
|
||||
* Supports visual modes: compact, enriched, summary
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
CSS VARIABLES (with fallbacks)
|
||||
============================================ */
|
||||
.activity-timeline {
|
||||
--timeline-bg: var(--bg-card, #1a1a1a);
|
||||
--timeline-border: var(--border-color, #333);
|
||||
--timeline-bg-secondary: var(--bg-secondary, #252525);
|
||||
--timeline-bg-elevated: var(--bg-elevated, #2a2a2a);
|
||||
--timeline-text-primary: var(--text-primary, #fff);
|
||||
--timeline-text-secondary: var(--text-secondary, #888);
|
||||
--timeline-text-dim: var(--text-dim, #666);
|
||||
--timeline-accent: var(--accent-cyan, #4a9eff);
|
||||
--timeline-status-new: var(--signal-new, #3b82f6);
|
||||
--timeline-status-baseline: var(--signal-baseline, #6b7280);
|
||||
--timeline-status-burst: var(--signal-burst, #f59e0b);
|
||||
--timeline-status-flagged: var(--signal-emergency, #ef4444);
|
||||
--timeline-status-gone: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TIMELINE CONTAINER
|
||||
============================================ */
|
||||
.activity-timeline {
|
||||
background: var(--timeline-bg);
|
||||
border: 1px solid var(--timeline-border);
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.activity-timeline.collapsed .activity-timeline-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-timeline.collapsed .activity-timeline-header {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.activity-timeline.collapsed .activity-timeline-collapse-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HEADER
|
||||
============================================ */
|
||||
.activity-timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.activity-timeline-header:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.activity-timeline-collapse-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s ease;
|
||||
color: var(--timeline-text-dim);
|
||||
}
|
||||
|
||||
.activity-timeline-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--timeline-text-secondary);
|
||||
}
|
||||
|
||||
.activity-timeline-header-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 10px;
|
||||
color: var(--timeline-text-dim);
|
||||
}
|
||||
|
||||
.activity-timeline-header-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.activity-timeline-header-stat .stat-value {
|
||||
color: var(--timeline-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BODY
|
||||
============================================ */
|
||||
.activity-timeline-body {
|
||||
padding: 0 12px 12px 12px;
|
||||
border-top: 1px solid var(--timeline-border);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
CONTROLS
|
||||
============================================ */
|
||||
.activity-timeline-controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.activity-timeline-btn {
|
||||
background: var(--timeline-bg-secondary);
|
||||
border: 1px solid var(--timeline-border);
|
||||
color: var(--timeline-text-secondary);
|
||||
font-size: 9px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.activity-timeline-btn:hover {
|
||||
background: var(--timeline-bg-elevated);
|
||||
color: var(--timeline-text-primary);
|
||||
}
|
||||
|
||||
.activity-timeline-btn.active {
|
||||
background: var(--timeline-accent);
|
||||
color: #000;
|
||||
border-color: var(--timeline-accent);
|
||||
}
|
||||
|
||||
.activity-timeline-window {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: var(--timeline-text-dim);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.activity-timeline-window-select {
|
||||
background: var(--timeline-bg-secondary);
|
||||
border: 1px solid var(--timeline-border);
|
||||
color: var(--timeline-text-primary);
|
||||
font-size: 9px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TIME AXIS
|
||||
============================================ */
|
||||
.activity-timeline-axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 50px 0 140px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 9px;
|
||||
color: var(--timeline-text-dim);
|
||||
}
|
||||
|
||||
.activity-timeline-axis-label {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.activity-timeline-axis-label::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
height: 4px;
|
||||
background: var(--timeline-border);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LANES CONTAINER
|
||||
============================================ */
|
||||
.activity-timeline-lanes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.activity-timeline-lanes::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.activity-timeline-lanes::-webkit-scrollbar-track {
|
||||
background: var(--timeline-bg-secondary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.activity-timeline-lanes::-webkit-scrollbar-thumb {
|
||||
background: var(--timeline-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.activity-timeline-lanes::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--timeline-text-dim);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
INDIVIDUAL LANE
|
||||
============================================ */
|
||||
.activity-timeline-lane {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-height: 32px;
|
||||
background: var(--timeline-bg-secondary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.activity-timeline-lane:hover {
|
||||
background: var(--timeline-bg-elevated);
|
||||
}
|
||||
|
||||
.activity-timeline-lane.expanded {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.activity-timeline-lane.baseline {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.activity-timeline-lane.baseline:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Status indicator strip */
|
||||
.activity-timeline-status {
|
||||
width: 4px;
|
||||
min-width: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-timeline-status[data-status="new"] {
|
||||
background: var(--timeline-status-new);
|
||||
}
|
||||
|
||||
.activity-timeline-status[data-status="baseline"] {
|
||||
background: var(--timeline-status-baseline);
|
||||
}
|
||||
|
||||
.activity-timeline-status[data-status="burst"] {
|
||||
background: var(--timeline-status-burst);
|
||||
}
|
||||
|
||||
.activity-timeline-status[data-status="flagged"] {
|
||||
background: var(--timeline-status-flagged);
|
||||
}
|
||||
|
||||
.activity-timeline-status[data-status="gone"] {
|
||||
background: var(--timeline-status-gone);
|
||||
}
|
||||
|
||||
/* Label section */
|
||||
.activity-timeline-label {
|
||||
width: 130px;
|
||||
min-width: 130px;
|
||||
padding: 6px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
border-right: 1px solid var(--timeline-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.activity-timeline-id {
|
||||
color: var(--timeline-text-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.activity-timeline-name {
|
||||
color: var(--timeline-text-dim);
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TRACK (where bars are drawn)
|
||||
============================================ */
|
||||
.activity-timeline-track {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 32px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.activity-timeline-track-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SIGNAL BARS
|
||||
============================================ */
|
||||
.activity-timeline-bar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 14px;
|
||||
min-width: 2px;
|
||||
border-radius: 2px;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
/* Strength variants */
|
||||
.activity-timeline-bar[data-strength="1"] { height: 5px; }
|
||||
.activity-timeline-bar[data-strength="2"] { height: 9px; }
|
||||
.activity-timeline-bar[data-strength="3"] { height: 13px; }
|
||||
.activity-timeline-bar[data-strength="4"] { height: 17px; }
|
||||
.activity-timeline-bar[data-strength="5"] { height: 21px; }
|
||||
|
||||
/* Status colors */
|
||||
.activity-timeline-bar[data-status="new"],
|
||||
.activity-timeline-bar[data-status="repeated"] {
|
||||
background: var(--timeline-status-new);
|
||||
box-shadow: 0 0 4px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.activity-timeline-bar[data-status="baseline"] {
|
||||
background: var(--timeline-status-baseline);
|
||||
}
|
||||
|
||||
.activity-timeline-bar[data-status="burst"] {
|
||||
background: var(--timeline-status-burst);
|
||||
box-shadow: 0 0 5px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.activity-timeline-bar[data-status="flagged"] {
|
||||
background: var(--timeline-status-flagged);
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
|
||||
animation: timeline-flagged-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes timeline-flagged-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.activity-timeline-lane:hover .activity-timeline-bar {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EXPANDED VIEW (tick marks)
|
||||
============================================ */
|
||||
.activity-timeline-ticks {
|
||||
display: none;
|
||||
position: relative;
|
||||
height: 24px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--timeline-border);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.activity-timeline-lane.expanded .activity-timeline-ticks {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.activity-timeline-tick {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--timeline-accent);
|
||||
}
|
||||
|
||||
.activity-timeline-tick[data-strength="1"] { height: 4px; }
|
||||
.activity-timeline-tick[data-strength="2"] { height: 8px; }
|
||||
.activity-timeline-tick[data-strength="3"] { height: 12px; }
|
||||
.activity-timeline-tick[data-strength="4"] { height: 16px; }
|
||||
.activity-timeline-tick[data-strength="5"] { height: 20px; }
|
||||
|
||||
/* ============================================
|
||||
STATS COLUMN
|
||||
============================================ */
|
||||
.activity-timeline-stats {
|
||||
width: 45px;
|
||||
min-width: 45px;
|
||||
padding: 4px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
font-size: 9px;
|
||||
color: var(--timeline-text-dim);
|
||||
border-left: 1px solid var(--timeline-border);
|
||||
}
|
||||
|
||||
.activity-timeline-stat-count {
|
||||
color: var(--timeline-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.activity-timeline-stat-label {
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ANNOTATIONS
|
||||
============================================ */
|
||||
.activity-timeline-annotations {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--timeline-border);
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.activity-timeline-annotation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
color: var(--timeline-text-secondary);
|
||||
background: var(--timeline-bg-secondary);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.activity-timeline-annotation-icon {
|
||||
font-size: 10px;
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.activity-timeline-annotation[data-type="new"] {
|
||||
border-left: 2px solid var(--timeline-status-new);
|
||||
}
|
||||
|
||||
.activity-timeline-annotation[data-type="burst"] {
|
||||
border-left: 2px solid var(--timeline-status-burst);
|
||||
}
|
||||
|
||||
.activity-timeline-annotation[data-type="pattern"] {
|
||||
border-left: 2px solid var(--timeline-accent);
|
||||
}
|
||||
|
||||
.activity-timeline-annotation[data-type="flagged"] {
|
||||
border-left: 2px solid var(--timeline-status-flagged);
|
||||
color: var(--timeline-status-flagged);
|
||||
}
|
||||
|
||||
.activity-timeline-annotation[data-type="gone"] {
|
||||
border-left: 2px solid var(--timeline-status-gone);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TOOLTIP
|
||||
============================================ */
|
||||
.activity-timeline-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
background: var(--timeline-bg-elevated);
|
||||
border: 1px solid var(--timeline-border);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
font-size: 10px;
|
||||
color: var(--timeline-text-primary);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
max-width: 240px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.activity-timeline-tooltip-header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--timeline-accent);
|
||||
}
|
||||
|
||||
.activity-timeline-tooltip-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--timeline-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.activity-timeline-tooltip-row span:last-child {
|
||||
color: var(--timeline-text-primary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LEGEND
|
||||
============================================ */
|
||||
.activity-timeline-legend {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--timeline-border);
|
||||
font-size: 9px;
|
||||
color: var(--timeline-text-dim);
|
||||
}
|
||||
|
||||
.activity-timeline-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.activity-timeline-legend-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.activity-timeline-legend-dot.new { background: var(--timeline-status-new); }
|
||||
.activity-timeline-legend-dot.baseline { background: var(--timeline-status-baseline); }
|
||||
.activity-timeline-legend-dot.burst { background: var(--timeline-status-burst); }
|
||||
.activity-timeline-legend-dot.flagged { background: var(--timeline-status-flagged); }
|
||||
|
||||
/* ============================================
|
||||
EMPTY STATE
|
||||
============================================ */
|
||||
.activity-timeline-empty {
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
color: var(--timeline-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.activity-timeline-empty-icon {
|
||||
font-size: 20px;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* More indicator */
|
||||
.activity-timeline-more {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--timeline-text-dim);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
VISUAL MODE: COMPACT
|
||||
============================================ */
|
||||
.activity-timeline--compact .activity-timeline-lanes {
|
||||
max-height: 140px;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-lane {
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-label {
|
||||
width: 100px;
|
||||
min-width: 100px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-id {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-name {
|
||||
font-size: 10px;
|
||||
color: var(--timeline-text-secondary);
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-track {
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-bar {
|
||||
height: 10px !important;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-bar[data-strength="1"] { height: 4px !important; }
|
||||
.activity-timeline--compact .activity-timeline-bar[data-strength="2"] { height: 6px !important; }
|
||||
.activity-timeline--compact .activity-timeline-bar[data-strength="3"] { height: 8px !important; }
|
||||
.activity-timeline--compact .activity-timeline-bar[data-strength="4"] { height: 10px !important; }
|
||||
.activity-timeline--compact .activity-timeline-bar[data-strength="5"] { height: 12px !important; }
|
||||
|
||||
.activity-timeline--compact .activity-timeline-stats {
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-stat-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-legend {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-timeline--compact .activity-timeline-axis {
|
||||
padding-left: 110px;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
VISUAL MODE: SUMMARY
|
||||
============================================ */
|
||||
.activity-timeline--summary .activity-timeline-lanes {
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-lane {
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-label {
|
||||
width: 80px;
|
||||
min-width: 80px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-id,
|
||||
.activity-timeline--summary .activity-timeline-name {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-status {
|
||||
width: 3px;
|
||||
min-width: 3px;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-track {
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-bar {
|
||||
height: 8px !important;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-stats {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-ticks {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-annotations {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-legend {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-timeline--summary .activity-timeline-axis {
|
||||
padding-left: 90px;
|
||||
padding-right: 10px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BACKWARD COMPATIBILITY NOTE
|
||||
The old signal-timeline.css is still loaded
|
||||
for existing TSCM code that uses those classes.
|
||||
New code should use activity-timeline classes.
|
||||
============================================ */
|
||||
@@ -0,0 +1,879 @@
|
||||
/**
|
||||
* Device Cards Component CSS
|
||||
* Styling for Bluetooth device cards, heuristic badges, range bands, and sparklines
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
CSS VARIABLES
|
||||
============================================ */
|
||||
:root {
|
||||
/* Protocol colors */
|
||||
--proto-ble: #3b82f6;
|
||||
--proto-ble-bg: rgba(59, 130, 246, 0.15);
|
||||
--proto-classic: #8b5cf6;
|
||||
--proto-classic-bg: rgba(139, 92, 246, 0.15);
|
||||
|
||||
/* Range band colors */
|
||||
--range-very-close: #ef4444;
|
||||
--range-close: #f97316;
|
||||
--range-nearby: #eab308;
|
||||
--range-far: #6b7280;
|
||||
--range-unknown: #374151;
|
||||
|
||||
/* Heuristic badge colors */
|
||||
--heuristic-new: #3b82f6;
|
||||
--heuristic-persistent: #22c55e;
|
||||
--heuristic-beacon: #f59e0b;
|
||||
--heuristic-strong: #ef4444;
|
||||
--heuristic-random: #6b7280;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DEVICE CARD BASE
|
||||
============================================ */
|
||||
.device-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.device-card:hover {
|
||||
border-color: var(--accent-cyan, #00d4ff);
|
||||
box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.device-card:active {
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DEVICE IDENTITY
|
||||
============================================ */
|
||||
.device-identity {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.device-address {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.device-address .address-value {
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
.device-address .address-type {
|
||||
color: var(--text-dim, #666);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PROTOCOL BADGES
|
||||
============================================ */
|
||||
.signal-proto-badge.device-protocol {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HEURISTIC BADGES
|
||||
============================================ */
|
||||
.device-heuristic-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
|
||||
color: var(--badge-color);
|
||||
border: 1px solid color-mix(in srgb, var(--badge-color) 30%, transparent);
|
||||
}
|
||||
|
||||
.device-heuristic-badge.new {
|
||||
--badge-color: var(--heuristic-new);
|
||||
animation: heuristicPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.device-heuristic-badge.persistent {
|
||||
--badge-color: var(--heuristic-persistent);
|
||||
}
|
||||
|
||||
.device-heuristic-badge.beacon_like {
|
||||
--badge-color: var(--heuristic-beacon);
|
||||
}
|
||||
|
||||
.device-heuristic-badge.strong_stable {
|
||||
--badge-color: var(--heuristic-strong);
|
||||
}
|
||||
|
||||
.device-heuristic-badge.random_address {
|
||||
--badge-color: var(--heuristic-random);
|
||||
}
|
||||
|
||||
@keyframes heuristicPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SIGNAL ROW & RSSI DISPLAY
|
||||
============================================ */
|
||||
.device-signal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rssi-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rssi-current {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RSSI SPARKLINE
|
||||
============================================ */
|
||||
.rssi-sparkline,
|
||||
.rssi-sparkline-svg {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.rssi-sparkline-empty {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.rssi-sparkline-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rssi-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rssi-current-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.sparkline-dot {
|
||||
animation: sparklinePulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes sparklinePulse {
|
||||
0%, 100% { r: 2; opacity: 1; }
|
||||
50% { r: 3; opacity: 0.8; }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RANGE BAND INDICATOR
|
||||
============================================ */
|
||||
.device-range-band {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: color-mix(in srgb, var(--range-color) 15%, transparent);
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid var(--range-color);
|
||||
}
|
||||
|
||||
.device-range-band .range-label {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--range-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.device-range-band .range-estimate {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.device-range-band .range-confidence {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
padding: 1px 4px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MANUFACTURER INFO
|
||||
============================================ */
|
||||
.device-manufacturer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #888);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.device-manufacturer .mfr-icon {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.device-manufacturer .mfr-name {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
META ROW
|
||||
============================================ */
|
||||
.device-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.device-seen-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.device-seen-count .seen-icon {
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.device-timestamp {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SERVICE UUIDS
|
||||
============================================ */
|
||||
.device-uuids {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.device-uuid {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border-radius: 3px;
|
||||
color: var(--text-secondary, #888);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HEURISTICS DETAIL VIEW
|
||||
============================================ */
|
||||
.device-heuristics-detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.heuristic-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.heuristic-item.active {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.heuristic-item .heuristic-name {
|
||||
font-size: 10px;
|
||||
text-transform: capitalize;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.heuristic-item .heuristic-status {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.heuristic-item.active .heuristic-status {
|
||||
color: var(--accent-green, #22c55e);
|
||||
}
|
||||
|
||||
.heuristic-item:not(.active) .heuristic-status {
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MESSAGE CARDS
|
||||
============================================ */
|
||||
.message-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--message-bg);
|
||||
border: 1px solid color-mix(in srgb, var(--message-color) 30%, transparent);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
animation: messageSlideIn 0.25s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes messageSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.message-card.message-card-hiding {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.message-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--message-color);
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
.message-card-icon {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--message-color);
|
||||
}
|
||||
|
||||
.message-card-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.message-card-icon svg.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.message-card-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-card-title {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.message-card-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #888);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message-card-details {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.message-card-dismiss {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim, #666);
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.message-card-dismiss:hover {
|
||||
opacity: 1;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.message-card-dismiss svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.message-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.message-action-btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
color: var(--text-secondary, #888);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.message-action-btn:hover {
|
||||
background: var(--bg-tertiary, #252525);
|
||||
border-color: var(--border-light, #444);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.message-action-btn.primary {
|
||||
background: color-mix(in srgb, var(--message-color) 20%, transparent);
|
||||
border-color: color-mix(in srgb, var(--message-color) 40%, transparent);
|
||||
color: var(--message-color);
|
||||
}
|
||||
|
||||
.message-action-btn.primary:hover {
|
||||
background: color-mix(in srgb, var(--message-color) 30%, transparent);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DEVICE FILTER BAR
|
||||
============================================ */
|
||||
.device-filter-bar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.device-filter-bar .signal-filter-btn .filter-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE ADJUSTMENTS
|
||||
============================================ */
|
||||
@media (max-width: 600px) {
|
||||
.device-signal-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rssi-display {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.device-range-band {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.device-heuristics-detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.message-card {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.message-card-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-card-text {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BLUETOOTH DEVICE LIST CONTAINER
|
||||
============================================ */
|
||||
#btDeviceListContent {
|
||||
display: block !important;
|
||||
padding: 10px !important;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
/* Pure inline-styled cards - ensure no interference */
|
||||
#btDeviceListContent > div[data-bt-device-id] {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
height: auto !important;
|
||||
min-height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Legacy card support */
|
||||
#btDeviceListContent .device-card,
|
||||
#btDeviceListContent .signal-card {
|
||||
margin: 0 0 10px 0;
|
||||
height: auto !important;
|
||||
min-height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Ensure card body is visible */
|
||||
.device-card .signal-card-body,
|
||||
.signal-card .signal-card-body {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 8px !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.device-card .device-identity,
|
||||
.signal-card .device-identity {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.device-card .device-signal-row,
|
||||
.signal-card .device-signal-row {
|
||||
display: flex !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.device-card .device-meta-row,
|
||||
.signal-card .device-meta-row {
|
||||
display: flex !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ENHANCED MODAL STYLES
|
||||
============================================ */
|
||||
.signal-details-modal-header .modal-header-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.signal-details-modal-subtitle {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.signal-details-modal-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.signal-details-copy-addr-btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-secondary, #252525);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary, #888);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.signal-details-copy-addr-btn:hover {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
/* Modal Header Section */
|
||||
.modal-device-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.modal-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Modal Sections */
|
||||
.modal-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.modal-section-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-dim, #666);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Signal Display */
|
||||
.modal-signal-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.modal-rssi-large {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-rssi-large .rssi-unit {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-dim, #666);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.modal-sparkline {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Signal Stats Grid */
|
||||
.modal-signal-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-signal-stats .stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-signal-stats .stat-label {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim, #666);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.modal-signal-stats .stat-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
/* Info Grid */
|
||||
.modal-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-info-grid .info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.modal-info-grid .info-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.modal-info-grid .info-value {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.modal-info-grid .info-value.mono {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
/* UUID List */
|
||||
.modal-uuid-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.modal-uuid {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
/* Heuristics Grid */
|
||||
.modal-heuristics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.heuristic-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.heuristic-check.active {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.heuristic-indicator {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.heuristic-check.active .heuristic-indicator {
|
||||
color: var(--accent-green, #22c55e);
|
||||
}
|
||||
|
||||
.heuristic-label {
|
||||
font-size: 11px;
|
||||
text-transform: capitalize;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE MODAL
|
||||
============================================ */
|
||||
@media (max-width: 600px) {
|
||||
.modal-signal-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.modal-info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-signal-display {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal-sparkline {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-device-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DARK MODE OVERRIDES (if needed)
|
||||
============================================ */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.device-card {
|
||||
--bg-secondary: #1a1a1a;
|
||||
--bg-tertiary: #141414;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
/* Function Strip (Action Bar) - Shared across modes
|
||||
* Based on APRS strip pattern, reusable for Pager, Sensor, Bluetooth, WiFi, TSCM, etc.
|
||||
*/
|
||||
|
||||
.function-strip {
|
||||
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
overflow: visible;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.function-strip-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
/* Strip title badge */
|
||||
.function-strip .strip-title {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.function-strip .strip-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
border: 1px solid rgba(74, 158, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
min-width: 55px;
|
||||
}
|
||||
|
||||
.function-strip .strip-stat:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border-color: rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.function-strip .strip-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.function-strip .strip-label {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.function-strip .strip-divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: var(--border-color);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* Signal stat coloring */
|
||||
.function-strip .signal-stat.good .strip-value { color: var(--accent-green); }
|
||||
.function-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
|
||||
.function-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
|
||||
|
||||
/* Controls */
|
||||
.function-strip .strip-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.function-strip .strip-select {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.function-strip .strip-select:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.function-strip .strip-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.function-strip .strip-input-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.function-strip .strip-input {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.function-strip .strip-input:hover,
|
||||
.function-strip .strip-input:focus {
|
||||
border-color: var(--accent-cyan);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.function-strip .strip-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Wider input for frequency values */
|
||||
.function-strip .strip-input.wide {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
/* Tool Status Indicators */
|
||||
.function-strip .strip-tools {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.function-strip .strip-tool {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 59, 48, 0.2);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid rgba(255, 59, 48, 0.3);
|
||||
}
|
||||
|
||||
.function-strip .strip-tool.ok {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--accent-green);
|
||||
border-color: rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
.function-strip .strip-tool.warn {
|
||||
background: rgba(255, 193, 7, 0.2);
|
||||
color: var(--accent-yellow);
|
||||
border-color: rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.function-strip .strip-btn {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border: 1px solid rgba(74, 158, 255, 0.2);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.function-strip .strip-btn:hover:not(:disabled) {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
border-color: rgba(74, 158, 255, 0.4);
|
||||
}
|
||||
|
||||
.function-strip .strip-btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
|
||||
border: none;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.function-strip .strip-btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.function-strip .strip-btn.stop {
|
||||
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.function-strip .strip-btn.stop:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.function-strip .strip-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.function-strip .strip-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.function-strip .status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.function-strip .status-dot.inactive {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.function-strip .status-dot.active,
|
||||
.function-strip .status-dot.scanning,
|
||||
.function-strip .status-dot.decoding {
|
||||
background: var(--accent-cyan);
|
||||
animation: strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.function-strip .status-dot.listening,
|
||||
.function-strip .status-dot.tracking,
|
||||
.function-strip .status-dot.receiving {
|
||||
background: var(--accent-green);
|
||||
animation: strip-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.function-strip .status-dot.sweeping {
|
||||
background: var(--accent-orange);
|
||||
animation: strip-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.function-strip .status-dot.error {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
|
||||
@keyframes strip-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
|
||||
50% { opacity: 0.6; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* Time display */
|
||||
.function-strip .strip-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Mode-specific accent colors */
|
||||
.function-strip.pager-strip .strip-stat {
|
||||
background: rgba(255, 193, 7, 0.05);
|
||||
border-color: rgba(255, 193, 7, 0.15);
|
||||
}
|
||||
.function-strip.pager-strip .strip-stat:hover {
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border-color: rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
.function-strip.pager-strip .strip-value {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
|
||||
.function-strip.sensor-strip .strip-stat {
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
border-color: rgba(0, 255, 136, 0.15);
|
||||
}
|
||||
.function-strip.sensor-strip .strip-stat:hover {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border-color: rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
.function-strip.sensor-strip .strip-value {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.function-strip.bt-strip .strip-stat {
|
||||
background: rgba(0, 122, 255, 0.05);
|
||||
border-color: rgba(0, 122, 255, 0.15);
|
||||
}
|
||||
.function-strip.bt-strip .strip-stat:hover {
|
||||
background: rgba(0, 122, 255, 0.1);
|
||||
border-color: rgba(0, 122, 255, 0.3);
|
||||
}
|
||||
.function-strip.bt-strip .strip-value {
|
||||
color: #0a84ff;
|
||||
}
|
||||
|
||||
.function-strip.wifi-strip .strip-stat {
|
||||
background: rgba(255, 149, 0, 0.05);
|
||||
border-color: rgba(255, 149, 0, 0.15);
|
||||
}
|
||||
.function-strip.wifi-strip .strip-stat:hover {
|
||||
background: rgba(255, 149, 0, 0.1);
|
||||
border-color: rgba(255, 149, 0, 0.3);
|
||||
}
|
||||
.function-strip.wifi-strip .strip-value {
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.function-strip.tscm-strip {
|
||||
margin-top: 4px; /* Extra clearance to prevent top clipping */
|
||||
}
|
||||
|
||||
.function-strip.tscm-strip .strip-stat {
|
||||
background: rgba(255, 59, 48, 0.15);
|
||||
border: 1px solid rgba(255, 59, 48, 0.4);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-stat:hover {
|
||||
background: rgba(255, 59, 48, 0.25);
|
||||
border-color: rgba(255, 59, 48, 0.6);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-value {
|
||||
color: #ef4444; /* Explicit red color */
|
||||
}
|
||||
.function-strip.tscm-strip .strip-label {
|
||||
color: #9ca3af; /* Explicit light gray */
|
||||
}
|
||||
.function-strip.tscm-strip .strip-select {
|
||||
color: #e8eaed; /* Explicit white for selects */
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.function-strip.tscm-strip .strip-btn {
|
||||
color: #e8eaed; /* Explicit white for buttons */
|
||||
}
|
||||
.function-strip.tscm-strip .strip-tool {
|
||||
color: #e8eaed; /* Explicit white for tool indicators */
|
||||
}
|
||||
.function-strip.tscm-strip .strip-time,
|
||||
.function-strip.tscm-strip .strip-status span {
|
||||
color: #9ca3af; /* Explicit gray for status/time */
|
||||
}
|
||||
|
||||
.function-strip.rtlamr-strip .strip-stat {
|
||||
background: rgba(175, 82, 222, 0.05);
|
||||
border-color: rgba(175, 82, 222, 0.15);
|
||||
}
|
||||
.function-strip.rtlamr-strip .strip-stat:hover {
|
||||
background: rgba(175, 82, 222, 0.1);
|
||||
border-color: rgba(175, 82, 222, 0.3);
|
||||
}
|
||||
.function-strip.rtlamr-strip .strip-value {
|
||||
color: #af52de;
|
||||
}
|
||||
|
||||
.function-strip.listening-strip .strip-stat {
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
border-color: rgba(74, 158, 255, 0.15);
|
||||
}
|
||||
.function-strip.listening-strip .strip-stat:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border-color: rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
.function-strip.listening-strip .strip-value {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Threat-colored stats for TSCM */
|
||||
.function-strip .strip-stat.threat-high .strip-value { color: var(--accent-red); }
|
||||
.function-strip .strip-stat.threat-review .strip-value { color: var(--accent-orange); }
|
||||
.function-strip .strip-stat.threat-info .strip-value { color: var(--accent-cyan); }
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Proximity Visualization Components
|
||||
* Styles for radar and timeline heatmap
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
PROXIMITY RADAR
|
||||
============================================ */
|
||||
|
||||
.proximity-radar-svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.radar-device {
|
||||
transition: transform 0.2s ease;
|
||||
transform-origin: center center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radar-device:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Invisible larger hit area to prevent hover flicker */
|
||||
.radar-device-hitarea {
|
||||
fill: transparent;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.radar-dot-pulse circle:first-child {
|
||||
animation: radar-pulse 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes radar-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-sweep {
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
/* Radar filter buttons */
|
||||
.bt-radar-filter-btn {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn:hover {
|
||||
background: var(--bg-hover, #333) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn.active {
|
||||
background: #00d4ff !important;
|
||||
color: #000 !important;
|
||||
border-color: #00d4ff !important;
|
||||
}
|
||||
|
||||
#btRadarPauseBtn.active {
|
||||
background: #f97316 !important;
|
||||
color: #000 !important;
|
||||
border-color: #f97316 !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TIMELINE HEATMAP
|
||||
============================================ */
|
||||
|
||||
.timeline-heatmap-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.heatmap-control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
|
||||
.heatmap-select {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.heatmap-select:hover {
|
||||
border-color: var(--accent-color, #00d4ff);
|
||||
}
|
||||
|
||||
.heatmap-btn {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim, #888);
|
||||
font-size: 10px;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.heatmap-btn:hover {
|
||||
background: var(--bg-hover, #252525);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.heatmap-btn.active {
|
||||
background: #f97316;
|
||||
color: #000;
|
||||
border-color: #f97316;
|
||||
}
|
||||
|
||||
.timeline-heatmap-content {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.heatmap-loading,
|
||||
.heatmap-empty,
|
||||
.heatmap-error {
|
||||
color: var(--text-dim, #666);
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.heatmap-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.heatmap-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.heatmap-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.heatmap-row:hover:not(.heatmap-header) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.heatmap-row.selected {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
outline: 1px solid rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.heatmap-header {
|
||||
cursor: default;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.heatmap-label {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-right: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heatmap-label .device-name {
|
||||
font-size: 10px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.heatmap-label .device-rssi {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.heatmap-cells {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.heatmap-cell {
|
||||
border-radius: 2px;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.heatmap-cell:hover {
|
||||
transform: scale(1.5);
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heatmap-time-label {
|
||||
font-size: 8px;
|
||||
color: var(--text-dim, #666);
|
||||
text-align: center;
|
||||
transform: rotate(-45deg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ZONE SUMMARY
|
||||
============================================ */
|
||||
|
||||
#btZoneSummary {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
#btZoneSummary > div {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE ADJUSTMENTS
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.timeline-heatmap-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.heatmap-control-group {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.proximity-radar-svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#btRadarControls {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#btZoneSummary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* Signal Activity Timeline Component
|
||||
* Lightweight visualization for RF signal presence over time
|
||||
* Used for TSCM sweeps and investigative analysis
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
TIMELINE CONTAINER
|
||||
============================================ */
|
||||
.signal-timeline {
|
||||
background: var(--bg-card, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.signal-timeline.collapsed .signal-timeline-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.signal-timeline.collapsed .signal-timeline-header {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.signal-timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.signal-timeline-header:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.signal-timeline-body {
|
||||
padding: 0 12px 12px 12px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.signal-timeline-collapse-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.signal-timeline.collapsed .signal-timeline-collapse-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.signal-timeline-header-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.signal-timeline-header-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.signal-timeline-header-stat .stat-value {
|
||||
color: var(--text-primary, #fff);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.signal-timeline-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.signal-timeline-controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.signal-timeline-btn {
|
||||
background: var(--bg-secondary, #252525);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 9px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.signal-timeline-btn:hover {
|
||||
background: var(--bg-elevated, #2a2a2a);
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.signal-timeline-btn.active {
|
||||
background: var(--accent-cyan, #4a9eff);
|
||||
color: #000;
|
||||
border-color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
/* Time window selector */
|
||||
.signal-timeline-window {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.signal-timeline-window select {
|
||||
background: var(--bg-secondary, #252525);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 9px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TIME AXIS
|
||||
============================================ */
|
||||
.signal-timeline-axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 80px 0 100px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.signal-timeline-axis-label {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.signal-timeline-axis-label::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
height: 4px;
|
||||
background: var(--border-color, #333);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SWIMLANES
|
||||
============================================ */
|
||||
.signal-timeline-lanes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.signal-timeline-lanes::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.signal-timeline-lanes::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary, #252525);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.signal-timeline-lanes::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color, #444);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.signal-timeline-lanes::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.signal-timeline-lane {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-height: 36px;
|
||||
background: var(--bg-secondary, #252525);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.signal-timeline-lane:hover {
|
||||
background: var(--bg-elevated, #2a2a2a);
|
||||
}
|
||||
|
||||
.signal-timeline-lane.expanded {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.signal-timeline-lane.baseline {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.signal-timeline-lane.baseline:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Signal label */
|
||||
.signal-timeline-label {
|
||||
width: 130px;
|
||||
min-width: 130px;
|
||||
padding: 6px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
border-right: 1px solid var(--border-color, #333);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.signal-timeline-freq {
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.signal-timeline-name {
|
||||
color: var(--text-dim, #666);
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.signal-timeline-status {
|
||||
width: 4px;
|
||||
min-width: 4px;
|
||||
}
|
||||
|
||||
.signal-timeline-status[data-status="new"] {
|
||||
background: var(--signal-new, #3b82f6);
|
||||
}
|
||||
|
||||
.signal-timeline-status[data-status="baseline"] {
|
||||
background: var(--signal-baseline, #6b7280);
|
||||
}
|
||||
|
||||
.signal-timeline-status[data-status="burst"] {
|
||||
background: var(--signal-burst, #f59e0b);
|
||||
}
|
||||
|
||||
.signal-timeline-status[data-status="flagged"] {
|
||||
background: var(--signal-emergency, #ef4444);
|
||||
}
|
||||
|
||||
.signal-timeline-status[data-status="gone"] {
|
||||
background: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TRACK (where bars are drawn)
|
||||
============================================ */
|
||||
.signal-timeline-track {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 36px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.signal-timeline-track-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Grid lines */
|
||||
.signal-timeline-grid {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--border-color, #333);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SIGNAL BARS
|
||||
============================================ */
|
||||
.signal-timeline-bar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 16px;
|
||||
min-width: 2px;
|
||||
border-radius: 2px;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
/* Strength variants (height) */
|
||||
.signal-timeline-bar[data-strength="1"] { height: 6px; }
|
||||
.signal-timeline-bar[data-strength="2"] { height: 10px; }
|
||||
.signal-timeline-bar[data-strength="3"] { height: 14px; }
|
||||
.signal-timeline-bar[data-strength="4"] { height: 18px; }
|
||||
.signal-timeline-bar[data-strength="5"] { height: 22px; }
|
||||
|
||||
/* Status colors */
|
||||
.signal-timeline-bar[data-status="new"] {
|
||||
background: var(--signal-new, #3b82f6);
|
||||
box-shadow: 0 0 6px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.signal-timeline-bar[data-status="baseline"] {
|
||||
background: var(--signal-baseline, #6b7280);
|
||||
}
|
||||
|
||||
.signal-timeline-bar[data-status="burst"] {
|
||||
background: var(--signal-burst, #f59e0b);
|
||||
box-shadow: 0 0 6px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.signal-timeline-bar[data-status="flagged"] {
|
||||
background: var(--signal-emergency, #ef4444);
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
|
||||
animation: flaggedPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes flaggedPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.signal-timeline-lane:hover .signal-timeline-bar {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EXPANDED VIEW (tick marks)
|
||||
============================================ */
|
||||
.signal-timeline-ticks {
|
||||
display: none;
|
||||
position: relative;
|
||||
height: 24px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.signal-timeline-lane.expanded .signal-timeline-ticks {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.signal-timeline-tick {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.signal-timeline-tick[data-strength="1"] { height: 4px; }
|
||||
.signal-timeline-tick[data-strength="2"] { height: 8px; }
|
||||
.signal-timeline-tick[data-strength="3"] { height: 12px; }
|
||||
.signal-timeline-tick[data-strength="4"] { height: 16px; }
|
||||
.signal-timeline-tick[data-strength="5"] { height: 20px; }
|
||||
|
||||
/* ============================================
|
||||
ANNOTATIONS
|
||||
============================================ */
|
||||
.signal-timeline-annotations {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
max-height: 60px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.signal-timeline-annotation {
|
||||
padding: 3px 6px;
|
||||
font-size: 9px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.signal-timeline-annotation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary, #888);
|
||||
background: var(--bg-secondary, #252525);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.signal-timeline-annotation-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.signal-timeline-annotation[data-type="new"] {
|
||||
border-left: 2px solid var(--signal-new, #3b82f6);
|
||||
}
|
||||
|
||||
.signal-timeline-annotation[data-type="burst"] {
|
||||
border-left: 2px solid var(--signal-burst, #f59e0b);
|
||||
}
|
||||
|
||||
.signal-timeline-annotation[data-type="pattern"] {
|
||||
border-left: 2px solid var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.signal-timeline-annotation[data-type="flagged"] {
|
||||
border-left: 2px solid var(--signal-emergency, #ef4444);
|
||||
color: var(--signal-emergency, #ef4444);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TOOLTIP
|
||||
============================================ */
|
||||
.signal-timeline-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: var(--bg-elevated, #2a2a2a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
font-size: 10px;
|
||||
color: var(--text-primary, #fff);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.signal-timeline-tooltip-header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.signal-timeline-tooltip-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.signal-timeline-tooltip-row span:last-child {
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATS ROW
|
||||
============================================ */
|
||||
.signal-timeline-stats {
|
||||
width: 50px;
|
||||
min-width: 50px;
|
||||
padding: 4px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
border-left: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.signal-timeline-stat-count {
|
||||
color: var(--text-primary, #fff);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.signal-timeline-stat-label {
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EMPTY STATE
|
||||
============================================ */
|
||||
.signal-timeline-empty {
|
||||
text-align: center;
|
||||
padding: 30px 20px;
|
||||
color: var(--text-dim, #666);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.signal-timeline-empty-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LEGEND - compact inline version
|
||||
============================================ */
|
||||
.signal-timeline-legend {
|
||||
display: none; /* Hide by default - status colors are self-explanatory */
|
||||
}
|
||||
|
||||
.signal-timeline-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.signal-timeline-legend-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.signal-timeline-legend-dot.new { background: var(--signal-new, #3b82f6); }
|
||||
.signal-timeline-legend-dot.baseline { background: var(--signal-baseline, #6b7280); }
|
||||
.signal-timeline-legend-dot.burst { background: var(--signal-burst, #f59e0b); }
|
||||
.signal-timeline-legend-dot.flagged { background: var(--signal-emergency, #ef4444); }
|
||||
|
||||
/* ============================================
|
||||
NOW MARKER
|
||||
============================================ */
|
||||
.signal-timeline-now {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--accent-green, #22c55e);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.signal-timeline-now::after {
|
||||
content: 'NOW';
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 8px;
|
||||
color: var(--accent-green, #22c55e);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
MARKER (first seen indicator)
|
||||
============================================ */
|
||||
.signal-timeline-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-bottom: 8px solid var(--signal-new, #3b82f6);
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.signal-timeline-marker::after {
|
||||
content: attr(data-label);
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 8px;
|
||||
color: var(--signal-new, #3b82f6);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
/**
|
||||
* Toast Notification System
|
||||
* Reusable toast notifications for update alerts and other messages
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
TOAST CONTAINER
|
||||
============================================ */
|
||||
#toastContainer {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#toastContainer > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UPDATE TOAST
|
||||
============================================ */
|
||||
.update-toast {
|
||||
display: flex;
|
||||
background: var(--bg-card, #121620);
|
||||
border: 1px solid var(--border-color, #1f2937);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
max-width: 340px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.update-toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.update-toast-indicator {
|
||||
width: 4px;
|
||||
background: var(--accent-green, #22c55e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.update-toast-content {
|
||||
flex: 1;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.update-toast-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.update-toast-icon {
|
||||
color: var(--accent-green, #22c55e);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.update-toast-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.update-toast-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e8eaed);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.update-toast-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim, #4b5563);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: -4px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.update-toast-close:hover {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
.update-toast-body {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.update-toast-body strong {
|
||||
color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.update-toast-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.update-toast-btn {
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.update-toast-btn-primary {
|
||||
background: var(--accent-green, #22c55e);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.update-toast-btn-primary:hover {
|
||||
background: #34d673;
|
||||
}
|
||||
|
||||
.update-toast-btn-secondary {
|
||||
background: var(--bg-secondary, #0f1218);
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
border: 1px solid var(--border-color, #1f2937);
|
||||
}
|
||||
|
||||
.update-toast-btn-secondary:hover {
|
||||
background: var(--bg-tertiary, #151a23);
|
||||
border-color: var(--border-light, #374151);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UPDATE MODAL
|
||||
============================================ */
|
||||
.update-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10002;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.update-modal-overlay.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.update-modal {
|
||||
background: var(--bg-card, #121620);
|
||||
border: 1px solid var(--border-color, #1f2937);
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 520px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.update-modal-overlay.show .update-modal {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.update-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||
}
|
||||
|
||||
.update-modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e8eaed);
|
||||
}
|
||||
|
||||
.update-modal-icon {
|
||||
color: var(--accent-green, #22c55e);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.update-modal-icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.update-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim, #4b5563);
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.update-modal-close:hover {
|
||||
color: var(--accent-red, #ef4444);
|
||||
}
|
||||
|
||||
.update-modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Version Info */
|
||||
.update-version-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary, #0f1218);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.update-version-current,
|
||||
.update-version-latest {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.update-version-label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim, #4b5563);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.update-version-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
.update-version-new {
|
||||
color: var(--accent-green, #22c55e);
|
||||
}
|
||||
|
||||
.update-version-arrow {
|
||||
color: var(--text-dim, #4b5563);
|
||||
}
|
||||
|
||||
.update-version-arrow svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.update-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.update-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim, #4b5563);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.update-release-notes {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
background: var(--bg-secondary, #0f1218);
|
||||
border: 1px solid var(--border-color, #1f2937);
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.update-release-notes h2,
|
||||
.update-release-notes h3,
|
||||
.update-release-notes h4 {
|
||||
color: var(--text-primary, #e8eaed);
|
||||
margin: 16px 0 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.update-release-notes h2:first-child,
|
||||
.update-release-notes h3:first-child,
|
||||
.update-release-notes h4:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.update-release-notes ul {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.update-release-notes li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.update-release-notes code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--bg-tertiary, #151a23);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.update-release-notes p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* Warning */
|
||||
.update-warning {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.update-warning-icon {
|
||||
color: var(--accent-orange, #f59e0b);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.update-warning-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.update-warning-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
.update-warning-text strong {
|
||||
display: block;
|
||||
color: var(--accent-orange, #f59e0b);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.update-warning-text p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Options */
|
||||
.update-options {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.update-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.update-option input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
.update-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
.update-progress-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-color, #1f2937);
|
||||
border-top-color: var(--accent-cyan, #4a9eff);
|
||||
border-radius: 50%;
|
||||
animation: updateSpin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes updateSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Results */
|
||||
.update-result {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 6px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.update-result-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.update-result-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.update-result-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.update-result-text code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
display: inline-block;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.update-result-success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.update-result-success .update-result-icon {
|
||||
color: var(--accent-green, #22c55e);
|
||||
}
|
||||
|
||||
.update-result-success .update-result-text {
|
||||
color: var(--accent-green, #22c55e);
|
||||
}
|
||||
|
||||
.update-result-error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.update-result-error .update-result-icon {
|
||||
color: var(--accent-red, #ef4444);
|
||||
}
|
||||
|
||||
.update-result-error .update-result-text {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
.update-result-error .update-result-text strong {
|
||||
color: var(--accent-red, #ef4444);
|
||||
}
|
||||
|
||||
.update-result-warning {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.update-result-warning .update-result-icon {
|
||||
color: var(--accent-orange, #f59e0b);
|
||||
}
|
||||
|
||||
.update-result-warning .update-result-text {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
.update-result-warning .update-result-text strong {
|
||||
color: var(--accent-orange, #f59e0b);
|
||||
}
|
||||
|
||||
.update-result-info {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.update-result-info .update-result-icon {
|
||||
color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.update-result-info .update-result-text {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.update-modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border-color, #1f2937);
|
||||
background: var(--bg-secondary, #0f1218);
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
.update-modal-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim, #4b5563);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.update-modal-link:hover {
|
||||
color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
.update-modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.update-modal-btn {
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.update-modal-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.update-modal-btn-primary {
|
||||
background: var(--accent-green, #22c55e);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.update-modal-btn-primary:hover:not(:disabled) {
|
||||
background: #34d673;
|
||||
}
|
||||
|
||||
.update-modal-btn-secondary {
|
||||
background: var(--bg-tertiary, #151a23);
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
border: 1px solid var(--border-color, #1f2937);
|
||||
}
|
||||
|
||||
.update-modal-btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-elevated, #1a202c);
|
||||
border-color: var(--border-light, #374151);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE
|
||||
============================================ */
|
||||
@media (max-width: 480px) {
|
||||
#toastContainer {
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.update-toast {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.update-modal {
|
||||
width: 95%;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.update-version-info {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.update-version-arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.update-modal-footer {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.update-modal-link {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.update-modal-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.update-modal-btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* INTERCEPT Base Styles
|
||||
* Reset, typography, and foundational element styles
|
||||
* Requires: variables.css to be imported first
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
CSS RESET
|
||||
============================================ */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-moz-tab-size: 4;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
background-image:
|
||||
var(--noise-image),
|
||||
radial-gradient(circle at 15% 0%, var(--grid-dot), transparent 45%),
|
||||
linear-gradient(180deg, var(--grid-dot), transparent 35%),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 40px 40px, auto, auto, 48px 48px, 48px 48px;
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TYPOGRAPHY
|
||||
============================================ */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
h1 { font-size: var(--text-4xl); }
|
||||
h2 { font-size: var(--text-3xl); }
|
||||
h3 { font-size: var(--text-2xl); }
|
||||
h4 { font-size: var(--text-xl); }
|
||||
h5 { font-size: var(--text-lg); }
|
||||
h6 { font-size: var(--text-base); }
|
||||
|
||||
p {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-cyan-hover);
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
strong, b {
|
||||
font-weight: var(--font-semibold);
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
code, kbd, pre, samp {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FORM ELEMENTS
|
||||
============================================ */
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--text-primary);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 2px var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239fb0c7' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TABLES
|
||||
============================================ */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LISTS
|
||||
============================================ */
|
||||
ul, ol {
|
||||
padding-left: var(--space-6);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UTILITY CLASSES
|
||||
============================================ */
|
||||
|
||||
/* Text colors */
|
||||
.text-primary { color: var(--text-primary); }
|
||||
.text-secondary { color: var(--text-secondary); }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-cyan { color: var(--accent-cyan); }
|
||||
.text-green { color: var(--accent-green); }
|
||||
.text-red { color: var(--accent-red); }
|
||||
.text-orange { color: var(--accent-orange); }
|
||||
.text-amber { color: var(--accent-amber); }
|
||||
|
||||
/* Font utilities */
|
||||
.font-mono { font-family: var(--font-mono); }
|
||||
.font-medium { font-weight: var(--font-medium); }
|
||||
.font-semibold { font-weight: var(--font-semibold); }
|
||||
.font-bold { font-weight: var(--font-bold); }
|
||||
|
||||
/* Text sizes */
|
||||
.text-xs { font-size: var(--text-xs); }
|
||||
.text-sm { font-size: var(--text-sm); }
|
||||
.text-base { font-size: var(--text-base); }
|
||||
.text-lg { font-size: var(--text-lg); }
|
||||
.text-xl { font-size: var(--text-xl); }
|
||||
|
||||
/* Display */
|
||||
.hidden { display: none !important; }
|
||||
.block { display: block; }
|
||||
.inline-block { display: inline-block; }
|
||||
.flex { display: flex; }
|
||||
.inline-flex { display: inline-flex; }
|
||||
.grid { display: grid; }
|
||||
|
||||
/* Flexbox */
|
||||
.items-center { align-items: center; }
|
||||
.justify-center { justify-content: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.flex-1 { flex: 1; }
|
||||
.gap-1 { gap: var(--space-1); }
|
||||
.gap-2 { gap: var(--space-2); }
|
||||
.gap-3 { gap: var(--space-3); }
|
||||
.gap-4 { gap: var(--space-4); }
|
||||
|
||||
/* Spacing */
|
||||
.m-0 { margin: 0; }
|
||||
.mt-2 { margin-top: var(--space-2); }
|
||||
.mt-4 { margin-top: var(--space-4); }
|
||||
.mb-2 { margin-bottom: var(--space-2); }
|
||||
.mb-4 { margin-bottom: var(--space-4); }
|
||||
.p-2 { padding: var(--space-2); }
|
||||
.p-3 { padding: var(--space-3); }
|
||||
.p-4 { padding: var(--space-4); }
|
||||
|
||||
/* Borders */
|
||||
.rounded { border-radius: var(--radius-md); }
|
||||
.rounded-lg { border-radius: var(--radius-lg); }
|
||||
.border { border: 1px solid var(--border-color); }
|
||||
|
||||
/* Truncate text */
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Screen reader only */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SCROLLBAR STYLING
|
||||
============================================ */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-light);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-light) var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SELECTION
|
||||
============================================ */
|
||||
::selection {
|
||||
background: var(--accent-cyan-dim);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UX POLISH - TRANSITIONS & INTERACTIONS
|
||||
============================================ */
|
||||
|
||||
/* Smooth page transitions */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Better focus ring for all interactive elements */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent-cyan);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Remove focus ring for mouse users */
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Active state feedback */
|
||||
button:active:not(:disabled),
|
||||
a:active,
|
||||
[role="button"]:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Smooth transitions for all interactive elements */
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
[role="button"] {
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
transform var(--transition-fast),
|
||||
opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Subtle hover lift effect for cards and panels */
|
||||
.card:hover,
|
||||
.panel:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Link underline on hover */
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Skip link for accessibility */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-primary);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
z-index: 9999;
|
||||
transition: top var(--transition-fast);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* Reduced motion preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--border-color: #4b5563;
|
||||
--text-secondary: #d1d5db;
|
||||
}
|
||||
}
|
||||