First commit

This commit is contained in:
Ian Renton
2025-09-26 22:37:17 +01:00
commit c34821dc9b
11 changed files with 787 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/.idea
/.venv
__pycache__
*.pyc

24
LICENSE Normal file
View File

@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
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 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.
For more information, please refer to <https://unlicense.org>

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# MetaSpot
A utility to aggregate spots from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data.
Work in progress.

432
core/constants.py Normal file
View File

@@ -0,0 +1,432 @@
from data.band import Band
# General software
SOFTWARE_NAME = "Metaspot by M0TRT"
SOFTWARE_VERSION = "0.1"
# Band definitions
BANDS = [
Band(name="160m", start_freq=1800, end_freq=2000, color="#7cfc00", contrast_color="black"),
Band(name="80m", start_freq=3500, end_freq=4000, color="#e550e5", contrast_color="black"),
Band(name="60m", start_freq=5250, end_freq=5410, color="#00008b", contrast_color="white"),
Band(name="40m", start_freq=7000, end_freq=7300, color="#5959ff", contrast_color="white"),
Band(name="30m", start_freq=10100, end_freq=10150, color="#62d962", contrast_color="black"),
Band(name="20m", start_freq=14000, end_freq=14350, color="#f2c40c", contrast_color="black"),
Band(name="17m", start_freq=18068, end_freq=18168, color="#f2f261", contrast_color="black"),
Band(name="15m", start_freq=21000, end_freq=21450, color="#cca166", contrast_color="black"),
Band(name="12m", start_freq=24890, end_freq=24990, color="#b22222", contrast_color="white"),
Band(name="10m", start_freq=28000, end_freq=29700, color="#ff69b4", contrast_color="black"),
Band(name="6m", start_freq=50000, end_freq=54000, color="#FF0000", contrast_color="white"),
Band(name="4m", start_freq=70000, end_freq=70500, color="#cc0044", contrast_color="white"),
Band(name="2m", start_freq=144000, end_freq=148000, color="#FF1493", contrast_color="black"),
Band(name="70cm", start_freq=420000, end_freq=450000, color="#999900", contrast_color="white"),
Band(name="23cm", start_freq=1240000, end_freq=1325000, color="#5AB8C7", contrast_color="black"),
Band(name="13cm", start_freq=2300000, end_freq=2450000, color="#FF7F50", contrast_color="black")]
UNKNOWN_BAND = Band(name="Unknown", start_freq=0, end_freq=0, color="black", contrast_color="white")
# DXCC flags (borrowed from https:#github.com/wavelog/wavelog/blob/master/application/libraries/DxccFlag.php)
DXCC_FLAGS = {
0: "", # DXCC NONE
1: "\U0001F1E8\U0001F1E6", # CANADA
2: "", # ABU AIL IS
3: "\U0001F1E6\U0001F1EB", # AFGHANISTAN
4: "", # AGALEGA & ST BRANDON ISLANDS
5: "\U0001F1E6\U0001F1FD", # ALAND ISLANDS
6: "", # ALASKA
7: "\U0001F1E6\U0001F1F1", # ALBANIA
8: "", # ALDABRA
9: "\U0001F1E6\U0001F1F8", # AMERICAN SAMOA
10: "\U0001F1F9\U0001F1EB", # AMSTERDAM & ST PAUL ISLANDS
11: "", # ANDAMAN & NICOBAR ISLANDS
12: "\U0001F1E6\U0001F1EE", # ANGUILLA
13: "\U0001F1E6\U0001F1F6", # ANTARCTICA
14: "\U0001F1E6\U0001F1F2", # ARMENIA
15: "\U0001F1F7\U0001F1FA", # ASIATIC RUSSIA
16: "", # NEW ZEALAND SUBANTARCTIC ISLANDS
17: "", # AVES ISLAND
18: "\U0001F1E6\U0001F1FF", # AZERBAIJAN
19: "", # BAJO NUEVO
20: "", # BAKER HOWLAND ISLANDS
21: "", # BALEARIC ISLANDS
22: "\U0001F1F5\U0001F1FC", # PALAU
23: "", # BLENHEIM REEF
24: "\U0001F1E7\U0001F1FB", # BOUVET ISLAND
25: "", # BRITISH NORTH BORNEO
26: "", # BRITISH SOMALILAND
27: "\U0001F1E7\U0001F1FE", # BELARUS
28: "\U0001F1E8\U0001F1FB", # CANAL ZONE
29: "", # CANARY ISLANDS
30: "", # CELEBE & MOLUCCA ISLANDS
31: "\U0001F1F0\U0001F1EE", # CENTRAL KIRIBATI
32: "", # CEUTA & MELILLA
33: "", # CHAGOS ISLANDS
34: "", # CHATHAM ISLAND
35: "\U0001F1E8\U0001F1FD", # CHRISTMAS ISLAND
36: "", # CLIPPERTON ISLAND
37: "", # COCOS ISLAND
38: "\U0001F1E8\U0001F1E8", # COCOS (KEELING) ISLAND
39: "", # COMORO ISLANDS
40: "", # CRETE
41: "\U0001F1F9\U0001F1EB", # CROZET ISLAND
42: "", #"DAMAO, DIU"
43: "", # DESECHEO ISLAND
44: "", # DESROCHES
45: "", # DODECANESE
46: "", # EAST MALAYSIA
47: "", # EASTER ISLAND
48: "\U0001F1F0\U0001F1EE", # EASTERN KIRIBATI
49: "\U0001F1EC\U0001F1F6", # EQUATORIAL GUINEA
50: "\U0001F1F2\U0001F1FD", # MEXICO
51: "\U0001F1EA\U0001F1F7", # ERITREA
52: "\U0001F1EA\U0001F1EA", # ESTONIA
53: "\U0001F1EA\U0001F1F9", # ETHIOPIA
54: "\U0001F1F7\U0001F1FA", # EUROPEAN RUSSIA
55: "", # FARQUHAR
56: "", # FERNANDO DE NORONHA
57: "", # FRENCH EQUATORIAL AFRICA
58: "", # FRENCH INDO-CHINA
59: "", # FRENCH WEST AFRICA
60: "\U0001F1E7\U0001F1F8", # BAHAMAS
61: "", # FRANZ JOSEF LAND
62: "\U0001F1E7\U0001F1E7", # BARBADOS
63: "\U0001F1EC\U0001F1EB", # FRENCH GUIANA
64: "\U0001F1E7\U0001F1F2", # BERMUDA
65: "\U0001F1FB\U0001F1EC", # BRITISH VIRGIN ISLANDS
66: "\U0001F1E7\U0001F1FF", # BELIZE
67: "", # FRENCH INDIA
68: "", # KUWAIT/SAUDI ARABIA NEUT. ZONE
69: "\U0001F1F0\U0001F1FE", # CAYMAN ISLANDS
70: "\U0001F1E8\U0001F1FA", # CUBA
71: "", # GALAPAGOS ISLANDS
72: "\U0001F1E9\U0001F1F4", # DOMINICAN REPUBLIC
74: "\U0001F1F8\U0001F1FB", # EL SALVADOR
75: "\U0001F1EC\U0001F1EA", # GEORGIA
76: "\U0001F1EC\U0001F1F9", # GUATEMALA
77: "\U0001F1EC\U0001F1E9", # GRENADA
78: "\U0001F1ED\U0001F1F9", # HAITI
79: "\U0001F1EC\U0001F1F5", # GUADELOUPE
80: "\U0001F1ED\U0001F1F3", # HONDURAS
81: "", # GERMANY
82: "\U0001F1EF\U0001F1F2", # JAMAICA
84: "\U0001F1F2\U0001F1F6", # MARTINIQUE
85: "", #"BONAIRE, CURACAO (NETH ANTILLES)"
86: "\U0001F1F3\U0001F1EE", # NICARAGUA
88: "\U0001F1F5\U0001F1E6", # PANAMA
89: "\U0001F1F9\U0001F1E8", # TURKS & CAICOS ISLANDS
90: "\U0001F1F9\U0001F1F9", # TRINIDAD & TOBAGO
91: "\U0001F1E6\U0001F1FC", # ARUBA
93: "", # GEYSER REEF
94: "\U0001F1E6\U0001F1EC", # ANTIGUA & BARBUDA
95: "\U0001F1E9\U0001F1F2", # DOMINICA
96: "\U0001F1F2\U0001F1F8", # MONTSERRAT
97: "\U0001F1F1\U0001F1E8", # SAINT LUCIA
98: "\U0001F1FB\U0001F1E8", # SAINT VINCENT
99: "", # GLORIOSO ISLAND
100: "\U0001F1E6\U0001F1F7", # ARGENTINA
101: "", # GOA
102: "", # GOLD COAST TOGOLAND
103: "\U0001F1EC\U0001F1FA", # GUAM
104: "\U0001F1E7\U0001F1F4", # BOLIVIA
105: "", # GUANTANAMO BAY
106: "\U0001F1EC\U0001F1EC", # GUERNSEY
107: "\U0001F1EC\U0001F1F3", # GUINEA
108: "\U0001F1E7\U0001F1F7", # BRAZIL
109: "\U0001F1EC\U0001F1FC", # GUINEA-BISSAU
110: "", # HAWAII
111: "\U0001F1ED\U0001F1F2", # HEARD ISLAND
112: "\U0001F1E8\U0001F1F1", # CHILE
113: "", # IFNI
114: "\U0001F1EE\U0001F1F2", # ISLE OF MAN
115: "", # ITALIAN SOMALI
116: "\U0001F1E8\U0001F1F4", # COLOMBIA
117: "", # ITU HQ
118: "", # JAN MAYEN
119: "", # JAVA
120: "\U0001F1EA\U0001F1E8", # ECUADOR
122: "\U0001F1EF\U0001F1EA", # JERSEY
123: "", # JOHNSTON ISLAND
124: "", #"JUAN DE NOVA, EUROPA"
125: "", # JUAN FERNANDEZ ISLANDS
126: "", # KALININGRAD
127: "", # KAMARAN ISLANDS
128: "", # KARELO-FINN REP
129: "\U0001F1EC\U0001F1FE", # GUYANA
130: "\U0001F1F0\U0001F1FF", # KAZAKHSTAN
131: "\U0001F1F9\U0001F1EB", # KERGUELEN ISLAND
132: "\U0001F1F5\U0001F1FE", # PARAGUAY
133: "", # KERMADEC ISLAND
134: "", # KINGMAN REEF
135: "\U0001F1F0\U0001F1EC", # KYRGYZSTAN
136: "\U0001F1F5\U0001F1EA", # PERU
137: "\U0001F1F0\U0001F1F7", # REPUBLIC OF KOREA
138: "", # KURE ISLAND
139: "", # KURIA MURIA ISLAND
140: "", # SURINAME
141: "\U0001F1EB\U0001F1F0", # FALKLAND ISLANDS
142: "", # LAKSHADWEEP ISLANDS
143: "\U0001F1F1\U0001F1E6", # LAOS
144: "\U0001F1FA\U0001F1FE", # URUGUAY
145: "\U0001F1F1\U0001F1FB", # LATVIA
146: "\U0001F1F1\U0001F1F9", # LITHUANIA
147: "", # LORD HOWE ISLAND
148: "\U0001F1FB\U0001F1EA", # VENEZUELA
149: "", # AZORES
150: "\U0001F1E6\U0001F1FA", # AUSTRALIA
151: "", # MALYJ VYSOTSKIJ ISLAND
152: "\U0001F1F2\U0001F1F4", # MACAO
153: "", # MACQUARIE ISLAND
154: "", # YEMEN ARAB REPUBLIC
155: "\U0001F1F2\U0001F1FE", # MALAYA
157: "\U0001F1F3\U0001F1F7", # NAURU
158: "\U0001F1FB\U0001F1FA", # VANUATU
159: "\U0001F1F2\U0001F1FB", # MALDIVES
160: "\U0001F1F9\U0001F1F4", # TONGA
161: "", # MALPELO ISLAND
162: "\U0001F1F3\U0001F1E8", # NEW CALEDONIA
163: "\U0001F1F5\U0001F1EC", # PAPUA NEW GUINEA
164: "", # MANCHURIA
165: "\U0001F1F2\U0001F1FA", # MAURITIUS ISLAND
166: "", # MARIANA ISLANDS
167: "", # MARKET REEF
168: "\U0001F1F2\U0001F1ED", # MARSHALL ISLANDS
169: "\U0001F1FE\U0001F1F9", # MAYOTTE
170: "\U0001F1F3\U0001F1FF", # NEW ZEALAND
171: "", # MELLISH REEF
172: "\U0001F1F5\U0001F1F3", # PITCAIRN ISLAND
173: "\U0001F1EB\U0001F1F2", # MICRONESIA
174: "", # MIDWAY ISLAND
175: "\U0001F1F5\U0001F1EB", # FRENCH POLYNESIA
176: "\U0001F1EB\U0001F1EF", # FIJI ISLANDS
177: "", # MINAMI TORISHIMA
178: "", # MINERVA REEF
179: "\U0001F1F2\U0001F1E9", # MOLDOVA
180: "", # MOUNT ATHOS
181: "\U0001F1F2\U0001F1FF", # MOZAMBIQUE
182: "", # NAVASSA ISLAND
183: "", # NETHERLANDS BORNEO
184: "", # NETHERLANDS NEW GUINEA
185: "\U0001F1F8\U0001F1E7", # SOLOMON ISLANDS
186: "", # NEWFOUNDLAND LABRADOR
187: "\U0001F1F3\U0001F1EA", # NIGER
188: "\U0001F1F3\U0001F1FA", # NIUE
189: "\U0001F1F3\U0001F1EB", # NORFOLK ISLAND
190: "\U0001F1FC\U0001F1F8", # SAMOA
191: "\U0001F1E8\U0001F1F0", # NORTH COOK ISLANDS
192: "", # OGASAWARA
193: "", # OKINAWA
194: "", # OKINO TORI-SHIMA
195: "", # ANNOBON
196: "", # PALESTINE (DELETED)
197: "", # PALMYRA & JARVIS ISLANDS
198: "", # PAPUA TERR
199: "", # PETER 1 ISLAND
200: "", # PORTUGUESE TIMOR
201: "", # PRINCE EDWARD & MARION ISLANDS
202: "\U0001F1F5\U0001F1F7", # PUERTO RICO
203: "\U0001F1E6\U0001F1E9", # ANDORRA
204: "", # REVILLAGIGEDO
205: "", # ASCENSION ISLAND
206: "\U0001F1E6\U0001F1F9", # AUSTRIA
207: "", # RODRIGUEZ ISLAND
208: "", # RUANDA-URUNDI
209: "\U0001F1E7\U0001F1EA", # BELGIUM
210: "", # SAAR
211: "", # SABLE ISLAND
212: "\U0001F1E7\U0001F1EC", # BULGARIA
213: "\U0001F1F2\U0001F1EB", # SAINT MARTIN
214: "", # CORSICA
215: "\U0001F1E8\U0001F1FE", # CYPRUS
216: "", # SAN ANDRES ISLAND
217: "", # SAN FELIX ISLANDS
218: "", # CZECHOSLOVAKIA
219: "\U0001F1F8\U0001F1F9", # SAO TOME & PRINCIPE
220: "", # SARAWAK
221: "\U0001F1E9\U0001F1F0", # DENMARK
222: "\U0001F1EB\U0001F1F4", # FAROE ISLANDS
223: "\U0001F3F4\U000E0067\U000E0062\U000E0065\U000E006E\U000E0067\U000E007F", # ENGLAND
224: "\U0001F1EB\U0001F1EE", # FINLAND
225: "", # SARDINIA
226: "", # SAUDI ARABIA/IRAQ NEUT ZONE
227: "\U0001F1EB\U0001F1F7", # FRANCE
228: "", # SERRANA BANK & RONCADOR CAY
229: "\U0001F1E9\U0001F1EA", # GERMAN DEMOCRATIC REPUBLIC
230: "\U0001F1E9\U0001F1EA", # FEDERAL REPUBLIC OF GERMANY
231: "", # SIKKIM
232: "\U0001F1F8\U0001F1F4", # SOMALIA
233: "\U0001F1EC\U0001F1EE", # GIBRALTAR
234: "\U0001F1E8\U0001F1F0", # SOUTH COOK ISLANDS
235: "\U0001F1EC\U0001F1F8", # SOUTH GEORGIA ISLAND
236: "\U0001F1EC\U0001F1F7", # GREECE
237: "\U0001F1EC\U0001F1F1", # GREENLAND
238: "", # SOUTH ORKNEY ISLANDS
239: "\U0001F1ED\U0001F1FA", # HUNGARY
240: "", # SOUTH SANDWICH ISLANDS
241: "", # SOUTH SHETLAND ISLANDS
242: "\U0001F1EE\U0001F1F8", # ICELAND
243: "", # PEOPLES DEM REP OF YEMEN
244: "\U0001F1F8\U0001F1F8", # SOUTHERN SUDAN
245: "\U0001F1EE\U0001F1EA", # IRELAND
246: "", # SOV MILITARY ORDER OF MALTA
247: "", # SPRATLY ISLANDS
248: "\U0001F1EE\U0001F1F9", # ITALY
249: "\U0001F1F0\U0001F1F3", # SAINT KITTS & NEVIS
250: "\U0001F1F8\U0001F1ED", # SAINT HELENA
251: "\U0001F1F1\U0001F1EE", # LIECHTENSTEIN
252: "", # SAINT PAUL ISLAND
253: "\U0001F1F5\U0001F1F2", # SAINT PETER AND PAUL ROCKS
254: "\U0001F1F1\U0001F1FA", # LUXEMBOURG
255: "", #"SINT MAARTEN, SABA, ST EUSTATIUS"
256: "", # MADEIRA ISLANDS
257: "\U0001F1F2\U0001F1F9", # MALTA
258: "\U0001F1F8\U0001F1F7", # SUMATRA
259: "\U0001F1F8\U0001F1EF", # SVALBARD
260: "\U0001F1F2\U0001F1E8", # MONACO
261: "", # SWAN ISLAND
262: "\U0001F1F9\U0001F1EF", # TAJIKISTAN
263: "\U0001F1F3\U0001F1F1", # NETHERLANDS
264: "", # TANGIER
265: "", # NORTHERN IRELAND
266: "\U0001F1F3\U0001F1F4", # NORWAY
267: "", # TERR NEW GUINEA
268: "", # TIBET
269: "\U0001F1F5\U0001F1F1", # POLAND
270: "\U0001F1F9\U0001F1F0", # TOKELAU ISLANDS
271: "", # TRIESTE
272: "\U0001F1F5\U0001F1F9", # PORTUGAL
273: "", # TRINDADE & MARTIM VAZ ISLANDS
274: "", # TRISTAN DA CUNHA & GOUGH ISLANDS
275: "\U0001F1F7\U0001F1F4", # ROMANIA
276: "", # TROMELIN ISLAND
277: "", # SAINT PIERRE & MIQUELON
278: "\U0001F1F8\U0001F1F2", # SAN MARINO
279: "\U0001F3F4\U000E0067\U000E0062\U000E0073\U000E0063\U000E0074\U000E007F", # SCOTLAND
280: "\U0001F1F9\U0001F1F2", # TURKMENISTAN
281: "\U0001F1EA\U0001F1F8", # SPAIN
282: "\U0001F1F9\U0001F1FB", # TUVALU
283: "", # UK BASES ON CYPRUS
284: "\U0001F1F8\U0001F1EA", # SWEDEN
285: "\U0001F1FB\U0001F1EE", # US VIRGIN ISLANDS
286: "\U0001F1FA\U0001F1EC", # UGANDA
287: "\U0001F1E8\U0001F1ED", # SWITZERLAND
288: "\U0001F1FA\U0001F1E6", # UKRAINE
289: "", # UNITED NATIONS HQ
291: "\U0001F1FA\U0001F1F8", # UNITED STATES OF AMERICA
292: "\U0001F1FA\U0001F1FF", # UZBEKISTAN
293: "\U0001F1FB\U0001F1F3", # VIET NAM
294: "\U0001F3F4\U000E0067\U000E0062\U000E0077\U000E006C\U000E0073\U000E007F", # WALES
295: "\U0001F1FB\U0001F1E6", # VATICAN CITY
296: "\U0001F1F7\U0001F1F8", # SERBIA
297: "", # WAKE ISLAND
298: "\U0001F1FC\U0001F1EB", # WALLIS & FUTUNA ISLANDS
299: "", # WEST MALAYSIA
301: "\U0001F1F0\U0001F1EE", # WESTERN KIRIBATI
302: "\U0001F1EA\U0001F1ED", # WESTERN SAHARA
303: "", # WILLIS ISLAND
304: "\U0001F1E7\U0001F1ED", # BAHRAIN
305: "\U0001F1E7\U0001F1E9", # BANGLADESH
306: "\U0001F1E7\U0001F1F9", # BHUTAN
307: "", # ZANZIBAR
308: "\U0001F1E8\U0001F1F7", # COSTA RICA
309: "\U0001F1F2\U0001F1F2", # MYANMAR
312: "\U0001F1F0\U0001F1ED", # CAMBODIA
315: "\U0001F1F1\U0001F1F0", # SRI LANKA
318: "\U0001F1E8\U0001F1F3", # CHINA
321: "\U0001F1ED\U0001F1F0", # HONG KONG
324: "\U0001F1EE\U0001F1F3", # INDIA
327: "\U0001F1EE\U0001F1E9", # INDONESIA
330: "\U0001F1EE\U0001F1F7", # IRAN
333: "\U0001F1EE\U0001F1F6", # IRAQ
336: "\U0001F1EE\U0001F1F1", # ISRAEL
339: "\U0001F1EF\U0001F1F5", # JAPAN
342: "\U0001F1EF\U0001F1F4", # JORDAN
344: "\U0001F1F0\U0001F1F5", # DPRK (NORTH KOREA)
345: "\U0001F1E7\U0001F1F3", # BRUNEI
348: "\U0001F1F0\U0001F1FC", # KUWAIT
354: "\U0001F1F1\U0001F1E7", # LEBANON
363: "\U0001F1F2\U0001F1F3", # MONGOLIA
369: "\U0001F1F3\U0001F1F5", # NEPAL
370: "\U0001F1F4\U0001F1F2", # OMAN
372: "\U0001F1F5\U0001F1F0", # PAKISTAN
375: "\U0001F1F5\U0001F1ED", # PHILIPPINES
376: "\U0001F1F6\U0001F1E6", # QATAR
378: "\U0001F1F8\U0001F1E6", # SAUDI ARABIA
379: "\U0001F1F8\U0001F1E8", # SEYCHELLES ISLANDS
381: "\U0001F1F8\U0001F1EC", # SINGAPORE
382: "\U0001F1E9\U0001F1EF", # DJIBOUTI
384: "\U0001F1F8\U0001F1FE", # SYRIA
386: "\U0001F1F9\U0001F1FC", # TAIWAN
387: "\U0001F1F9\U0001F1ED", # THAILAND
390: "\U0001F1F9\U0001F1F7", # TURKEY
391: "\U0001F1E6\U0001F1EA", # UNITED ARAB EMIRATES
400: "\U0001F1E9\U0001F1FF", # ALGERIA
401: "\U0001F1E6\U0001F1F4", # ANGOLA
402: "\U0001F1E7\U0001F1FC", # BOTSWANA
404: "\U0001F1E7\U0001F1EE", # BURUNDI
406: "\U0001F1E8\U0001F1F2", # CAMEROON
408: "\U0001F1E8\U0001F1EB", # CENTRAL AFRICAN REPUBLIC
409: "", # CAPE VERDE
410: "\U0001F1F9\U0001F1E9", # CHAD
411: "\U0001F1F0\U0001F1F2", # COMOROS
412: "\U0001F1E8\U0001F1EC", # REPUBLIC OF THE CONGO
414: "\U0001F1E8\U0001F1E9", # DEM. REP. OF THE CONGO
416: "\U0001F1E7\U0001F1EF", # BENIN
420: "\U0001F1EC\U0001F1E6", # GABON
422: "\U0001F1EC\U0001F1F2", # THE GAMBIA
424: "\U0001F1EC\U0001F1ED", # GHANA
428: "\U0001F1E8\U0001F1EE", # COTE DIVOIRE
430: "\U0001F1F0\U0001F1EA", # KENYA
432: "\U0001F1F1\U0001F1F8", # LESOTHO
434: "\U0001F1F1\U0001F1F7", # LIBERIA
436: "\U0001F1F1\U0001F1FE", # LIBYA
438: "\U0001F1F2\U0001F1EC", # MADAGASCAR
440: "\U0001F1F2\U0001F1FC", # MALAWI
442: "\U0001F1F2\U0001F1F1", # MALI
444: "\U0001F1F2\U0001F1F7", # MAURITANIA
446: "\U0001F1F2\U0001F1E6", # MOROCCO
450: "\U0001F1F3\U0001F1EC", # NIGERIA
452: "\U0001F1FF\U0001F1FC", # ZIMBABWE
453: "\U0001F1F7\U0001F1EA", # REUNION ISLAND
454: "\U0001F1F7\U0001F1FC", # RWANDA
456: "\U0001F1F8\U0001F1F3", # SENEGAL
458: "\U0001F1F8\U0001F1F1", # SIERRA LEONE
460: "", # ROTUMA
462: "\U0001F1FF\U0001F1E6", # REPUBLIC OF SOUTH AFRICA
464: "\U0001F1F3\U0001F1E6", # NAMIBIA
466: "\U0001F1F8\U0001F1E9", # SUDAN
468: "\U0001F1F8\U0001F1FF", # KINGDOM OF ESWATINI
470: "\U0001F1F9\U0001F1FF", # TANZANIA
474: "\U0001F1F9\U0001F1F3", # TUNISIA
478: "\U0001F1EA\U0001F1EC", # EGYPT
480: "\U0001F1E7\U0001F1EB", # BURKINA FASO
482: "\U0001F1FF\U0001F1F2", # ZAMBIA
483: "\U0001F1F9\U0001F1EC", # TOGO
488: "", # WALVIS BAY
489: "", # CONWAY REEF
490: "", # BANABA ISLAND
492: "\U0001F1FE\U0001F1EA", # YEMEN
493: "", # PENGUIN ISLANDS
497: "\U0001F1ED\U0001F1F7", # CROATIA
499: "\U0001F1F8\U0001F1EE", # SLOVENIA
501: "\U0001F1E7\U0001F1E6", # BOSNIA-HERZEGOVINA
502: "\U0001F1F2\U0001F1F0", # NORTH MACEDONIA
503: "\U0001F1E8\U0001F1FF", # CZECH REPUBLIC
504: "\U0001F1F8\U0001F1F0", # SLOVAK REPUBLIC
505: "", # PRATAS ISLAND
506: "", # SCARBOROUGH REEF
507: "", # TEMOTU PROVINCE
508: "", # AUSTRAL ISLANDS
509: "", # MARQUESAS ISLANDS
510: "\U0001F1F5\U0001F1F8", # PALESTINE
511: "\U0001F1F9\U0001F1F1", # TIMOR-LESTE
512: "", # CHESTERFIELD ISLANDS
513: "", # DUCIE ISLAND
514: "\U0001F1F2\U0001F1EA", # MONTENEGRO
515: "", # SWAINS ISLAND
516: "\U0001F1E7\U0001F1F1", # SAINT BARTHELEMY
517: "\U0001F1E8\U0001F1FC", # CURACAO
518: "\U0001F1F8\U0001F1FD", # SINT MAARTEN
519: "", # SABA & ST EUSTATIUS
520: "", # BONAIRE
521: "", # REPUBLIC OF SOUTH SUDAN
522: "\U0001F1FD\U0001F1F0" # REPUBLIC OF KOSOVO
}

