From c34821dc9b1331f56b94eb3e5221398dbb8fb42b Mon Sep 17 00:00:00 2001 From: Ian Renton Date: Fri, 26 Sep 2025 22:37:17 +0100 Subject: [PATCH] First commit --- .gitignore | 4 + LICENSE | 24 +++ README.md | 5 + core/constants.py | 432 ++++++++++++++++++++++++++++++++++++++++++ core/utils.py | 58 ++++++ data/band.py | 15 ++ data/spot.py | 100 ++++++++++ main.py | 42 ++++ providers/pota.py | 65 +++++++ providers/provider.py | 41 ++++ requirements.txt | 1 + 11 files changed, 787 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 core/constants.py create mode 100644 core/utils.py create mode 100644 data/band.py create mode 100644 data/spot.py create mode 100644 main.py create mode 100644 providers/pota.py create mode 100644 providers/provider.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..507bc33 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.idea +/.venv +__pycache__ +*.pyc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/LICENSE @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0efa6d7 --- /dev/null +++ b/README.md @@ -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. diff --git a/core/constants.py b/core/constants.py new file mode 100644 index 0000000..5e1a850 --- /dev/null +++ b/core/constants.py @@ -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 +} \ No newline at end of file diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..e86d96b --- /dev/null +++ b/core/utils.py @@ -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 \ No newline at end of file diff --git a/data/band.py b/data/band.py new file mode 100644 index 0000000..e57a790 --- /dev/null +++ b/data/band.py @@ -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 \ No newline at end of file diff --git a/data/spot.py b/data/spot.py new file mode 100644 index 0000000..47e6dec --- /dev/null +++ b/data/spot.py @@ -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) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..e959eab --- /dev/null +++ b/main.py @@ -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 \ No newline at end of file diff --git a/providers/pota.py b/providers/pota.py new file mode 100644 index 0000000..edcb527 --- /dev/null +++ b/providers/pota.py @@ -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() \ No newline at end of file diff --git a/providers/provider.py b/providers/provider.py new file mode 100644 index 0000000..3e962a1 --- /dev/null +++ b/providers/provider.py @@ -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") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d4ebc34 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests~=2.32.5 \ No newline at end of file