├── .gitignore ├── LICENSE ├── README.md ├── collection ├── hackrf │ └── hackrf.go ├── rtlsdr │ └── rtlsdr.go └── spectre.go ├── export ├── csv.go ├── export.go ├── spectreserver.go └── sql.go ├── extraction └── extraction.go ├── filter └── filter.go ├── go.mod ├── go.sum ├── render └── render.go ├── sdr └── sdr.go ├── server └── server.go └── waterfall.jpg /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/go,macos,windows,visualstudiocode 2 | # Edit at https://www.gitignore.io/?templates=go,macos,windows,visualstudiocode 3 | 4 | ### Custom ### 5 | 6 | spectre 7 | render 8 | server 9 | collector-secret.json 10 | **/*.sqlite 11 | **/*.sqlite3 12 | **/*.db 13 | 14 | ### Go ### 15 | # Binaries for programs and plugins 16 | *.exe 17 | *.exe~ 18 | *.dll 19 | *.so 20 | *.dylib 21 | 22 | # Test binary, built with `go test -c` 23 | *.test 24 | 25 | # Output of the go coverage tool, specifically when used with LiteIDE 26 | *.out 27 | 28 | # Dependency directories (remove the comment below to include it) 29 | # vendor/ 30 | 31 | ### Go Patch ### 32 | /vendor/ 33 | /Godeps/ 34 | 35 | ### macOS ### 36 | # General 37 | .DS_Store 38 | .AppleDouble 39 | .LSOverride 40 | 41 | # Icon must end with two \r 42 | Icon 43 | 44 | # Thumbnails 45 | ._* 46 | 47 | # Files that might appear in the root of a volume 48 | .DocumentRevisions-V100 49 | .fseventsd 50 | .Spotlight-V100 51 | .TemporaryItems 52 | .Trashes 53 | .VolumeIcon.icns 54 | .com.apple.timemachine.donotpresent 55 | 56 | # Directories potentially created on remote AFP share 57 | .AppleDB 58 | .AppleDesktop 59 | Network Trash Folder 60 | Temporary Items 61 | .apdisk 62 | 63 | ### VisualStudioCode ### 64 | .vscode/* 65 | !.vscode/settings.json 66 | !.vscode/tasks.json 67 | !.vscode/launch.json 68 | !.vscode/extensions.json 69 | 70 | ### VisualStudioCode Patch ### 71 | # Ignore all local history of files 72 | .history 73 | 74 | ### Windows ### 75 | # Windows thumbnail cache files 76 | Thumbs.db 77 | Thumbs.db:encryptable 78 | ehthumbs.db 79 | ehthumbs_vista.db 80 | 81 | # Dump file 82 | *.stackdump 83 | 84 | # Folder config file 85 | [Dd]esktop.ini 86 | 87 | # Recycle Bin used on file shares 88 | $RECYCLE.BIN/ 89 | 90 | # Windows Installer files 91 | *.cab 92 | *.msi 93 | *.msix 94 | *.msm 95 | *.msp 96 | 97 | # Windows shortcuts 98 | *.lnk 99 | 100 | # Custom 101 | *.exe 102 | *.log 103 | *.conf 104 | 105 | # End of https://www.gitignore.io/api/go,macos,windows,visualstudiocode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spectre 2 | 3 | Spectre is an SDR based long term, wide spectrum collection and analysis tool. 4 | 5 | ![Waterfall image rendered from collected data.](https://github.com/hb9tf/spectre/blob/main/waterfall.jpg?raw=true) 6 | 7 | ## Collection 8 | 9 | ### Prerequisites 10 | 11 | You will need a working setup for one of the [supported SDRs](#supported-sdrs). 12 | 13 | Notes: This has primarily been tested on macOS 12.1 and Debian but it will probably work elsewhere as well. 14 | 15 | ### Flags 16 | 17 | * `-lowFreq`: The lower frequency to start the sweeps with in Hz. 18 | 19 | * `-highFreq`: The upper frequency to end the sweeps with in Hz. 20 | 21 | * `-binSize`: The FFT bin width (frequency resolution) in Hz. BinSize is a maximum, smaller more convenient bins will be used. 22 | 23 | * `-integrationInterval`: The duration during which to collect information per frequency. 24 | 25 | > Note: HackRF's `hackrf_sweep` is sweeping at much higher rates than e.g. RTL SDR's `rtl_power` 26 | > but on the flipside, it does not allow providing an integration interval. Thus this integration 27 | > is done in software which is more resource intense when using a HackRF. 28 | 29 | * `discardOutOfRange`: When set to `true` (default) this causes samples to be filtered which are captured by the SDR but outside the specified range. 30 | 31 | > Note: This is useful to save bandwidth and storage when using an SDR like HackRF which returns samples in a 32 | > 20MHz bandwidth even when only a 2MHz sample range is needed. 33 | 34 | * `-sdr`: Which SDR type to use (determines the CLI command which is called). 35 | 36 | * `-identifier`: Unique identifier for the source instance (needs to be assigned). 37 | 38 | * `-output`: Export mechanism to use, needs to be one of: `csv`, `sqlite`, `mysql`, `spectre`. See [Output section](#output) below. 39 | 40 | * For `sqlite` output option: 41 | * `sqliteFile`: File path of the sqlite DB file to use (default: `/tmp/spectre`). Note that the DB file is created if it doesn't already exist. 42 | * For `mysql` output option: 43 | * `mysqlServer`: MySQL TCP server endpoint to connect to (IP/DNS and port). Defaults to "127.0.0.1:3306". 44 | * `mysqlUser`: MySQL DB user 45 | * `mysqlPasswordFile`: Path to the file containing the password for the MySQL user. 46 | * `mysqlDBName`: Name of the DB to use. Defaults to `spectre`. 47 | * For `spectre` output option: 48 | * `spectreServer`: URL scheme, address and port of the spectre server in the following format: "https://localhost:8443" 49 | * `spectreServerSamples`: Defines how many samples should be sent to the server at once (default is 100). 50 | 51 | We're using [glog]() which allows you to modify the logging behavior through flags as well if needed. The most useful ones: 52 | 53 | * `logtostderr`: Logs to stderr instead of logfiles 54 | * `v`: Shows all `V(x)` messages for `x` less or equal the value of this flag. 55 | 56 | For more info on how to control logging, see the following: 57 | 58 | * [Go glog](https://github.com/golang/glog) 59 | * [glog](https://github.com/google/glog) 60 | 61 | ### Output 62 | 63 | The following output options are currently supported, controlled via the `-output` flag: 64 | 65 | * `csv`: CSV formatted export to `stdout`. 66 | * `sqlite`: Write samples to local sqlite DB. 67 | * `mysql`: Write samples to a MySQL DB. 68 | * `spectre`: Write samples to a remote Spectre server endpoint. 69 | 70 | Note: See additional control flags for each output option in the [Flags section](#flags) above. 71 | 72 | Generally, the output contains the following data: 73 | * Source: Source type (e.g. "hackrf" or "rtl_sdr"). 74 | * Identifier: Unique identifier for the specific instance as defined by the `-id` flag. 75 | * Center Frequency: Center frequency of the sample (halfway between lower and upper frequency). 76 | * Low Frequency: Lower frequency used for this sample's bin. 77 | * High Frequency: Upper frequency used for this sample's bin. 78 | * Start Time: Unix timestamp in milliseconds at which the measurement started. 79 | * End Time: Unix timestamp in milliseconds at which the measurement ended. 80 | * DB Low: Lowest signal strength measured across the samples aggregated in this frequency bucket. 81 | * DB High: Highest signal strength measured across the samples aggregated in this frequency bucket. 82 | * DB Avg: Average signal strength across the samples aggregated in this frequency bucket. 83 | * Sample Count: Number of measurements aggregated into this sample. 84 | 85 | ### Examples 86 | 87 | #### Example 1 88 | 89 | The following uses an RTL SDR to sweep from 400-500MHz with a bin size of 12.5kHz and 10s integration 90 | per channel and writes the output to stdout as a CSV: 91 | 92 | ``` 93 | $ go run spectre.go -sdr rtlsdr -lowFreq 400000000 -highFreq 500000000 -binSize 12500 -integrationInterval 10s -output csv 94 | Running RTL SDR sweep: "/opt/homebrew/bin/rtl_power -f 400000000:500000000:12500 -i 10s -" 95 | ... 96 | 489046189,489040764,489051614,1639222100000,1639222100000,-19.350000,-19.350000,-19.350000,160 97 | 489057039,489051614,489062464,1639222100000,1639222100000,-19.550000,-19.550000,-19.550000,160 98 | 489067889,489062464,489073314,1639222100000,1639222100000,-18.840000,-18.840000,-18.840000,160 99 | 489078739,489073314,489084164,1639222100000,1639222100000,-17.120000,-17.120000,-17.120000,160 100 | 489089589,489084164,489095014,1639222100000,1639222100000,-16.110000,-16.110000,-16.110000,160 101 | ... 102 | ``` 103 | 104 | #### Example 2 105 | 106 | In this example, we use an RTL SDR to sweep from 400-500MHz with a bin size of 12.5kHz and 10s integration 107 | per channel and write the output to a sqlite DB in `/tmp/spectre` (the file is created if it doesn't already exist): 108 | 109 | ``` 110 | $ go run spectre.go -sdr rtlsdr -lowFreq 400000000 -highFreq 500000000 -binSize 12500 -integrationInterval 10s -output sqlite -sqliteFile "/tmp/spectre" 111 | Running RTL SDR sweep: "/opt/homebrew/bin/rtl_power -f 400000000:500000000:12500 -i 10s -" 112 | Sample export counts: map[error:0 success:1000 total:1000] 113 | Sample export counts: map[error:0 success:2000 total:2000] 114 | Sample export counts: map[error:0 success:3000 total:3000] 115 | ... 116 | ``` 117 | 118 | ### Supported SDRs 119 | 120 | Currently there is support for: 121 | 122 | * [RTL SDR](https://osmocom.org/projects/rtl-sdr/wiki/Rtl-sdr) 123 | 124 | Use the `-sdr rtlsdr` flag for Spectre. 125 | 126 | Ensure you installed the `rtl-sdr` tools - specifically `rtl_power` needs to be findable via `$PATH`. 127 | 128 | * macOS: `brew install librtlsdr` 129 | * Debian/Ubuntu: `apt-get install rtl-sdr` 130 | 131 | Note: RTL SDR support has been less tested than HackRF so there might be more rough edges here. 132 | 133 | * [HackRF](https://greatscottgadgets.com/hackrf/) 134 | 135 | Use the `-sdr hackrf` flag for Spectre. 136 | 137 | Ensure you installed the `hackrf` tools - specifically `hackrf_sweep` needs to be findable via `$PATH`. 138 | 139 | * macOS: `brew install hackrf` or `sudo port install hackrf` 140 | * Debian/Ubuntu: `apt-get install hackrf` 141 | 142 | > Note: You might have to install the HackRF tools from source and update your HackRF's firmware if you 143 | > run into problems. We have confirmed this working with the latest 144 | > [HackRF source](https://github.com/greatscottgadgets/hackrf) as of 2022-01-15 145 | > ([commit `8660e44`](https://github.com/greatscottgadgets/hackrf/commit/8660e44575b401855ae75d25e439c0e785c1af04)) 146 | > and [release `2021.03.1`](https://github.com/greatscottgadgets/hackrf/releases/tag/v2021.03.1) (e.g. firmware). 147 | 148 | ## Server 149 | 150 | This is an optional piece of spectre which can centrally collect samples from one or more endpoints. 151 | 152 | Note: This is experimental at the moment. 153 | 154 | The server can be run as follows: 155 | 156 | ``` 157 | go run server.go -logtostderr -storage sqlite -sqliteFile /tmp/spectre 158 | I0116 12:06:35.799644 1333 server.go:88] Resorting to serving HTTP because there was no certificate and key defined. 159 | ... 160 | ``` 161 | 162 | See `server.go` for more details such as available flags. 163 | 164 | Once running, the server presents two endpoints: 165 | 166 | * `/spectre/v1/collect`: The endpoint the collection binary uses to send its samples. 167 | * `/spectre/v1/render`: An endpoint to call to get a rendered image back. Supported `GET` parameters are: 168 | 169 | * Filter options: 170 | 171 | * `sdr`: Either `rtlsdr` or `hackrf`. 172 | * `identifier`: The identifier of a specific sender in order to just render samples for that one station. 173 | * `startFreq`: Lowest frequency to filter for. 174 | * `endFreq`: Highest frequency to filter for. 175 | * `startTime`: Unix start time in milliseconds in UTC. 176 | * `endTime`: Unix end time in milliseconds in UTC. 177 | 178 | * Image options: 179 | 180 | * `addGrid`: Whether to add a grid or not (default `1`). To disable either set it to `0` or `false`. 181 | * `imgWidth`: Desired image width in pixels. 182 | * `imgHeight`: Desired image height in pixels. 183 | * `imageType`: Either `jpg` (default) or `png`. 184 | 185 | ## Renderer 186 | 187 | The renderer `render.go` can be used to render collected Spectre data as a waterfall. 188 | 189 | Note: This is highly experimental at the moment. 190 | 191 | The renderer currently only supports data collected into a sqlite DB and can be run as follows: 192 | 193 | ``` 194 | $ go run render.go -source sqlite -sqliteFile /tmp/spectre -sdr hackrf -imgPath /tmp/out.jpg 195 | Selected source metadata: 196 | - Low frequency: 88.00 MHz 197 | - High frequency: 128.00 MHz 198 | - Start time: 2022-01-07T09:39:26 (1641544766) 199 | - End time: 2022-01-07T10:51:26 (1641549086) 200 | - Duration: 1h11m59.997s 201 | Rendering image (3208 x 432) 202 | - Frequency resolution: 12.47 kHz per pixel 203 | - Time resultion: 10.00 seconds per pixel 204 | Writing image to "/tmp/out.jpg" 205 | ``` 206 | 207 | See `render.go` for supported flags as there are more filter options than showed here. -------------------------------------------------------------------------------- /collection/hackrf/hackrf.go: -------------------------------------------------------------------------------- 1 | package hackrf 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os/exec" 7 | "strconv" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "github.com/golang/glog" 13 | 14 | "github.com/hb9tf/spectre/sdr" 15 | ) 16 | 17 | const ( 18 | SourceName = "hackrf" 19 | sweepAlias = "hackrf_sweep" 20 | ) 21 | 22 | type SDR struct { 23 | Identifier string 24 | 25 | buckets map[int64]sdr.Sample 26 | bucketsMu *sync.Mutex 27 | } 28 | 29 | func (s SDR) Name() string { 30 | return SourceName 31 | } 32 | 33 | func (s *SDR) Sweep(opts *sdr.Options, samples chan<- sdr.Sample) error { 34 | s.buckets = map[int64]sdr.Sample{} 35 | s.bucketsMu = &sync.Mutex{} 36 | 37 | args := []string{ 38 | fmt.Sprintf("-f %d:%d", opts.LowFreq/1000000, opts.HighFreq/1000000), 39 | fmt.Sprintf("-w %d", opts.BinSize), 40 | "-a 1", // RX RF amplifier 1=Enable, 0=Disable 41 | "-l 16", // RX LNA (IF) gain, 0-40dB, 8dB steps 42 | "-g 20", // RX VGA (baseband) gain, 0-62dB, 2dB steps 43 | } 44 | cmd := exec.Command(sweepAlias, args...) 45 | out, err := cmd.StdoutPipe() 46 | if err != nil { 47 | return err 48 | } 49 | 50 | scanner := bufio.NewScanner(out) 51 | // Start() executes command asynchronically. 52 | fmt.Printf("Running HackRF sweep: %q\n", cmd) 53 | if err := cmd.Start(); err != nil { 54 | glog.Fatalf("unable to start sweep: %s\n", err) 55 | } 56 | go func() { 57 | if err := cmd.Wait(); err != nil { 58 | glog.Exitf("sweep command ended with error: %s\n", err) 59 | } else { 60 | glog.Exit("sweep command ended successfully") 61 | } 62 | }() 63 | 64 | rawSamples := make(chan sdr.Sample) 65 | // Start raw sample processing. 66 | go func() { 67 | for scanner.Scan() { 68 | if err := s.scanRow(scanner, rawSamples); err != nil { 69 | glog.Warningf("error parsing line: %s\n", err) 70 | continue 71 | } 72 | } 73 | }() 74 | 75 | // Output aggregated samples in regular ticks. 76 | ticker := time.NewTicker(opts.IntegrationInterval) 77 | go func() { 78 | for range ticker.C { 79 | // This is not concurrency friendly... Buuut it's ok: 80 | // We're creating a new bucket to store new records in 81 | // and operate on the old one afterwards. Since we aggregate, 82 | // we won't miss much ¯\_(ツ)_/¯ 83 | old := s.buckets 84 | s.bucketsMu.Lock() 85 | s.buckets = map[int64]sdr.Sample{} 86 | s.bucketsMu.Unlock() 87 | 88 | for _, sample := range old { 89 | samples <- sample 90 | } 91 | } 92 | }() 93 | 94 | // Aggregate samples in frequency buckets. 95 | for sample := range rawSamples { 96 | stored, ok := s.buckets[sample.FreqCenter] 97 | if !ok { 98 | s.buckets[sample.FreqCenter] = sample 99 | continue 100 | } 101 | stored.End = sample.End 102 | stored.DBAvg = (stored.DBAvg*float64(stored.SampleCount) + sample.DBAvg*float64(sample.SampleCount)) / float64(stored.SampleCount+sample.SampleCount) 103 | if sample.DBLow < stored.DBLow { 104 | stored.DBLow = sample.DBLow 105 | } 106 | if sample.DBHigh > stored.DBHigh { 107 | stored.DBHigh = sample.DBHigh 108 | } 109 | stored.SampleCount += sample.SampleCount 110 | s.bucketsMu.Lock() 111 | s.buckets[sample.FreqCenter] = stored 112 | s.bucketsMu.Unlock() 113 | } 114 | 115 | return nil 116 | } 117 | 118 | func parseInt(num string) (int64, error) { 119 | return strconv.ParseInt(strings.Split(num, ".")[0], 10, 64) 120 | } 121 | 122 | // calculateBinRange calculates the highest and lowest frequencies in a bin 123 | func calculateBinRange(freqLow, freqHigh, binWidth, binNum int64) (int64, int64) { 124 | low := freqLow + (binNum * binWidth) 125 | high := low + binWidth 126 | if high > freqHigh { 127 | high = freqHigh 128 | } 129 | return low, high 130 | } 131 | func (s *SDR) scanRow(scanner *bufio.Scanner, samples chan<- sdr.Sample) error { 132 | glog.V(3).Info(scanner.Text()) 133 | row := strings.Split(scanner.Text(), ", ") 134 | numBins := len(row) - 6 135 | 136 | sampleCount, err := parseInt(row[5]) 137 | if err != nil { 138 | return err 139 | } 140 | freqLow, err := parseInt(row[2]) 141 | if err != nil { 142 | return err 143 | } 144 | freqHigh, err := parseInt(row[3]) 145 | if err != nil { 146 | return err 147 | } 148 | binWidth, err := parseInt(row[4]) 149 | if err != nil { 150 | return err 151 | } 152 | 153 | for i := 0; i < numBins; i++ { 154 | low, high := calculateBinRange(freqLow, freqHigh, binWidth, int64(i)) 155 | binRowIndex := i + 6 156 | parsedTime, err := time.Parse(time.RFC3339, row[0]+"T"+row[1]+"Z") 157 | if err != nil { 158 | return err 159 | } 160 | 161 | decibels, err := strconv.ParseFloat(row[binRowIndex], 64) 162 | if err != nil { 163 | return err 164 | } 165 | 166 | samples <- sdr.Sample{ 167 | Identifier: s.Identifier, 168 | Source: s.Name(), 169 | FreqCenter: (low + high) / 2, 170 | FreqLow: low, 171 | FreqHigh: high, 172 | DBLow: decibels, 173 | DBHigh: decibels, 174 | DBAvg: decibels, 175 | SampleCount: sampleCount, 176 | Start: parsedTime, 177 | End: parsedTime, 178 | } 179 | } 180 | return nil 181 | } 182 | -------------------------------------------------------------------------------- /collection/rtlsdr/rtlsdr.go: -------------------------------------------------------------------------------- 1 | package rtlsdr 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os/exec" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/golang/glog" 12 | 13 | "github.com/hb9tf/spectre/sdr" 14 | ) 15 | 16 | const ( 17 | SourceName = "rtlsdr" 18 | sweepAlias = "rtl_power" 19 | ) 20 | 21 | type SDR struct { 22 | Identifier string 23 | } 24 | 25 | func (s SDR) Name() string { 26 | return SourceName 27 | } 28 | 29 | func (s *SDR) Sweep(opts *sdr.Options, samples chan<- sdr.Sample) error { 30 | args := []string{ 31 | fmt.Sprintf("-f %d:%d:%d", opts.LowFreq, opts.HighFreq, opts.BinSize), 32 | fmt.Sprintf("-i %s", opts.IntegrationInterval), 33 | "-", // dumps samples to stdout 34 | } 35 | cmd := exec.Command(sweepAlias, args...) 36 | out, err := cmd.StdoutPipe() 37 | if err != nil { 38 | return err 39 | } 40 | 41 | scanner := bufio.NewScanner(out) 42 | // Start() executes command asynchronically. 43 | fmt.Printf("Running RTL SDR sweep: %q\n", cmd) 44 | if err := cmd.Start(); err != nil { 45 | glog.Exitf("unable to start sweep: %s\n", err) 46 | } 47 | go func() { 48 | if err := cmd.Wait(); err != nil { 49 | glog.Exitf("sweep command ended with error: %s\n", err) 50 | } else { 51 | glog.Exit("sweep command ended successfully") 52 | } 53 | }() 54 | 55 | // Start raw sample processing. 56 | for scanner.Scan() { 57 | if err := s.scanRow(scanner, samples); err != nil { 58 | glog.Warningf("error parsing line: %s\n", err) 59 | continue 60 | } 61 | } 62 | 63 | return nil 64 | } 65 | 66 | func parseInt(num string) (int64, error) { 67 | return strconv.ParseInt(strings.Split(num, ".")[0], 10, 64) 68 | } 69 | 70 | // calculateBinRange calculates the highest and lowest frequencies in a bin 71 | func calculateBinRange(freqLow, freqHigh, binWidth, binNum int64) (int64, int64) { 72 | low := freqLow + (binNum * binWidth) 73 | high := low + binWidth 74 | if high > freqHigh { 75 | high = freqHigh 76 | } 77 | return low, high 78 | } 79 | func (s *SDR) scanRow(scanner *bufio.Scanner, samples chan<- sdr.Sample) error { 80 | glog.V(3).Info(scanner.Text()) 81 | row := strings.Split(scanner.Text(), ", ") 82 | numBins := len(row) - 6 83 | 84 | sampleCount, err := parseInt(row[5]) 85 | if err != nil { 86 | return err 87 | } 88 | freqLow, err := parseInt(row[2]) 89 | if err != nil { 90 | return err 91 | } 92 | freqHigh, err := parseInt(row[3]) 93 | if err != nil { 94 | return err 95 | } 96 | binWidth, err := parseInt(row[4]) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | for i := 0; i < numBins; i++ { 102 | low, high := calculateBinRange(freqLow, freqHigh, binWidth, int64(i)) 103 | binRowIndex := i + 6 104 | parsedTime, err := time.Parse(time.RFC3339, row[0]+"T"+row[1]+"Z") 105 | if err != nil { 106 | return err 107 | } 108 | 109 | decibels, err := strconv.ParseFloat(row[binRowIndex], 64) 110 | if err != nil { 111 | return err 112 | } 113 | 114 | samples <- sdr.Sample{ 115 | Identifier: s.Identifier, 116 | Source: s.Name(), 117 | FreqCenter: (low + high) / 2, 118 | FreqLow: low, 119 | FreqHigh: high, 120 | DBLow: decibels, 121 | DBHigh: decibels, 122 | DBAvg: decibels, 123 | SampleCount: sampleCount, 124 | Start: parsedTime, 125 | End: parsedTime, 126 | } 127 | } 128 | return nil 129 | } 130 | -------------------------------------------------------------------------------- /collection/spectre.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "flag" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "github.com/go-sql-driver/mysql" 12 | "github.com/golang/glog" 13 | "github.com/google/uuid" 14 | 15 | "github.com/hb9tf/spectre/collection/hackrf" 16 | "github.com/hb9tf/spectre/collection/rtlsdr" 17 | "github.com/hb9tf/spectre/export" 18 | "github.com/hb9tf/spectre/filter" 19 | "github.com/hb9tf/spectre/sdr" 20 | 21 | // Blind import support for sqlite3 used by sqlite.go. 22 | _ "github.com/mattn/go-sqlite3" 23 | ) 24 | 25 | // Flags 26 | var ( 27 | identifier = flag.String("identifier", "", "unique identifier of source instance (defaults to a random UUID)") 28 | lowFreq = flag.Int64("lowFreq", 400000000, "lower frequency boundary in Hz") 29 | highFreq = flag.Int64("highFreq", 450000000, "upper frequency boundary in Hz") 30 | binSize = flag.Int64("binSize", 12500, "size of the bin in Hz") 31 | integrationInterval = flag.Duration("integrationInterval", 5*time.Second, "duration to aggregate samples") 32 | sdrType = flag.String("sdr", "", "SDR to use (one of: hackrf, rtlsdr)") 33 | discardOutOfRange = flag.Bool("discardOutOfRange", true, "Discard samples which are outside the specified frequencies") 34 | output = flag.String("output", "", "Export mechanism to use (one of: csv, sqlite, mysql, spectre)") 35 | 36 | // SQLite 37 | sqliteFile = flag.String("sqliteFile", "/tmp/spectre", "File path of the sqlite DB file to use.") 38 | 39 | // MySQL 40 | mysqlServer = flag.String("mysqlServer", "127.0.0.1:3306", "MySQL TCP server endpoint to connect to (IP/DNS and port).") 41 | mysqlUser = flag.String("mysqlUser", "", "MySQL DB user.") 42 | mysqlPasswordFile = flag.String("mysqlPasswordFile", "", "Path to the file containing the password for the MySQL user.") 43 | mysqlDBName = flag.String("mysqlDBName", "spectre", "Name of the DB to use.") 44 | 45 | // Spectre Server 46 | spectreServer = flag.String("spectreServer", "http://localhost:8080", "URL scheme, address and port of the spectre server.") 47 | spectreServerSamples = flag.Int("spectreServerSamples", 0, "Defines how many samples should be sent to the server at once.") 48 | ) 49 | 50 | func main() { 51 | ctx := context.Background() 52 | // Set defaults for glog flags. Can be overridden via cmdline. 53 | flag.Set("logtostderr", "false") 54 | flag.Set("stderrthreshold", "WARNING") 55 | flag.Set("v", "1") 56 | // Parse flags globally. 57 | flag.Parse() 58 | 59 | if *identifier == "" { 60 | *identifier = uuid.NewString() 61 | } 62 | 63 | // SDR setup 64 | var radio sdr.SDR 65 | switch strings.ToLower(*sdrType) { 66 | case hackrf.SourceName: 67 | radio = &hackrf.SDR{ 68 | Identifier: *identifier, 69 | } 70 | case rtlsdr.SourceName: 71 | radio = &rtlsdr.SDR{ 72 | Identifier: *identifier, 73 | } 74 | default: 75 | glog.Exitf("%q is not a supported SDR type, pick one of: hackrf, rtlsdr", *sdrType) 76 | } 77 | opts := &sdr.Options{ 78 | LowFreq: *lowFreq, 79 | HighFreq: *highFreq, 80 | BinSize: *binSize, 81 | IntegrationInterval: *integrationInterval, 82 | } 83 | 84 | // Exporter setup 85 | var exporter export.Exporter 86 | switch strings.ToLower(*output) { 87 | case "csv": 88 | exporter = &export.CSV{} 89 | case "sqlite": 90 | db, err := sql.Open("sqlite3", *sqliteFile) 91 | if err != nil { 92 | glog.Exitf("unable to open sqlite DB %q: %s", *sqliteFile, err) 93 | } 94 | exporter = &export.SQL{ 95 | DB: db, 96 | } 97 | case "mysql": 98 | pass, err := os.ReadFile(*mysqlPasswordFile) 99 | if err != nil { 100 | glog.Exitf("unable to read MySQL password file %q: %s\n", *mysqlPasswordFile, err) 101 | } 102 | cfg := mysql.Config{ 103 | User: *mysqlUser, 104 | Passwd: strings.TrimSpace(string(pass)), 105 | Net: "tcp", 106 | Addr: *mysqlServer, 107 | DBName: *mysqlDBName, 108 | } 109 | db, err := sql.Open("mysql", cfg.FormatDSN()) 110 | if err != nil { 111 | glog.Exitf("unable to open MySQL DB %q: %s", *mysqlServer, err) 112 | } 113 | db.SetConnMaxLifetime(3 * time.Minute) 114 | db.SetMaxOpenConns(10) 115 | db.SetMaxIdleConns(10) 116 | exporter = &export.SQL{ 117 | DB: db, 118 | } 119 | case "spectre": 120 | exporter = &export.SpectreServer{ 121 | Server: *spectreServer, 122 | SendSamplesAmount: *spectreServerSamples, 123 | } 124 | default: 125 | glog.Exitf("%q is not a supported export method, pick one of: csv, sqlite, mysql, spectre", *output) 126 | } 127 | 128 | // Run 129 | samples := make(chan sdr.Sample) 130 | go func() { 131 | if err := radio.Sweep(opts, samples); err != nil { 132 | glog.Fatal(err) 133 | } 134 | }() 135 | 136 | filteredSamples := make(chan sdr.Sample) 137 | go func() { 138 | filters := []filter.Filterer{} 139 | if *discardOutOfRange { 140 | filters = append(filters, &filter.FilterFreq{ 141 | FreqLow: *lowFreq, 142 | FreqHigh: *highFreq, 143 | }) 144 | } 145 | if err := filter.Filter(samples, filteredSamples, filters); err != nil { 146 | glog.Fatal(err) 147 | } 148 | }() 149 | 150 | if err := exporter.Write(ctx, filteredSamples); err != nil { 151 | glog.Fatal(err) 152 | } 153 | 154 | glog.Flush() 155 | } 156 | -------------------------------------------------------------------------------- /export/csv.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import ( 4 | "context" 5 | "encoding/csv" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/golang/glog" 10 | 11 | "github.com/hb9tf/spectre/sdr" 12 | ) 13 | 14 | type CSV struct{} 15 | 16 | func (c *CSV) Write(ctx context.Context, samples <-chan sdr.Sample) error { 17 | w := csv.NewWriter(os.Stdout) 18 | w.Write([]string{ 19 | "Source", 20 | "Identifier", 21 | "FreqCenter", 22 | "FreqLow", 23 | "FreqHigh", 24 | "StartUnixMilli", 25 | "EndUnixMilli", 26 | "dBLow", 27 | "dBHigh", 28 | "dbAvg", 29 | "SampleCount", 30 | }) 31 | 32 | for s := range samples { 33 | if err := w.Write([]string{ 34 | s.Source, 35 | s.Identifier, 36 | fmt.Sprintf("%d", s.FreqCenter), 37 | fmt.Sprintf("%d", s.FreqLow), 38 | fmt.Sprintf("%d", s.FreqHigh), 39 | fmt.Sprintf("%d", s.Start.UnixMilli()), 40 | fmt.Sprintf("%d", s.End.UnixMilli()), 41 | fmt.Sprintf("%f", s.DBLow), 42 | fmt.Sprintf("%f", s.DBHigh), 43 | fmt.Sprintf("%f", s.DBAvg), 44 | fmt.Sprintf("%d", s.SampleCount), 45 | }); err != nil { 46 | glog.Warningf("error while writing CSV line: %s\n", err) 47 | } 48 | 49 | w.Flush() 50 | if err := w.Error(); err != nil { 51 | glog.Warningf("error flushing CSV: %s\n", err) 52 | } 53 | } 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /export/export.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/hb9tf/spectre/sdr" 7 | ) 8 | 9 | type Exporter interface { 10 | Write(context.Context, <-chan sdr.Sample) error 11 | } 12 | -------------------------------------------------------------------------------- /export/spectreserver.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/golang/glog" 13 | "github.com/hb9tf/spectre/sdr" 14 | ) 15 | 16 | const ( 17 | contentType = "application/json" 18 | spectreEndpoint = "spectre/v1/collect" 19 | defaultSendSampleAmount = 100 20 | ) 21 | 22 | type SpectreServer struct { 23 | Server string 24 | SendSamplesAmount int 25 | } 26 | 27 | func (s *SpectreServer) Write(ctx context.Context, samples <-chan sdr.Sample) error { 28 | 29 | type collectResponse struct { 30 | Status string `json:"status"` 31 | SampleCount int `json:"sampleCount"` 32 | } 33 | 34 | sendSamplesAmount := defaultSendSampleAmount 35 | if s.SendSamplesAmount > 0 { 36 | sendSamplesAmount = s.SendSamplesAmount 37 | } 38 | 39 | var samplesToSend []sdr.Sample 40 | for sample := range samples { 41 | samplesToSend = append(samplesToSend, sample) 42 | if len(samplesToSend) < sendSamplesAmount { 43 | continue // we haven't collected enough samples to send yet 44 | } 45 | 46 | body, err := json.Marshal(samplesToSend) 47 | if err != nil { 48 | glog.Warningf("error marshalling sample to JSON: %s\n", err) 49 | continue 50 | } 51 | 52 | resp, err := http.Post(fmt.Sprintf("%s/%s", strings.TrimRight(s.Server, "/"), spectreEndpoint), contentType, bytes.NewBuffer(body)) 53 | if err != nil { 54 | glog.Warningf("error POSTing sample: %s\n", err) 55 | continue 56 | } 57 | respBody, err := io.ReadAll(resp.Body) 58 | if err != nil { 59 | glog.Warningf("error reading POST body: %s\n", err) 60 | } 61 | 62 | collectResponseBody := collectResponse{} 63 | json.Unmarshal(respBody, &collectResponseBody) 64 | glog.Infof("submitted %d samples to server %s", collectResponseBody.SampleCount, s.Server) 65 | 66 | resp.Body.Close() 67 | 68 | samplesToSend = nil 69 | } 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /export/sql.go: -------------------------------------------------------------------------------- 1 | package export 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | 8 | "github.com/golang/glog" 9 | 10 | "github.com/hb9tf/spectre/sdr" 11 | ) 12 | 13 | const ( 14 | sqlSampleCountInfo = 1000 15 | 16 | sqlCreateTableTmpl = `CREATE TABLE IF NOT EXISTS spectre ( 17 | "ID" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 18 | "Identifier" TEXT NOT NULL, 19 | "Source" TEXT NOT NULL, 20 | "FreqCenter" INTEGER, 21 | "FreqLow" INTEGER, 22 | "FreqHigh" INTEGER, 23 | "DBHigh" REAL, 24 | "DBLow" REAL, 25 | "DBAvg" REAL, 26 | "SampleCount" INTEGER, 27 | "Start" INTEGER, 28 | "End" INTEGER 29 | );` 30 | sqlInsertSampleTmpl = `INSERT INTO spectre ( 31 | Identifier, 32 | Source, 33 | FreqCenter, 34 | FreqLow, 35 | FreqHigh, 36 | DBHigh, 37 | DBLow, 38 | DBAvg, 39 | SampleCount, 40 | Start, 41 | End 42 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);` 43 | ) 44 | 45 | type SQL struct { 46 | DB *sql.DB 47 | } 48 | 49 | func (s *SQL) Write(ctx context.Context, samples <-chan sdr.Sample) error { 50 | if err := sqlCreateTableIfNotExists(s.DB); err != nil { 51 | return fmt.Errorf("unable to create table: %s", err) 52 | } 53 | 54 | counts := map[string]int64{ 55 | "error": 0, 56 | "success": 0, 57 | "total": 0, 58 | } 59 | for sample := range samples { 60 | counts["total"] += 1 61 | if err := sqlInsertSample(s.DB, sample); err != nil { 62 | counts["error"] += 1 63 | glog.Warningf("error storing in sqlite DB: %s\n", err) 64 | continue 65 | } 66 | counts["success"] += 1 67 | if counts["total"]%sqlSampleCountInfo == 0 { 68 | glog.Infof("Sample export counts: %+v\n", counts) 69 | } 70 | } 71 | 72 | return nil 73 | } 74 | 75 | func sqlCreateTableIfNotExists(db *sql.DB) error { 76 | statement, err := db.Prepare(sqlCreateTableTmpl) 77 | if err != nil { 78 | return err 79 | } 80 | if _, err := statement.Exec(); err != nil { 81 | return err 82 | } 83 | 84 | return nil 85 | } 86 | 87 | func sqlInsertSample(db *sql.DB, s sdr.Sample) error { 88 | statement, err := db.Prepare(sqlInsertSampleTmpl) 89 | if err != nil { 90 | return err 91 | } 92 | if _, err := statement.Exec(s.Identifier, s.Source, s.FreqCenter, s.FreqLow, s.FreqHigh, s.DBHigh, s.DBLow, s.DBAvg, s.SampleCount, s.Start.UnixMilli(), s.End.UnixMilli()); err != nil { 93 | return err 94 | } 95 | 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /extraction/extraction.go: -------------------------------------------------------------------------------- 1 | package extraction 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "image" 8 | "image/color" 9 | "image/draw" 10 | "math" 11 | "time" 12 | 13 | "github.com/golang/glog" 14 | "golang.org/x/image/font" 15 | "golang.org/x/image/font/basicfont" 16 | "golang.org/x/image/math/fixed" 17 | ) 18 | 19 | var ( 20 | // Colors defining the gradient in the heatmap. The higher the index, the warmer. 21 | colors = map[int]color.RGBA{ 22 | 0: {0, 0, 0, 255}, // black 23 | 1: {0, 0, 255, 255}, // blue 24 | 2: {0, 255, 255, 255}, // cyan 25 | 3: {0, 255, 0, 255}, // green 26 | 4: {255, 255, 0, 255}, // yellow 27 | 5: {255, 0, 0, 255}, // red 28 | 6: {255, 255, 255, 255}, // white 29 | } 30 | 31 | gridColor = color.RGBA{0, 0, 0, 255} // white 32 | gridBackgroundColor = color.RGBA{255, 255, 255, 255} // black 33 | 34 | expSuffixLookup = map[int]string{ 35 | 0: "Hz", // 10^0 36 | 1: "kHz", // 10^3 37 | 2: "MHz", // 10^6 38 | 3: "GHz", // 10^9 39 | 4: "THz", // 10^12 40 | } 41 | ) 42 | 43 | const ( 44 | timeFmt = "2006-01-02T15:04:05" 45 | gridMarginTop = 20 // pixels 46 | gridMarginLeft = 150 // pixels 47 | gridTickLen = 10 // pixel 48 | gridMinStepX = 100 // pixels 49 | gridMinStepY = 20 // pixels 50 | getSampleCountTmpl = `SELECT 51 | COUNT(*) 52 | FROM 53 | spectre 54 | WHERE 55 | Source = ? 56 | AND Identifier LIKE ? 57 | AND FreqLow >= ? 58 | AND FreqHigh <= ? 59 | AND Start >= ? 60 | AND End <= ?;` 61 | // getFreqResolutionTmpl is the sqlite query to get the number of distinct frequencies 62 | // in the DB. This results in the maximum amount of pixels in the X axis we should render. 63 | // This is possible because the frequency centers remain the same across a run. 64 | getFreqResolutionTmpl = `SELECT 65 | COUNT(DISTINCT(FreqCenter)) 66 | FROM 67 | spectre 68 | WHERE 69 | Source = ? 70 | AND Identifier LIKE ? 71 | AND FreqLow >= ? 72 | AND FreqHigh <= ? 73 | AND Start >= ? 74 | AND End <= ?;` 75 | // getTimeResolution is the sqlite query to get the number of distinct timestamps 76 | // for a frequency in the DB. This results in the maximum amount of pixels in the Y 77 | // axis we should render. 78 | // This is more involved because the timestamps are different per frequency. 79 | getTimeResolutionTmpl = `SELECT 80 | COUNT(DISTINCT(Start)) 81 | FROM 82 | spectre AS s 83 | WHERE 84 | s.FreqCenter = ( 85 | SELECT 86 | MIN(FreqCenter) 87 | FROM 88 | spectre 89 | WHERE 90 | Source = ? 91 | AND Identifier LIKE ? 92 | AND FreqLow >= ? 93 | AND FreqHigh <= ? 94 | AND Start >= ? 95 | AND End <= ? 96 | ) 97 | AND Source = ? 98 | AND Identifier LIKE ? 99 | AND Start >= ? 100 | AND End <= ?;` 101 | getImgDataTmpl = `SELECT 102 | MIN(FreqLow), 103 | AVG(FreqCenter), 104 | MAX(FreqHigh), 105 | MAX(DBHigh), 106 | MIN(Start), 107 | MAX(End), 108 | TimeBucket, 109 | FreqBucket 110 | FROM ( 111 | SELECT 112 | FreqLow, 113 | FreqCenter, 114 | FreqHigh, 115 | DBHigh, 116 | Start, 117 | End, 118 | NTILE (?) OVER (ORDER BY Start) TimeBucket, 119 | NTILE (?) OVER (ORDER BY FreqCenter) FreqBucket 120 | FROM 121 | spectre 122 | WHERE 123 | Source = ? 124 | AND Identifier LIKE ? 125 | AND FreqLow >= ? 126 | AND FreqHigh <= ? 127 | AND Start >= ? 128 | AND End <= ? 129 | ORDER BY 130 | TimeBucket ASC, 131 | FreqBucket ASC 132 | ) 133 | GROUP BY TimeBucket, FreqBucket;` 134 | ) 135 | 136 | func GetSampleCount(db *sql.DB, source, identifier string, startFreq, endFreq int64, startTime, endTime time.Time) (int, error) { 137 | if identifier == "" { 138 | identifier = "%" 139 | } 140 | statement, err := db.Prepare(getSampleCountTmpl) 141 | if err != nil { 142 | return 0, err 143 | } 144 | var count int 145 | return count, statement.QueryRow(source, identifier, startFreq, endFreq, startTime.UnixMilli(), endTime.UnixMilli()).Scan(&count) 146 | } 147 | 148 | func GetMaxImageHeight(db *sql.DB, source, identifier string, startFreq, endFreq int64, startTime, endTime time.Time) (int, error) { 149 | if identifier == "" { 150 | identifier = "%" 151 | } 152 | statement, err := db.Prepare(getTimeResolutionTmpl) 153 | if err != nil { 154 | return 0, err 155 | } 156 | var count int 157 | return count, statement.QueryRow(source, identifier, startFreq, endFreq, startTime.UnixMilli(), endTime.UnixMilli(), source, identifier, startTime.UnixMilli(), endTime.UnixMilli()).Scan(&count) 158 | } 159 | 160 | func GetMaxImageWidth(db *sql.DB, source, identifier string, startFreq, endFreq int64, startTime, endTime time.Time) (int, error) { 161 | if identifier == "" { 162 | identifier = "%" 163 | } 164 | statement, err := db.Prepare(getFreqResolutionTmpl) 165 | if err != nil { 166 | return 0, err 167 | } 168 | var count int 169 | return count, statement.QueryRow(source, identifier, startFreq, endFreq, startTime.UnixMilli(), endTime.UnixMilli()).Scan(&count) 170 | } 171 | 172 | // GetColor determines the color of a pixel based on a color gradient and a pixel "level". 173 | // http://www.andrewnoske.com/wiki/Code_-_heatmaps_and_color_gradients 174 | // This is mostly a copy of https://github.com/finfinack/netmap/blob/master/netmap.go. 175 | func GetColor(lvl uint16) color.RGBA { 176 | // Find the first color in the gradient where the "level" is higher than the level we're looking for. 177 | // Then determine how far along we are between the previous and next color in the gradient and use that 178 | // to calculate the color between the two. 179 | for i := 0; i < len(colors); i++ { 180 | currC := colors[i] 181 | currV := uint16(i * math.MaxUint16 / len(colors)) 182 | if lvl < currV { 183 | prevC := colors[int(math.Max(0.0, float64(i-1)))] 184 | diff := uint16(math.Max(0.0, float64(i-1)))*math.MaxUint16/uint16(len(colors)) - currV 185 | fract := 0.0 186 | if diff != 0 { 187 | fract = float64(lvl) - float64(currV)/float64(diff) 188 | } 189 | return color.RGBA{ 190 | uint8(float64(prevC.R-currC.R)*fract + float64(currC.R)), 191 | uint8(float64(prevC.G-currC.G)*fract + float64(currC.G)), 192 | uint8(float64(prevC.B-currC.B)*fract + float64(currC.B)), 193 | uint8(float64(prevC.A-currC.A)*fract + float64(currC.A)), 194 | } 195 | } 196 | } 197 | return colors[len(colors)-1] 198 | } 199 | 200 | func GetReadableFreq(freq int64) string { 201 | exp := 0 202 | for f := float64(freq); f > 1000; f = f / 1000.0 { 203 | exp += 1 204 | } 205 | suffix, ok := expSuffixLookup[exp] 206 | if !ok { 207 | return fmt.Sprintf("%d Hz", freq) 208 | } 209 | return fmt.Sprintf("%.2f %s", float64(freq)/math.Pow(1000, float64(exp)), suffix) 210 | } 211 | 212 | func drawTick(canvas *image.RGBA, start image.Point, length int, horizontal bool) { 213 | for i := 0; i <= length; i++ { 214 | if horizontal { 215 | canvas.SetRGBA(start.X+i, start.Y, gridColor) 216 | } else { 217 | canvas.SetRGBA(start.X, start.Y+i, gridColor) 218 | } 219 | } 220 | } 221 | 222 | func findGridStepSize(step int, horizontal bool) int { 223 | gridMinStep := gridMinStepY 224 | if horizontal { 225 | gridMinStep = gridMinStepX 226 | } 227 | for step > gridMinStep { 228 | n := step / 2 229 | if n < gridMinStep { 230 | return step 231 | } 232 | step = n 233 | } 234 | return step 235 | } 236 | 237 | func DrawGrid(source *image.RGBA, lowFreq, highFreq int64, startTime, endTime time.Time) *image.RGBA { 238 | // Enlarge existing image. 239 | canvas := image.NewRGBA(image.Rectangle{ 240 | Min: image.Point{source.Bounds().Min.X, source.Bounds().Min.Y}, 241 | Max: image.Point{source.Bounds().Max.X - 1 + gridMarginLeft, source.Bounds().Max.Y - 1 + gridMarginTop}, 242 | }) 243 | draw.Draw(canvas, canvas.Bounds(), &image.Uniform{gridBackgroundColor}, canvas.Bounds().Min, draw.Src) 244 | r := canvas.Bounds() 245 | r.Min.X += gridMarginLeft 246 | r.Min.Y += gridMarginTop 247 | draw.Draw(canvas, r, source, source.Bounds().Min, draw.Src) 248 | 249 | // Draw grid. 250 | 251 | // Draw X ticks. 252 | xStep := findGridStepSize(source.Bounds().Max.X, true) 253 | for i := source.Bounds().Min.X; i < source.Bounds().Max.X; i += xStep { 254 | // Draw the tick. 255 | drawTick(canvas, image.Point{ 256 | canvas.Bounds().Min.X + gridMarginLeft + i, 257 | canvas.Bounds().Min.Y + gridMarginTop - gridTickLen, 258 | }, gridTickLen, false) 259 | // Label the tick. 260 | point := fixed.Point26_6{ 261 | X: fixed.Int26_6((canvas.Bounds().Min.X + gridMarginLeft + i + 5) * 64), 262 | Y: fixed.Int26_6((canvas.Bounds().Min.Y + gridMarginTop - 2) * 64), 263 | } 264 | d := &font.Drawer{ 265 | Dst: canvas, 266 | Src: image.NewUniform(gridColor), 267 | Face: basicfont.Face7x13, 268 | Dot: point, 269 | } 270 | freq := lowFreq + ((int64(i) * (highFreq - lowFreq)) / int64(source.Bounds().Max.X)) 271 | d.DrawString(GetReadableFreq(freq)) 272 | } 273 | 274 | // Draw Y ticks. 275 | yStep := findGridStepSize(source.Bounds().Max.Y, false) 276 | for i := source.Bounds().Min.Y; i < source.Bounds().Max.Y; i += yStep { 277 | // Draw the tick. 278 | drawTick(canvas, image.Point{ 279 | canvas.Bounds().Min.X + gridMarginLeft - gridTickLen, 280 | canvas.Bounds().Min.Y + gridMarginTop + i, 281 | }, gridTickLen, true) 282 | // Label the tick. 283 | timePoint := fixed.Point26_6{ 284 | X: fixed.Int26_6((canvas.Bounds().Min.X + 5) * 64), 285 | Y: fixed.Int26_6((canvas.Bounds().Min.Y + gridMarginTop + i + 17) * 64), 286 | } 287 | timeDrawer := &font.Drawer{ 288 | Dst: canvas, 289 | Src: image.NewUniform(gridColor), 290 | Face: basicfont.Face7x13, 291 | Dot: timePoint, 292 | } 293 | durPoint := fixed.Point26_6{ 294 | X: fixed.Int26_6((canvas.Bounds().Min.X + 5) * 64), 295 | Y: fixed.Int26_6((canvas.Bounds().Min.Y + gridMarginTop + i + 5) * 64), 296 | } 297 | durDrawer := &font.Drawer{ 298 | Dst: canvas, 299 | Src: image.NewUniform(gridColor), 300 | Face: basicfont.Face7x13, 301 | Dot: durPoint, 302 | } 303 | t := (int64(i) * endTime.Sub(startTime).Milliseconds()) / int64(source.Bounds().Max.Y) 304 | dur, _ := time.ParseDuration(fmt.Sprintf("%dms", t)) 305 | timeDrawer.DrawString(startTime.Add(dur).Format(timeFmt)) 306 | durDrawer.DrawString(dur.String()) 307 | } 308 | 309 | return canvas 310 | } 311 | 312 | type FilterOptions struct { 313 | SDR string 314 | Identifier string 315 | StartFreq int64 316 | EndFreq int64 317 | StartTime time.Time 318 | EndTime time.Time 319 | } 320 | 321 | type ImageOptions struct { 322 | Height int 323 | Width int 324 | 325 | AddGrid bool 326 | } 327 | 328 | type RenderRequest struct { 329 | Filter *FilterOptions 330 | Image *ImageOptions 331 | } 332 | 333 | type SourceMetadata struct { 334 | LowFreq int64 335 | HighFreq int64 336 | StartTime time.Time 337 | EndTime time.Time 338 | } 339 | 340 | type RenderMetadata struct { 341 | ImageHeight int 342 | ImageWidth int 343 | FreqPerPixel float64 344 | SecPerPixel float64 345 | } 346 | 347 | type RenderResult struct { 348 | Image image.Image 349 | 350 | SourceMeta *SourceMetadata 351 | ImageMeta *RenderMetadata 352 | } 353 | 354 | func Render(db *sql.DB, req *RenderRequest) (*RenderResult, error) { 355 | identifier := req.Filter.Identifier 356 | if identifier == "" { 357 | identifier = "%" 358 | } 359 | 360 | count, err := GetSampleCount(db, req.Filter.SDR, identifier, req.Filter.StartFreq, req.Filter.EndFreq, req.Filter.StartTime, req.Filter.EndTime) 361 | if err != nil { 362 | return nil, fmt.Errorf("unable to get sample count from DB: %s", err) 363 | } 364 | if count == 0 { 365 | return nil, errors.New("there are no samples in the DB matching the given filters") 366 | } 367 | 368 | maxImgHeight, err := GetMaxImageHeight(db, req.Filter.SDR, identifier, req.Filter.StartFreq, req.Filter.EndFreq, req.Filter.StartTime, req.Filter.EndTime) 369 | if err != nil { 370 | return nil, fmt.Errorf("unable to query sqlite DB to determine image height: %s", err) 371 | } 372 | switch { 373 | case maxImgHeight == 0: 374 | return nil, errors.New("unable to determine optimal/maximal image height") 375 | case req.Image.Height == 0: 376 | req.Image.Height = maxImgHeight 377 | case req.Image.Height > 0 && req.Image.Height > maxImgHeight: 378 | glog.Warningf("-imgHeight is set to %d which is more than what the data in the sqlite DB can provide. Reducing image height to %d pixels\n", req.Image.Height, maxImgHeight) 379 | req.Image.Height = maxImgHeight 380 | } 381 | maxImgWidth, err := GetMaxImageWidth(db, req.Filter.SDR, identifier, req.Filter.StartFreq, req.Filter.EndFreq, req.Filter.StartTime, req.Filter.EndTime) 382 | if err != nil { 383 | return nil, fmt.Errorf("unable to query sqlite DB to determine image width: %s", err) 384 | } 385 | switch { 386 | case maxImgWidth == 0: 387 | return nil, errors.New("unable to determine optimal/maximal image height") 388 | case req.Image.Width == 0: 389 | req.Image.Width = maxImgWidth 390 | case req.Image.Width > 0 && req.Image.Width > maxImgWidth: 391 | glog.Warningf("-imgWidth is set to %d which is more than what the data in the sqlite DB can provide. Reducing image width to %d pixels\n", req.Image.Width, maxImgWidth) 392 | req.Image.Width = maxImgWidth 393 | } 394 | 395 | statement, err := db.Prepare(getImgDataTmpl) 396 | if err != nil { 397 | return nil, err 398 | } 399 | imgData, err := statement.Query(req.Image.Height, req.Image.Width, req.Filter.SDR, identifier, req.Filter.StartFreq, req.Filter.EndFreq, req.Filter.StartTime.UnixMilli(), req.Filter.EndTime.UnixMilli()) 400 | if err != nil { 401 | return nil, err 402 | } 403 | 404 | lowFreq := int64(math.MaxInt64) 405 | highFreq := int64(0) 406 | globalMinDB := float32(1000) // assuming no dB value will be higher than this so it constantly gets corrected downwards 407 | globalMaxDB := float32(-1000) // assuming no dB value will be lower than this so it constantly gets corrected upwards 408 | sTime := time.Unix(0, math.MaxInt64) 409 | var eTime time.Time 410 | 411 | img := map[int]map[int]float32{} 412 | for imgData.Next() { 413 | var freqLow, freqHigh int64 414 | var timeStart, timeEnd int64 415 | var freqCenter float64 416 | var db float32 417 | var rowIdx, colIdx int 418 | if err := imgData.Scan(&freqLow, &freqCenter, &freqHigh, &db, &timeStart, &timeEnd, &rowIdx, &colIdx); err != nil { 419 | glog.Warningf("unable to get sample from DB: %s\n", err) 420 | continue 421 | } 422 | 423 | start := time.Unix(0, timeStart*int64(time.Millisecond)) 424 | if start.Before(sTime) { 425 | sTime = start 426 | } 427 | end := time.Unix(0, timeEnd*int64(time.Millisecond)) 428 | if end.After(eTime) { 429 | eTime = end 430 | } 431 | 432 | if db < globalMinDB { 433 | globalMinDB = db 434 | } 435 | if db > globalMaxDB { 436 | globalMaxDB = db 437 | } 438 | if freqLow < lowFreq { 439 | lowFreq = freqLow 440 | } 441 | if freqHigh > highFreq { 442 | highFreq = freqHigh 443 | } 444 | 445 | if _, ok := img[rowIdx]; !ok { 446 | img[rowIdx] = map[int]float32{} 447 | } 448 | img[rowIdx][colIdx] = db 449 | } 450 | imgData.Close() 451 | 452 | // Create image canvas. 453 | canvas := image.NewRGBA(image.Rectangle{ 454 | Min: image.Point{0, 0}, 455 | Max: image.Point{req.Image.Width, req.Image.Height}, 456 | }) 457 | 458 | // Draw waterfall. 459 | dbRange := globalMaxDB - globalMinDB 460 | minlvl := uint16(math.MaxUint16) 461 | maxlvl := uint16(0) 462 | for rowIdx, row := range img { 463 | for columnIdx, db := range row { 464 | lvl := uint16((db - globalMinDB) * math.MaxUint16 / dbRange) 465 | if lvl < minlvl { 466 | minlvl = lvl 467 | } 468 | if lvl > maxlvl { 469 | maxlvl = lvl 470 | } 471 | canvas.SetRGBA(columnIdx, rowIdx, GetColor(lvl)) 472 | } 473 | } 474 | 475 | // Draw grid. 476 | if req.Image.AddGrid { 477 | canvas = DrawGrid(canvas, lowFreq, highFreq, sTime, eTime) 478 | } 479 | 480 | return &RenderResult{ 481 | Image: canvas, 482 | SourceMeta: &SourceMetadata{ 483 | LowFreq: lowFreq, 484 | HighFreq: highFreq, 485 | StartTime: sTime, 486 | EndTime: eTime, 487 | }, 488 | ImageMeta: &RenderMetadata{ 489 | ImageHeight: req.Image.Height, 490 | ImageWidth: req.Image.Width, 491 | FreqPerPixel: float64(highFreq-lowFreq) / float64(req.Image.Width), 492 | SecPerPixel: eTime.Sub(sTime).Seconds() / float64(req.Image.Height), 493 | }, 494 | }, nil 495 | } 496 | -------------------------------------------------------------------------------- /filter/filter.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import "github.com/hb9tf/spectre/sdr" 4 | 5 | type Filterer interface { 6 | ShouldIgnore(*sdr.Sample) bool 7 | } 8 | 9 | func Filter(input <-chan sdr.Sample, output chan<- sdr.Sample, filters []Filterer) error { 10 | for s := range input { 11 | skip := false 12 | for _, f := range filters { 13 | skip = f.ShouldIgnore(&s) 14 | } 15 | if skip { 16 | continue 17 | } 18 | output <- s 19 | } 20 | return nil 21 | } 22 | 23 | type FilterFreq struct { 24 | FreqHigh int64 25 | FreqLow int64 26 | } 27 | 28 | func (f *FilterFreq) ShouldIgnore(s *sdr.Sample) bool { 29 | // Check if low freq of sample is higher than what we want to include. 30 | if s.FreqLow > f.FreqHigh { 31 | return true 32 | } 33 | // Check if high freq of sample is lower than what we want to include. 34 | if s.FreqHigh < f.FreqLow { 35 | return true 36 | } 37 | return false 38 | } 39 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hb9tf/spectre 2 | 3 | go 1.23.4 4 | 5 | require ( 6 | github.com/gin-gonic/gin v1.10.0 7 | github.com/go-sql-driver/mysql v1.8.1 8 | github.com/golang/glog v1.2.4 9 | github.com/google/uuid v1.6.0 10 | github.com/mattn/go-sqlite3 v1.14.24 11 | golang.org/x/image v0.23.0 12 | ) 13 | 14 | require ( 15 | filippo.io/edwards25519 v1.1.0 // indirect 16 | github.com/bytedance/sonic v1.12.6 // indirect 17 | github.com/bytedance/sonic/loader v0.2.1 // indirect 18 | github.com/cloudwego/base64x v0.1.4 // indirect 19 | github.com/cloudwego/iasm v0.2.0 // indirect 20 | github.com/gabriel-vasile/mimetype v1.4.7 // indirect 21 | github.com/gin-contrib/sse v0.1.0 // indirect 22 | github.com/go-playground/locales v0.14.1 // indirect 23 | github.com/go-playground/universal-translator v0.18.1 // indirect 24 | github.com/go-playground/validator/v10 v10.23.0 // indirect 25 | github.com/goccy/go-json v0.10.4 // indirect 26 | github.com/json-iterator/go v1.1.12 // indirect 27 | github.com/klauspost/cpuid/v2 v2.2.9 // indirect 28 | github.com/leodido/go-urn v1.4.0 // indirect 29 | github.com/mattn/go-isatty v0.0.20 // indirect 30 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 31 | github.com/modern-go/reflect2 v1.0.2 // indirect 32 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 33 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 34 | github.com/ugorji/go/codec v1.2.12 // indirect 35 | golang.org/x/arch v0.12.0 // indirect 36 | golang.org/x/crypto v0.36.0 // indirect 37 | golang.org/x/net v0.38.0 // indirect 38 | golang.org/x/sys v0.31.0 // indirect 39 | golang.org/x/text v0.23.0 // indirect 40 | google.golang.org/protobuf v1.36.0 // indirect 41 | gopkg.in/yaml.v3 v3.0.1 // indirect 42 | ) 43 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk= 4 | github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= 5 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 6 | github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= 7 | github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 8 | github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= 9 | github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 10 | github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= 11 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 12 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= 16 | github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= 17 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 18 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 19 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= 20 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 21 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 22 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 23 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 24 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 25 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 26 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 27 | github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= 28 | github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 29 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 30 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 31 | github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= 32 | github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 33 | github.com/golang/glog v1.2.4 h1:CNNw5U8lSiiBk7druxtSHHTsRWcxKoac6kZKm2peBBc= 34 | github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= 35 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 36 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 37 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 38 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 39 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 40 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 41 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 42 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 43 | github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= 44 | github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= 45 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 46 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 47 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 48 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 49 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 50 | github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 51 | github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 52 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 53 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 54 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 55 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 56 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 57 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 58 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 59 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 60 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 61 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 62 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 63 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 64 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 65 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 66 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 67 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 68 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 69 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 70 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 71 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 72 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 73 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 74 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 75 | golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= 76 | golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 77 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 78 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 79 | golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= 80 | golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= 81 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 82 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 83 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 84 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 85 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 86 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 87 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 88 | google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ= 89 | google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 90 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 91 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 92 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 93 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 94 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 95 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 96 | -------------------------------------------------------------------------------- /render/render.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "image/jpeg" 9 | "image/png" 10 | "math" 11 | "os" 12 | "strings" 13 | "time" 14 | 15 | "github.com/go-sql-driver/mysql" 16 | "github.com/golang/glog" 17 | 18 | "github.com/hb9tf/spectre/extraction" 19 | 20 | // Blind import support for sqlite3 used by sqlite.go. 21 | _ "github.com/mattn/go-sqlite3" 22 | ) 23 | 24 | // Flags 25 | var ( 26 | source = flag.String("source", "sqlite", "Source type, e.g. sqlite or mysql.") 27 | // SQLite 28 | sqliteFile = flag.String("sqliteFile", "/tmp/spectre", "File path of the sqlite DB file to use.") 29 | 30 | // MySQL 31 | mysqlServer = flag.String("mysqlServer", "127.0.0.1:3306", "MySQL TCP server endpoint to connect to (IP/DNS and port).") 32 | mysqlUser = flag.String("mysqlUser", "", "MySQL DB user.") 33 | mysqlPasswordFile = flag.String("mysqlPasswordFile", "", "Path to the file containing the password for the MySQL user.") 34 | mysqlDBName = flag.String("mysqlDBName", "spectre", "Name of the DB to use.") 35 | 36 | // Filter options 37 | sdr = flag.String("sdr", "", "Source type, e.g. rtlsdr or hackrf.") 38 | identifier = flag.String("identifier", "", "Identifier of the station to render the data for (typically a UUID4).") 39 | startFreq = flag.Int64("startFreq", 0, "Select samples starting with this frequency in Hz.") 40 | endFreq = flag.Int64("endFreq", math.MaxInt64, "Select samples up to this frequency in Hz.") 41 | startTimeRaw = flag.String("startTime", "1970-01-01T00:00:00", "Select samples collected after this time. Format: 2006-01-02T15:04:05") 42 | endTimeRaw = flag.String("endTime", "2100-01-02T15:04:05", "Select samples collected before this time. Format: 2006-01-02T15:04:05") 43 | 44 | // Image rendering options 45 | addGrid = flag.Bool("addGrid", true, "Adds a grid to the output image for reference when set.") 46 | imgPath = flag.String("imgPath", "/tmp/out.jpg", "Path where the rendered image should be written to.") 47 | imgWidth = flag.Int("imgWidth", 0, "Width of output image in pixels.") 48 | imgHeight = flag.Int("imgHeight", 0, "Height of output image in pixels.") 49 | ) 50 | 51 | const ( 52 | timeFmt = "2006-01-02T15:04:05" 53 | ) 54 | 55 | func main() { 56 | // Set defaults for glog flags. Can be overridden via cmdline. 57 | flag.Set("logtostderr", "false") 58 | flag.Set("stderrthreshold", "WARNING") 59 | flag.Set("v", "1") 60 | // Parse flags globally. 61 | flag.Parse() 62 | 63 | startTime, err := time.Parse(timeFmt, *startTimeRaw) 64 | if err != nil { 65 | glog.Exitf("unable to parse startTime (value: %q, format: %q): %s", *startTimeRaw, timeFmt, err) 66 | } 67 | endTime, err := time.Parse(timeFmt, *endTimeRaw) 68 | if err != nil { 69 | glog.Exitf("unable to parse endTime (value: %q, format: %q): %s", *endTimeRaw, timeFmt, err) 70 | } 71 | 72 | var db *sql.DB 73 | switch strings.ToLower(*source) { 74 | case "sqlite": 75 | if _, err := os.Stat(*sqliteFile); errors.Is(err, os.ErrNotExist) { 76 | glog.Exitf("unable to open sqlite DB %q: %s", sqliteFile, err) 77 | } 78 | var err error 79 | db, err = sql.Open("sqlite3", *sqliteFile) 80 | if err != nil { 81 | glog.Exitf("unable to open sqlite DB %q: %s", *sqliteFile, err) 82 | } 83 | case "mysql": 84 | pass, err := os.ReadFile(*mysqlPasswordFile) 85 | if err != nil { 86 | glog.Exitf("unable to read MySQL password file %q: %s\n", *mysqlPasswordFile, err) 87 | } 88 | cfg := mysql.Config{ 89 | User: *mysqlUser, 90 | Passwd: strings.TrimSpace(string(pass)), 91 | Net: "tcp", 92 | Addr: *mysqlServer, 93 | DBName: *mysqlDBName, 94 | } 95 | db, err = sql.Open("mysql", cfg.FormatDSN()) 96 | if err != nil { 97 | glog.Exitf("unable to open MySQL DB %q: %s", *mysqlServer, err) 98 | } 99 | db.SetConnMaxLifetime(3 * time.Minute) 100 | db.SetMaxOpenConns(10) 101 | db.SetMaxIdleConns(10) 102 | default: 103 | glog.Exitf("%q is not a supported source, pick one of: sqlite", *source) 104 | } 105 | 106 | result, err := extraction.Render(db, &extraction.RenderRequest{ 107 | Image: &extraction.ImageOptions{ 108 | Height: *imgHeight, 109 | Width: *imgWidth, 110 | AddGrid: *addGrid, 111 | }, 112 | Filter: &extraction.FilterOptions{ 113 | SDR: *sdr, 114 | Identifier: *identifier, 115 | StartFreq: *startFreq, 116 | EndFreq: *endFreq, 117 | StartTime: startTime, 118 | EndTime: endTime, 119 | }, 120 | }) 121 | if err != nil { 122 | glog.Exitf("Unable to render image: %s\n", err) 123 | } 124 | 125 | fmt.Println("Selected source metadata:") 126 | fmt.Printf(" - Low frequency: %s\n", extraction.GetReadableFreq(result.SourceMeta.LowFreq)) 127 | fmt.Printf(" - High frequency: %s\n", extraction.GetReadableFreq(result.SourceMeta.HighFreq)) 128 | fmt.Printf(" - Start time: %s (%d)\n", result.SourceMeta.StartTime.Format(timeFmt), result.SourceMeta.StartTime.Unix()) 129 | fmt.Printf(" - End time: %s (%d)\n", result.SourceMeta.EndTime.Format(timeFmt), result.SourceMeta.EndTime.Unix()) 130 | fmt.Printf(" - Duration: %s\n", result.SourceMeta.EndTime.Sub(result.SourceMeta.StartTime)) 131 | fmt.Printf("Rendered image (%d x %d)\n", result.ImageMeta.ImageWidth, result.ImageMeta.ImageHeight) 132 | fmt.Printf(" - Frequency resolution: %s per pixel\n", extraction.GetReadableFreq(int64(result.ImageMeta.FreqPerPixel))) 133 | fmt.Printf(" - Time resolution: %.2f seconds per pixel\n", result.ImageMeta.SecPerPixel) 134 | 135 | fmt.Printf("Writing image to %q\n", *imgPath) 136 | f, _ := os.Create(*imgPath) 137 | defer f.Close() 138 | switch { 139 | case strings.HasSuffix(*imgPath, ".png"): 140 | png.Encode(f, result.Image) 141 | case strings.HasSuffix(*imgPath, ".jpg"): 142 | jpeg.Encode(f, result.Image, &jpeg.Options{Quality: jpeg.DefaultQuality}) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /sdr/sdr.go: -------------------------------------------------------------------------------- 1 | package sdr 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Sample struct { 8 | // Metadata 9 | Identifier string 10 | Source string 11 | 12 | // Radio Data 13 | FreqCenter int64 14 | FreqLow int64 15 | FreqHigh int64 16 | DBHigh float64 17 | DBLow float64 18 | DBAvg float64 19 | SampleCount int64 20 | Start time.Time 21 | End time.Time 22 | } 23 | 24 | type SDR interface { 25 | Name() string 26 | Sweep(opts *Options, samples chan<- Sample) error 27 | } 28 | 29 | type Options struct { 30 | // LowFreq is the lower frequency to start the sweeps with in Hz. 31 | LowFreq int64 32 | // LowFreq is the upper frequency to end the sweeps with in Hz. 33 | HighFreq int64 34 | 35 | // BinSize is the FFT bin width (frequency resolution) in Hz. 36 | // BinSize is a maximum, smaller more convenient bins will be used. 37 | BinSize int64 38 | 39 | // IntegrationInterval is the duration during which to collect information per frequency. 40 | IntegrationInterval time.Duration 41 | } 42 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "database/sql" 7 | "flag" 8 | "image/jpeg" 9 | "image/png" 10 | "math" 11 | "net/http" 12 | "os" 13 | "strings" 14 | "time" 15 | 16 | "github.com/gin-gonic/gin" 17 | "github.com/go-sql-driver/mysql" 18 | "github.com/golang/glog" 19 | 20 | "github.com/hb9tf/spectre/export" 21 | "github.com/hb9tf/spectre/extraction" 22 | "github.com/hb9tf/spectre/sdr" 23 | 24 | // Blind import support for sqlite3 used by sqlite.go. 25 | _ "github.com/mattn/go-sqlite3" 26 | ) 27 | 28 | var ( 29 | listen = flag.String("listen", ":8080", "") 30 | storage = flag.String("storage", "", "Storage solutions to use (one of: sqlite, mysql)") 31 | 32 | // SQLite 33 | sqliteFile = flag.String("sqliteFile", "/tmp/spectre", "File path of the sqlite DB file to use.") 34 | 35 | // MySQL 36 | mysqlServer = flag.String("mysqlServer", "127.0.0.1:3306", "MySQL TCP server endpoint to connect to (IP/DNS and port).") 37 | mysqlUser = flag.String("mysqlUser", "", "MySQL DB user.") 38 | mysqlPasswordFile = flag.String("mysqlPasswordFile", "", "Path to the file containing the password for the MySQL user.") 39 | mysqlDBName = flag.String("mysqlDBName", "spectre", "Name of the DB to use.") 40 | ) 41 | 42 | const ( 43 | collectEndpoint = "/spectre/v1/collect" 44 | renderEndpoint = "/spectre/v1/render" 45 | ) 46 | 47 | type SpectreServer struct { 48 | Server *http.Server 49 | DB *sql.DB 50 | Samples chan sdr.Sample 51 | } 52 | 53 | func (s *SpectreServer) collectHandler(c *gin.Context) { 54 | samples := []sdr.Sample{} 55 | 56 | if err := c.BindJSON(&samples); err != nil { 57 | c.AbortWithStatus(http.StatusBadRequest) 58 | return 59 | } 60 | 61 | for _, sample := range samples { 62 | s.Samples <- sample 63 | } 64 | 65 | c.JSON(http.StatusOK, gin.H{ 66 | "status": "success", 67 | "sampleCount": len(samples), 68 | }) 69 | } 70 | 71 | func (s *SpectreServer) renderHandler(c *gin.Context) { 72 | type queryParameters struct { 73 | SDR string `form:"sdr"` 74 | Identifier string `form:"identifier"` 75 | StartFreq int64 `form:"startFreq"` 76 | EndFreq int64 `form:"endFreq"` 77 | StartTime int64 `form:"startTime"` 78 | EndTime int64 `form:"endTime"` 79 | AddGrid string `form:"addGrid"` 80 | ImgWidth int `form:"imgWidth"` 81 | ImgHeight int `form:"imgHeight"` 82 | ImageType string `form:"imageType"` 83 | } 84 | 85 | parsedQueryParameters := queryParameters{} 86 | if err := c.BindQuery(&parsedQueryParameters); err != nil { 87 | c.AbortWithError(http.StatusBadRequest, err) 88 | return 89 | } 90 | 91 | var startFreq int64 // default to the lowest possible frequency 92 | if parsedQueryParameters.StartFreq != 0 { 93 | startFreq = parsedQueryParameters.StartFreq 94 | } 95 | 96 | endFreq := int64(math.MaxInt64) // default to the maximum possible frequency 97 | if parsedQueryParameters.EndFreq != 0 { 98 | endFreq = parsedQueryParameters.EndFreq 99 | } 100 | 101 | var startTime time.Time // default to the earliest possible timestamp of a sample 102 | if parsedQueryParameters.StartTime != 0 { 103 | startTime = time.Unix(0, parsedQueryParameters.StartTime*1000000) // from milli to nano 104 | } 105 | 106 | endTime := time.Now().Add(24 * time.Hour) // default to the latest possible timestamp of a sample 107 | if parsedQueryParameters.EndTime != 0 { 108 | endTime = time.Unix(0, parsedQueryParameters.EndTime*1000000) // from milli to nano 109 | } 110 | 111 | addGrid := true 112 | if parsedQueryParameters.AddGrid == "0" || parsedQueryParameters.AddGrid == "false" { 113 | addGrid = false 114 | } 115 | 116 | var imgWidth int 117 | if parsedQueryParameters.ImgWidth != 0 { 118 | imgWidth = parsedQueryParameters.ImgWidth 119 | } 120 | 121 | var imgHeight int 122 | if parsedQueryParameters.ImgHeight != 0 { 123 | imgHeight = parsedQueryParameters.ImgHeight 124 | } 125 | 126 | result, err := extraction.Render(s.DB, &extraction.RenderRequest{ 127 | Image: &extraction.ImageOptions{ 128 | Height: imgHeight, 129 | Width: imgWidth, 130 | AddGrid: addGrid, 131 | }, 132 | Filter: &extraction.FilterOptions{ 133 | SDR: parsedQueryParameters.SDR, 134 | Identifier: parsedQueryParameters.Identifier, 135 | StartFreq: startFreq, 136 | EndFreq: endFreq, 137 | StartTime: startTime, 138 | EndTime: endTime, 139 | }, 140 | }) 141 | if err != nil { 142 | c.AbortWithError(http.StatusBadRequest, err) 143 | return 144 | } 145 | 146 | buf := new(bytes.Buffer) 147 | contentType := "" 148 | switch strings.ToLower(parsedQueryParameters.ImageType) { 149 | case "png": 150 | contentType = "image/png" 151 | png.Encode(buf, result.Image) 152 | default: 153 | contentType = "image/jpeg" 154 | jpeg.Encode(buf, result.Image, &jpeg.Options{Quality: jpeg.DefaultQuality}) 155 | } 156 | 157 | c.Data(http.StatusOK, contentType, buf.Bytes()) 158 | } 159 | 160 | func main() { 161 | ctx := context.Background() 162 | // Set defaults for glog flags. Can be overridden via cmdline. 163 | flag.Set("logtostderr", "false") 164 | flag.Set("stderrthreshold", "WARNING") 165 | flag.Set("v", "1") 166 | // Parse flags globally. 167 | flag.Parse() 168 | 169 | // Exporter and storage setup 170 | var db *sql.DB 171 | var exporter export.Exporter 172 | switch strings.ToLower(*storage) { 173 | case "csv": // CSV is a silent option as it only exports data but can't be used to render. 174 | exporter = &export.CSV{} 175 | case "sqlite": 176 | var err error 177 | db, err = sql.Open("sqlite3", *sqliteFile) 178 | if err != nil { 179 | glog.Exitf("unable to open sqlite DB %q: %s", *sqliteFile, err) 180 | } 181 | exporter = &export.SQL{ 182 | DB: db, 183 | } 184 | case "mysql": 185 | pass, err := os.ReadFile(*mysqlPasswordFile) 186 | if err != nil { 187 | glog.Exitf("unable to read MySQL password file %q: %s\n", *mysqlPasswordFile, err) 188 | } 189 | cfg := mysql.Config{ 190 | User: *mysqlUser, 191 | Passwd: strings.TrimSpace(string(pass)), 192 | Net: "tcp", 193 | Addr: *mysqlServer, 194 | DBName: *mysqlDBName, 195 | } 196 | db, err = sql.Open("mysql", cfg.FormatDSN()) 197 | if err != nil { 198 | glog.Exitf("unable to open MySQL DB %q: %s", *mysqlServer, err) 199 | } 200 | db.SetConnMaxLifetime(3 * time.Minute) 201 | db.SetMaxOpenConns(10) 202 | db.SetMaxIdleConns(10) 203 | exporter = &export.SQL{ 204 | DB: db, 205 | } 206 | default: 207 | glog.Exitf("%q is not a supported export method, pick one of: sqlite, mysql", *storage) 208 | } 209 | 210 | // Export samples. 211 | samples := make(chan sdr.Sample, 1000) 212 | go func() { 213 | if err := exporter.Write(ctx, samples); err != nil { 214 | glog.Fatal(err) 215 | } 216 | }() 217 | 218 | // Configure and run webserver. 219 | gin.SetMode(gin.ReleaseMode) 220 | router := gin.Default() 221 | s := SpectreServer{ 222 | Server: &http.Server{ 223 | Addr: *listen, 224 | Handler: router, // use `http.DefaultServeMux` 225 | }, 226 | DB: db, 227 | Samples: samples, 228 | } 229 | 230 | router.POST(collectEndpoint, s.collectHandler) 231 | router.GET(renderEndpoint, s.renderHandler) 232 | 233 | glog.Fatal(s.Server.ListenAndServe()) 234 | glog.Flush() 235 | } 236 | -------------------------------------------------------------------------------- /waterfall.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hb9tf/spectre/8f4f17ad475f555592882460eeaf4d841f00ff9c/waterfall.jpg --------------------------------------------------------------------------------