58
core/utils.py Normal file
View File

@@ -0,0 +1,58 @@
from core.constants import BANDS, UNKNOWN_BAND
from pyhamtools import LookupLib, Callinfo
# Static lookup helpers from pyhamtools
# todo in future add QRZ as a second lookup option in case it provides more data?
lookuplib = LookupLib(lookuptype="countryfile")
callinfo = Callinfo(lookuplib)
# Infer a "mode family" from a mode.
def infer_mode_family_from_mode(mode):
if mode.upper() == "CW":
return "CW"
elif mode.upper() in ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DMR", "DSTAR", "C4FM", "M17"]:
return "PHONE"
else:
return "DIGI"
# Infer a band from a frequency in kHz
def infer_band_from_freq(freq):
for b in BANDS:
if b.start_freq <= freq <= b.end_freq:
return b
return UNKNOWN_BAND
# Infer a country name from a callsign
def infer_country_from_callsign(call):
try:
return callinfo.get_country_name(call)
except KeyError as e:
return None
# Infer a DXCC ID from a callsign
def infer_dxcc_id_from_callsign(call):
try:
return callinfo.get_adif_id(call)
except KeyError as e:
return None
# Infer a continent shortcode from a callsign
def infer_continent_from_callsign(call):
try:
return callinfo.get_continent(call)
except KeyError as e:
return None
# Infer a CQ zone from a callsign
def infer_cq_zone_from_callsign(call):
try:
return callinfo.get_cqz(call)
except KeyError as e:
return None
# Infer a ITU zone from a callsign
def infer_itu_zone_from_callsign(call):
try:
return callinfo.get_ituz(call)
except KeyError as e:
return None

