Another Ham Radio Logbook -- Web, Multi-user multiple-logbook, with eQSL upload support
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

libjambon.py 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. import math
  2. import re
  3. import urllib.request
  4. import urllib.parse
  5. import urllib.error
  6. import bs4 as beautiful_soup
  7. import requests
  8. from flask import json
  9. from sqlalchemy import or_, func
  10. from models import Band, Mode, Config, DxccPrefixes
  11. from utils import add_log, InvalidUsage
  12. def band_to_frequency(band, mode):
  13. if not isinstance(band, int) or not isinstance(mode, int):
  14. raise TypeError("Sorry but only integer")
  15. b = Band.query.filter(Band.id == band).first()
  16. m = Mode.query.filter(Mode.id == mode).first()
  17. if not b or not m:
  18. return None
  19. frequencies = Band.query.filter(
  20. Band.lower.is_(None),
  21. Band.upper.is_(None),
  22. Band.name == b.name,
  23. or_(Band.modes.contains(m.submode), Band.modes.contains(m.mode)),
  24. )
  25. if frequencies.count() <= 0:
  26. return b.lower
  27. else:
  28. return frequencies.first().start
  29. def frequency_to_band(frequency, zone="iaru1"):
  30. f = frequency
  31. if type(f) == str:
  32. f = int(frequency)
  33. f_q = Band.query.filter(Band.start.is_(None), Band.lower < f, Band.upper > f, Band.zone == zone).single()
  34. return f_q.name
  35. def geo_bearing_star(bearing):
  36. dirs = ["N", "E", "S", "W"]
  37. rounded = round(bearing / 22.5) % 16
  38. if (rounded % 4) == 0:
  39. _dir = dirs[int(rounded / 4)]
  40. else:
  41. _dir = dirs[int(2 * math.floor(((math.floor(rounded / 4) + 1) % 4) / 2))]
  42. _dir += dirs[int(1 + 2 * math.floor(rounded / 8))]
  43. return _dir
  44. def adif(k, v):
  45. v = str(v)
  46. return "<{0}:{1}>{2} ".format(k.upper(), len(v), v)
  47. def eqsl_upload_log(log, config, dry_run):
  48. """ Doc: https://www.eqsl.cc/qslcard/ImportADIF.txt """
  49. reject = False
  50. # Sanity check
  51. if not config.eqsl_download_url:
  52. return {"state": "error", "message": "config.eqsl_download_url empty"}
  53. if not log.user.eqsl_name:
  54. return {"state": "error", "message": "user.eqsl_name empty"}
  55. if not log.user.eqsl_password:
  56. return {"state": "error", "message": "user.eqsl_password empty"}
  57. if not log.call:
  58. return {"state": "error", "message": "log.call empty"}
  59. if not log.band:
  60. return {"state": "error", "message": "log.band empty"}
  61. if not log.mode:
  62. return {"state": "error", "message": "log.mode empty"}
  63. if not log.time_on:
  64. return {"state": "error", "message": "log.time_on empty"}
  65. if not log.rst_sent:
  66. return {"state": "error", "message": "log.rst_sent empty"}
  67. if not log.logbook.eqsl_qth_nickname:
  68. return {"state": "error", "message": "log.logbook.eqsl_qth_nickname empty"}
  69. # End of checks
  70. # Build URL
  71. url = config.eqsl_upload_url
  72. log_adif = "AHRL Upload"
  73. log_adif += adif("adif_ver", "1.00") + " \r\n"
  74. # Test
  75. # log_adif += adif('eqsl_user', 'TEST-SWL') + ' '
  76. # log_adif += adif('eqsl_pswd', 'Testpswd') + ' '
  77. # Real ones
  78. log_adif += adif("eqsl_user", log.user.eqsl_name) + " "
  79. log_adif += adif("eqsl_pswd", log.user.eqsl_password) + " "
  80. log_adif += "<EOH> "
  81. log_adif += adif("band", log.band.name.upper()) + " \r\n"
  82. # Test
  83. # log_adif += adif('call', 'TEST-SWL') + ' '
  84. # Real one
  85. log_adif += adif("call", log.call.upper()) + " "
  86. # eQSL Mode standardization (http://www.eqsl.cc/qslcard/ADIFContentSpecs.cfm)
  87. mode = log.mode.submode
  88. if log.mode.mode == "OLIVIA":
  89. mode = "OLIVIA"
  90. elif log.mode.submode == "PAC4":
  91. mode = "PAC"
  92. elif log.mode.submode in ["PSK250", "PSK500", "PSK1000", "QPSK250", "QPSK500", "PSK2K", "FSKHELL"]:
  93. reject = True
  94. elif log.mode.mode == "JT9":
  95. mode = "JT9"
  96. elif log.mode.mode == "JT65":
  97. mode = "JT65"
  98. elif log.mode.mode == "ISCAT":
  99. mode = "ISCAT"
  100. elif log.mode.submode == "USB" or log.mode.submode == "LSB":
  101. mode = "SSB"
  102. elif log.mode.mode == "ROS":
  103. mode = "ROS"
  104. elif log.mode.submode == "DOMINOEX":
  105. mode = "DOMINO"
  106. elif log.mode.mode == "OPERA":
  107. mode = "OPERA"
  108. elif log.mode.submode in ["MFSK4", "MFSK11", "MFSK22", "MFSK31", "MFSK32", "MFSK64", "MFSK128"]:
  109. reject = True
  110. if reject:
  111. # Theses are modes not managed by eQSL, sorry
  112. return {
  113. "state": "rejected",
  114. "message": "rejected because mode {0} not managed by eQSL".format(log.mode.submode),
  115. }
  116. log_adif += adif("mode", mode.upper()) + " "
  117. log_adif += adif("qso_date", log.time_on.strftime("%Y%m%d")) + " "
  118. log_adif += adif("rst_sent", log.rst_sent) + " "
  119. log_adif += adif("time_on", log.time_on.strftime("%H%M%S")) + " \r\n"
  120. log_adif += adif("APP_EQSL_QTH_NICKNAME", log.logbook.eqsl_qth_nickname) + " "
  121. if log.qsl_comment:
  122. log_adif += adif("qslmsg", log.qsl_comment) + " "
  123. log_adif += "<EOR>"
  124. if dry_run:
  125. print("--- [DRY RUN] what would be commited for {0}:".format(log.id))
  126. print(log_adif)
  127. return
  128. try:
  129. data = urllib.parse.urlencode({"ADIFData": log_adif})
  130. data = data.encode("utf-8")
  131. req = urllib.request.Request(url, data)
  132. resp = urllib.request.urlopen(req)
  133. except ValueError as e:
  134. return {"state": "error", "message": str(e)}
  135. except urllib.error.URLError as e:
  136. return {"state": "error", "message": str(e)}
  137. soup = beautiful_soup.BeautifulSoup(resp.read(), "html.parser")
  138. body = soup.body.get_text().strip()
  139. # print(body)
  140. # Possible: 'Result', 'Warning', 'Error'
  141. rejected = False
  142. results = []
  143. for result in body.split("\r\n"):
  144. if result.strip() == "" or not result.strip():
  145. continue # Ignore empty lines
  146. obj = re.match(r"^(\w+): (.+)", result.strip())
  147. results.append([obj.group(1), obj.group(2)])
  148. if obj.group(2) == "0 out of 1 records added" or obj.group(1) == "Error":
  149. rejected = True
  150. if not rejected:
  151. return {"state": "success", "message": "QSO Sync-ed to eQSL", "msgs": results}
  152. else:
  153. return {"state": "error", "message": "Some errors where detected", "msgs": results}
  154. # To be managed a-part: band, klass (class), freq, freq_rx, mode, time_on, qso_date, comment
  155. ADIF_FIELDS = [
  156. "address",
  157. "age",
  158. "a_index",
  159. "ant_az",
  160. "ant_el",
  161. "ant_path",
  162. "arrl_sect",
  163. "biography",
  164. "band_rx",
  165. "call",
  166. "check",
  167. "cnty",
  168. "cont",
  169. "contacted_op",
  170. "contest_id",
  171. "country",
  172. "cqz",
  173. "distance",
  174. "dxcc",
  175. "email",
  176. "eq_call",
  177. "eqsl_qslrdate",
  178. "eqsl_qslsdate",
  179. "eqsl_qsl_rcvd",
  180. "eqsl_qsl_sent",
  181. "eqsl_status",
  182. "force_init",
  183. "gridsquare",
  184. "heading",
  185. "iota",
  186. "ituz",
  187. "k_index",
  188. "lat",
  189. "lon",
  190. "lotw_qslrdate",
  191. "lotw_qslsdate",
  192. "lotw_qsl_rcvd",
  193. "lotw_qsl_sent",
  194. "lotw_status",
  195. "max_bursts",
  196. "ms_shower",
  197. "my_city",
  198. "my_cnty",
  199. "my_country",
  200. "my_cq_zone",
  201. "my_gridsquare",
  202. "my_iota",
  203. "my_itu_zone",
  204. "my_lat",
  205. "my_lon",
  206. "my_name",
  207. "my_postal_code",
  208. "my_rig",
  209. "my_sig",
  210. "my_sig_info",
  211. "my_state",
  212. "my_street",
  213. "name",
  214. "notes",
  215. "nr_bursts",
  216. "nr_pings",
  217. "operator",
  218. "owner_callsign",
  219. "pfx",
  220. "precedence",
  221. "prop_mode",
  222. "public_key",
  223. "qslmsg",
  224. "qslrdate",
  225. "qslsdate",
  226. "qsl_rcvd",
  227. "qsl_rcvd_via",
  228. "qsl_sent",
  229. "qsl_sent_via",
  230. "qsl_via",
  231. "qso_complete",
  232. "qso_random",
  233. "qth",
  234. "rig",
  235. "rst_rcvd",
  236. "rst_sent",
  237. "rx_pwr",
  238. "sat_mode",
  239. "sat_name",
  240. "sfi",
  241. "sig",
  242. "sig_info",
  243. "srx",
  244. "srx_string",
  245. "state",
  246. "station_callsign",
  247. "stx",
  248. "stx_info",
  249. "swl",
  250. "ten_ten",
  251. "tx_pwr",
  252. "web",
  253. "credit_granted",
  254. "credit_submitted",
  255. ]
  256. def get_dxcc_from_clublog(callsign):
  257. config = Config.query.first()
  258. if not config:
  259. print("!!! Error: config not found")
  260. add_log(category="CONFIG", level="ERROR", message="Config not found")
  261. return
  262. clublog_api_key = config.clublog_api_key
  263. clublog_uri = "https://secure.clublog.org/dxcc?call={0}&api={1}&full=1".format(callsign, clublog_api_key)
  264. try:
  265. r = requests.get(clublog_uri)
  266. except: # noqa: E722
  267. raise InvalidUsage("Error getting DXCC from ClubLog", status_code=500)
  268. if r.status_code != 200:
  269. raise InvalidUsage("Error getting DXCC from ClubLog", status_code=r.status_code)
  270. return json.loads(r.content)
  271. def get_dxcc_from_clublog_or_database(callsign):
  272. response = {}
  273. dxcc_database = None
  274. dxcc_clublog = get_dxcc_from_clublog(callsign)
  275. if not dxcc_clublog:
  276. # Trying fallback from database
  277. q = (
  278. DxccPrefixes.query.filter(DxccPrefixes.call == func.substring(callsign, 1, func.LENGTH(DxccPrefixes.call)))
  279. .order_by(func.length(DxccPrefixes.call).asc())
  280. .limit(1)
  281. .first()
  282. )
  283. if q:
  284. dxcc_database = {
  285. "CQZ": int(q.cqz),
  286. "Continent": q.cont,
  287. "DXCC": q.adif,
  288. "Lat": q.lat,
  289. "Lon": q.long,
  290. "Name": q.entity,
  291. "PermKomi": False,
  292. }
  293. if not dxcc_clublog and not dxcc_database:
  294. # We have nothing at all :(
  295. raise InvalidUsage("Error while getting infos from clublog", status_code=500)
  296. if dxcc_clublog or dxcc_database:
  297. response["status"] = "ok"
  298. if dxcc_clublog:
  299. response.update(dxcc_clublog)
  300. response["source"] = "clublog"
  301. else:
  302. response.update(dxcc_database)
  303. response["source"] = "database"
  304. return response
  305. def coordinates2adif(coord, _type):
  306. """
  307. a sequence of characters representing a latitude or longitude in XDDD MM.MMM format, where
  308. X is a directional Character from the set {E, W, N, S}
  309. DDD is a 3-Digit degrees specifier, where 0 <= DDD <= 180 [use leading zeroes]
  310. MM.MMM is a 6-Digit minutes specifier, where 0 <= MM.MMM <= 59.999 [use leading zeroes]
  311. """
  312. degrees = int(coord)
  313. minutes = abs((coord - int(coord)) * 60)
  314. direction = ""
  315. if _type == "Longitude":
  316. if degrees <= 0:
  317. direction = "W"
  318. degrees = abs(degrees)
  319. elif degrees > 0:
  320. direction = "E"
  321. else:
  322. direction = ""
  323. elif _type == "Latitude":
  324. if degrees < 0:
  325. direction = "S"
  326. degrees = abs(degrees)
  327. elif degrees > 0:
  328. direction = "N"
  329. else:
  330. direction = ""
  331. return "{0}{1} {min:06.3f}".format(direction, str(degrees).zfill(3), min=minutes)
  332. def adif2coordinates(coord):
  333. """
  334. a sequence of characters representing a latitude or longitude in XDDD MM.MMM format, where
  335. X is a directional Character from the set {E, W, N, S}
  336. DDD is a 3-Digit degrees specifier, where 0 <= DDD <= 180 [use leading zeroes]
  337. MM.MMM is a 6-Digit minutes specifier, where 0 <= MM.MMM <= 59.999 [use leading zeroes]
  338. """
  339. p = re.match(r"([NWES])([\d]{3})\s(\d\d\.\d\d\d)", coord)
  340. coords = int(p.group(2)) + float(p.group(3)) / 60.0
  341. if p.group(1) in ["W", "S"] and coords >= 0:
  342. coords = math.fabs(coords) * -1
  343. return coords