15
data/band.py Normal file
View File

@@ -0,0 +1,15 @@
from dataclasses import dataclass
# Data class that defines a band.
@dataclass
class Band:
# Band name
name: str
# Start frequency, in kHz
start_freq: float
# Stop frequency, in kHz
end_freq: float
# Colour to use for this band, as per PSK Reporter
color: str
# Contrast colour to use for text against a background of the band colour
contrast_color: str

100
data/spot.py Normal file
View File

@@ -0,0 +1,100 @@
from dataclasses import dataclass
from datetime import datetime
from core.constants import DXCC_FLAGS
from core.utils import infer_mode_family_from_mode, infer_band_from_freq, infer_continent_from_callsign, \
infer_country_from_callsign, infer_cq_zone_from_callsign, infer_itu_zone_from_callsign, infer_dxcc_id_from_callsign
# Data class that defines a spot.
@dataclass
class Spot:
# Callsign of the operator that has been spotted
dx_call: str = None
# Callsign of the operator that has spotted them
de_call: str = None
# Country of the DX operator
dx_country: str = None
# Country of the spotter
de_country: str = None
# Country flag of the DX operator
dx_flag: str = None
# Country flag of the spotter
de_flag: str = None
# Continent of the DX operator
dx_continent: str = None
# Continent of the spotter
de_continent: str = None
# DXCC ID of the DX operator
dx_dxcc_id: int = None
# DXCC ID of the spotter
de_dxcc_id: int = None
# CQ zone of the DX operator
dx_cq_zone: int = None
# ITU zone of the DX operator
dx_itu_zone: int = None
# Reported mode, such as SSB, PHONE, CW, FT8...
mode: str = None
# Inferred mode "family". One of "CW", "PHONE" or "DIGI".
mode_family: str = None
# Frequency, in kHz
freq: float = None
# Band, defined by the frequency, e.g. "40m" or "70cm"
band: str = None
# Colour to use for the band
band_color: str = None
# Contrast colour to use for text on a background of band_color
band_contrast_color: str = None
# Time of the spot
time: datetime = None
# Comment left by the spotter, if any
comment: str = None
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
sig: str = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list = None
# SIG reference names
sig_refs_names: list = None
# Maidenhead grid locator for the spot. This could be from a geographical reference e.g. POTA, or just from the country
grid: str = None
# Latitude & longitude, in degrees. This could be from a geographical reference e.g. POTA, or just from the country
latitude: float = None
longitude: float = None
# QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
qrt: bool = None
# Where we got the spot from, e.g. "POTA", "Cluster"...
source: str = None
# The ID the source gave it, if any.
source_id: str = None
# Infer missing parameters where possible
def infer_missing(self):
if self.dx_call and not self.dx_country:
self.dx_country = infer_country_from_callsign(self.dx_call)
if self.dx_call and not self.dx_continent:
self.dx_continent = infer_continent_from_callsign(self.dx_call)
if self.dx_call and not self.dx_cq_zone:
self.dx_cq_zone = infer_cq_zone_from_callsign(self.dx_call)
if self.dx_call and not self.dx_itu_zone:
self.dx_itu_zone = infer_itu_zone_from_callsign(self.dx_call)
if self.dx_call and not self.dx_dxcc_id:
self.dx_dxcc_id = infer_dxcc_id_from_callsign(self.dx_call)
if self.dx_dxcc_id and not self.dx_flag:
self.dx_flag = DXCC_FLAGS[self.dx_dxcc_id]
if self.de_call and not self.de_country:
self.de_country = infer_country_from_callsign(self.de_call)
if self.de_call and not self.de_continent:
self.de_continent = infer_continent_from_callsign(self.de_call)
if self.de_call and not self.de_dxcc_id:
self.de_dxcc_id = infer_dxcc_id_from_callsign(self.de_call)
if self.de_dxcc_id and not self.de_flag:
self.de_flag = DXCC_FLAGS[self.de_dxcc_id]
if self.freq and not self.band:
band = infer_band_from_freq(self.freq)
self.band = band.name
self.band_color = band.color
self.band_contrast_color = band.contrast_color
if self.mode and not self.mode_family:
self.mode_family=infer_mode_family_from_mode(self.mode)

42
main.py Normal file
View File

@@ -0,0 +1,42 @@
# Main script
import signal
from time import sleep
from providers.pota import POTA
# Shutdown function
def shutdown(sig, frame):
# Start data providers
for p in providers: p.stop()
# Main function
if __name__ == '__main__':
# Shut down gracefully on SIGINT
signal.signal(signal.SIGINT, shutdown)
# Create providers
providers = [POTA()] # todo all other providers
# Set up spot list
spot_list = []
# Set up data providers
for p in providers: p.setup(spot_list=spot_list)
# Start data providers
for p in providers: p.start()
# todo thread to clear spot list of old data
# Todo serve spot API
# Todo serve status API
# Todo serve apidocs
# Todo serve website
sleep(2)
print(len(spot_list))
print(spot_list[0])
# NOTES FOR FIELD SPOTTER
# Still need to de-dupe spots
# Still need to do QSY checking

65
providers/pota.py Normal file
View File

@@ -0,0 +1,65 @@
from datetime import datetime, timezone
import pytz
from data.spot import Spot
from providers.provider import Provider
from threading import Timer
import requests
class POTA(Provider):
POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://api.pota.app/spot/activator"
def __init__(self):
super().__init__()
self.poll_timer = None
def name(self):
return "POTA"
def start(self):
self.poll()
def stop(self):
self.poll_timer.cancel()
def poll(self):
try:
# Request data from API
source_data = requests.get(self.SPOTS_URL, headers=self.HTTP_HEADERS).json()
# Build a list of spots we haven't seen before
new_spots = []
# Iterate through source data
for source_spot in source_data:
# Convert to our spot format
spot = Spot(source="POTA",
source_id=source_spot["spotId"],
dx_call=source_spot["activator"],
de_call=source_spot["spotter"],
freq=float(source_spot["frequency"]),
mode=source_spot["mode"],
comment=source_spot["comments"],
sig="POTA",
sig_refs=[source_spot["reference"]],
sig_refs_names=[source_spot["name"]],
time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=pytz.UTC),
grid=source_spot["grid6"],
latitude=source_spot["latitude"],
longitude=source_spot["longitude"],
qrt="QRT" in source_spot["comments"].upper())
# Fill in any blanks
spot.infer_missing()
# Add to our list
new_spots.append(spot)
# Submit the new spots for processing
self.submit(new_spots)
self.status = "OK"
self.last_update_time = datetime.now(timezone.utc)
except requests.exceptions.RequestException as e:
self.status = "Error"
self.poll_timer = Timer(self.POLL_INTERVAL_SEC, self.poll)
self.poll_timer.start()

41
providers/provider.py Normal file
View File

@@ -0,0 +1,41 @@
from datetime import datetime
import pytz
from core.constants import SOFTWARE_NAME, SOFTWARE_VERSION
# Generic data provider class. Subclasses of this query the individual APIs for data.
class Provider:
# HTTP headers used for providers that use HTTP
HTTP_HEADERS = { "User-Agent": SOFTWARE_NAME + " " + SOFTWARE_VERSION }
# Constructor
def __init__(self):
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.last_spot_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started"
self.spot_list = None
# Return the name of the provider
def name(self):
raise NotImplementedError("Subclasses must implement this method")
# Set up the provider, e.g. giving it the spot list to work from
def setup(self, spot_list):
self.spot_list = spot_list
# Start the provider. This should return immediately after spawning threads to access the remote resources
def start(self):
raise NotImplementedError("Subclasses must implement this method")
# Submit one or more new spots retrieved from the provider. Only spots that are newer than the last spot retrieved
# by this provider will be added to the spot list, to prevent duplications. This is called by the subclasses on
# receiving spots.
def submit(self, spots):
for spot in spots:
if spot.time > self.last_spot_time:
self.spot_list.append(spot)
self.last_spot_time = max(map(lambda s: s.time, spots))
# Stop any threads and prepare for application shutdown
def stop(self):
raise NotImplementedError("Subclasses must implement this method")

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
requests~=2.32.5