Repository: mbridak/FieldDayLogger (language: python)

Issue:
Log contacts from multiple stations.
Currently useful for just one person/station.

Use UDP messages to pass contacts and info back and forth to a central server.

Totally fail at first, wrecking some groups field day, since I'm not a group and testing will be minimal.
Good luck. Be brave.


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 5a8341d..8ecf0d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -136,3 +136,4 @@ spots.db
 .vscode/
 cwmacros_fd.txt
 fd_preferences.json
+server_database.db
diff --git a/Manual.md b/Manual.md
index 42cbbd5..125eb43 100644
--- a/Manual.md
+++ b/Manual.md
@@ -2,31 +2,44 @@
 
 ## Caveats
 
-This is a simple logger, although it's getting less and less simple as the days pass, ment for single op/station. It will not aggregate contacts made by multiple stations.
-
-This has always been a "scratch my own itch" project. And the development of it is driven by that. However, I welcome suggestions, criticisms and feature requests.
+This has always been a "scratch my own itch" project. And the development of 
+it is driven by that. However, I welcome suggestions, criticisms and feature 
+requests.
 
 Field Day only has a generic digital mode designator 'DI', which gets exported 
 to the cabrillo file. But ADIF and CloudLog needed something else, So I Chose 
 FT8. Yes Yes, I know. FT8 is the spawn of Satan, and is killing Ham Radio... 
-Blah Blah Blah... Feel free to change it to what ever you will use. Just search for the two places in the code 'FT8' is used and Bob's your dads brother.
+Blah Blah Blah... Feel free to change it to what ever you will use. 
+Just search for the two places in the code 'FT8' is used and Bob's your dads 
+brother.
 
 ---
 ## Wheres the data
 
-The log is stored in an sqlite3 database file './FieldDay.db'. If you need to 
+The client log is stored in an sqlite3 database file './FieldDay.db'. If you need to 
 wipe everything and start clean, just delete this file and re-run the logger.
 
-The logger will generate a cabrillo file './YOURCALL.log' and a './Statistics.txt' 
-file with a band/mode/power breakdown which you can use when you submit your 
-logs to the ARRL online [here](http://field-day.arrl.org/fdentry.php). 
+The aggrigation server stores it's database in a file called, in a stroke of 
+inspiration, './server_database.db'.
+
+The logger will generate a cabrillo file './YOURCALL.log' and a 
+'./Statistics.txt' file with a band/mode/power breakdown which you can use 
+when you submit your logs to the ARRL online 
+[here](http://field-day.arrl.org/fdentry.php). 
+
+The server will generate a cabrillo file './YOURCLUBSCALLSIGN.log'
+
+An ADIF file './FieldDay.adi' will also be generated by the client so you can
+merge contacts into your normal Log.
 
-An ADIF file './FieldDay.adi' will also be generated so you can merge contacts into your normal Log.
+The server does not create an adif file, 'cause why should it...
 
 ---
 ## Running from source
 
-Install Python 3, then two required libraries.
+### The Client
+
+Install Python 3, then required libraries.
 
 If you're the Ubuntu/Debian type you can:
 
@@ -41,6 +54,13 @@ or type:
 
 `python3 fielddaylogger.py`
 
+### The Server
+
+The server is a terminal / curses program and uses standard libraries that
+should already be installed.
+
+Just make server.py executable and run it the same way as the client. 
+
 ---
 
 ## What to do first
@@ -209,6 +229,81 @@ To be safe you might want to run it through 'unix2dos' before submitting it.
 
 A 'Statistics.txt' file which breaks down your band/mode/power usage.
 
+---
+## Group / Club logging
+
+I have added a group contact aggrigating server. This can be run on the same
+computer as the client program, or on a separate dedicated PC or Raspberry Pi
+on the same network.
+
+![Picture showing main server screen](pics/server_pic.png)
+
+### Server configuration
+
+The configuration file for the server is a JSON file 'server_preferences.json'.
+
+```
+{
+    "ourcall": "W1AW",
+    "ourclass": "3A",
+    "oursection": "ORG",
+    "altpower": 1,
+    "name": "Hiram Maxim",
+    "address": "225 Main Street",
+    "city": "Newington",
+    "state": "CT",
+    "postalcode": "06111",
+    "country": "USA",
+    "email": "Hiram.Maxim@arrl.net",
+    "mullticast_group": "224.1.1.1",
+    "multicast_port": 2239,
+    "interface_ip": "0.0.0.0"
+}
+```
+
+Go ahead and edit this file before running the server. Feel free to leave the
+last 3 items as they are unless you have good reason not too. The rest should
+be straight forward.
+
+### Client configuration for groups
+
+In the settings dialog there is now a tab labeled 'Group Operation'.
+
+![Picture showing settings dialog tab](pics/group_server_settings.png)
+
+Go ahead and place a check next to 'Connect to server'. Rejoyce and let
+merriment be had by all. Be sure and have your callsign already set before
+checking this. If you forgot, Uncheck it, set your callsign and then check it.
+
+A couple of things will change on the client when this is done. You will see
+that your callsign will disappear and be replaced with your clubs call that the
+server reports. The portion of the screen where all the different ARRL sections
+are displayed will be replaced by a group chat window and a column showing the
+station call, band and mode of other participants.
+
+![Picture showing logger screen changes](pics/group_chat.png)
+
+If more than one operator is on the same band/mode, their names will be
+highlighted in the operators list. Feel free to yell at eachother in the chat.
+
+![Picture showing band and mode conflict](pics/band_conflict.png)
+
+### Generating the cabrillo file
+
+If any of the networked clients presses the 'Generate Logs' button on their
+screen, the server will be told to generate it's cabrillo file, it will be
+named 'WhatEverYourClubCallIs.log'
+
+### I'm sure there are short cummings
+
+It's early days, and I've mainly tested the operations with the client logging
+program and several simulated operators, see file in `testing/simulant.py`
+Real world use for Field Day in September is hard to come by. So I'm sure there
+are a couple of things I forgot, or didn't account for.
+
+If you are part of a group of linux using Hams, please take this for a spin and
+tell me what I missed or could do better.
+
 ---
 ## The Bandmap program
 See [here](bandmap.md)
diff --git a/README.md b/README.md
index 96d2c88..7c1b7ba 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 # K6GTE Field Day logger (GUI)
 
-[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)  [![Python: 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)  [![Made With:PyQt5](https://img.shields.io/badge/Made%20with-PyQt5-red)](https://pypi.org/project/PyQt5/)
+[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)  [![Python: 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)  [![Made With:PyQt5](https://img.shields.io/badge/Made%20with-PyQt5-red)](https://pypi.org/project/PyQt5/)
 
 [ARRL Field Day](http://field-day.arrl.org/) is a once a year 24hr emergency 
 preparidness event for radio amateurs (Hams). During the event, we try and 
@@ -22,7 +22,11 @@ file with a band/mode/power breakdown which you can use when you submit your
 logs to the ARRL online [here](http://field-day.arrl.org/fdentry.php). An ADIF 
 file will also be generated so you can merge contacts into your normal Log.
 
+We now have a group aggrigation server, The clients and server pass traffic
+over Multicast UDP.
+
 Read the manual [here](Manual.md)
 
 ![Picture of main screen](pics/loggerscreenshot.png)
 
+![Picture of server main screen](pics/server_pic.png)
diff --git a/data/main.ui b/data/main.ui
index 72848d2..dac8a25 100644
--- a/data/main.ui
+++ b/data/main.ui
@@ -3866,6 +3866,102 @@ color: rgb(0, 0, 0);</string>
      <string>SP</string>
     </property>
    </widget>
+   <widget class="QLabel" name="group_call_indicator">
+    <property name="geometry">
+     <rect>
+      <x>348</x>
+      <y>620</y>
+      <width>91</width>
+      <height>25</height>
+     </rect>
+    </property>
+    <property name="font">
+     <font>
+      <family>JetBrains Mono</family>
+      <pointsize>12</pointsize>
+     </font>
+    </property>
+    <property name="styleSheet">
+     <string notr="true">border: 1px solid green;</string>
+    </property>
+    <property name="text">
+     <string/>
+    </property>
+    <property name="alignment">
+     <set>Qt::AlignCenter</set>
+    </property>
+   </widget>
+   <widget class="QFrame" name="chat_window">
+    <property name="geometry">
+     <rect>
+      <x>350</x>
+      <y>220</y>
+      <width>441</width>
+      <height>304</height>
+     </rect>
+    </property>
+    <property name="frameShape">
+     <enum>QFrame::WinPanel</enum>
+    </property>
+    <property name="frameShadow">
+     <enum>QFrame::Plain</enum>
+    </property>
+    <widget class="QTextEdit" name="chatlog">
+     <property name="geometry">
+      <rect>
+       <x>4</x>
+       <y>9</y>
+       <width>261</width>
+       <height>251</height>
+      </rect>
+     </property>
+     <property name="font">
+      <font>
+       <family>JetBrains Mono</family>
+       <pointsize>12</pointsize>
+      </font>
+     </property>
+     <property name="readOnly">
+      <bool>true</bool>
+     </property>
+    </widget>
+    <widget class="QLineEdit" name="chat_entry">
+     <property name="geometry">
+      <rect>
+       <x>10</x>
+       <y>265</y>
+       <width>421</width>
+       <height>29</height>
+      </rect>
+     </property>
+     <property name="font">
+      <font>
+       <family>JetBrains Mono</family>
+       <pointsize>12</pointsize>
+      </font>
+     </property>
+    </widget>
+    <widget class="QTextEdit" name="users_list">
+     <property name="geometry">
+      <rect>
+       <x>270</x>
+       <y>10</y>
+       <width>161</width>
+       <height>251</height>
+      </rect>
+     </property>
+     <property name="font">
+      <font>
+       <family>JetBrains Mono</family>
+       <pointsize>12</pointsize>
+      </font>
+     </property>
+     <property name="readOnly">
+      <bool>true</bool>
+     </property>
+    </widget>
+   </widget>
+   <zorder>group_call_indicator</zorder>
    <zorder>frame_2</zorder>
    <zorder>frame_3</zorder>
    <zorder>frame_4</zorder>
@@ -3907,6 +4003,7 @@ color: rgb(0, 0, 0);</string>
    <zorder>F11</zorder>
    <zorder>infoline</zorder>
    <zorder>run_button</zorder>
+   <zorder>chat_window</zorder>
   </widget>
  </widget>
  <tabstops>
diff --git a/data/settings.ui b/data/settings.ui
index b7156a3..fefef70 100644
--- a/data/settings.ui
+++ b/data/settings.ui
@@ -20,8 +20,8 @@ color: rgb(211, 215, 207);</string>
   <widget class="QDialogButtonBox" name="buttonBox">
    <property name="geometry">
     <rect>
-     <x>160</x>
-     <y>360</y>
+     <x>170</x>
+     <y>350</y>
      <width>341</width>
      <height>32</height>
     </rect>
@@ -64,6 +64,7 @@ color: rgb(211, 215, 207);</string>
    </property>
    <property name="font">
     <font>
+     <family>JetBrains Mono</family>
      <pointsize>12</pointsize>
     </font>
    </property>
@@ -767,6 +768,153 @@ color: rgb(211, 215, 207);</string>
      </property>
     </widget>
    </widget>
+   <widget class="QWidget" name="tab_5">
+    <attribute name="title">
+     <string>Group Operation</string>
+    </attribute>
+    <widget class="QCheckBox" name="connect_to_server">
+     <property name="geometry">
+      <rect>
+       <x>230</x>
+       <y>30</y>
+       <width>191</width>
+       <height>23</height>
+      </rect>
+     </property>
+     <property name="font">
+      <font>
+       <family>JetBrains Mono</family>
+       <pointsize>12</pointsize>
+      </font>
+     </property>
+     <property name="text">
+      <string>Connect to server</string>
+     </property>
+    </widget>
+    <widget class="QLabel" name="label_3">
+     <property name="geometry">
+      <rect>
+       <x>160</x>
+       <y>90</y>
+       <width>161</width>
+       <height>17</height>
+      </rect>
+     </property>
+     <property name="font">
+      <font>
+       <family>JetBrains Mono</family>
+       <pointsize>12</pointsize>
+      </font>
+     </property>
+     <property name="text">
+      <string>Multicast Group:</string>
+     </property>
+     <property name="alignment">
+      <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+     </property>
+    </widget>
+    <widget class="QLabel" name="label_9">
+     <property name="geometry">
+      <rect>
+       <x>160</x>
+       <y>140</y>
+       <width>161</width>
+       <height>17</height>
+      </rect>
+     </property>
+     <property name="font">
+      <font>
+       <family>JetBrains Mono</family>
+       <pointsize>12</pointsize>
+      </font>
+     </property>
+     <property name="text">
+      <string>Multicast Port:</string>
+     </property>
+     <property name="alignment">
+      <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+     </property>
+    </widget>
+    <widget class="QLabel" name="label_12">
+     <property name="geometry">
+      <rect>
+       <x>160</x>
+       <y>190</y>
+       <width>161</width>
+       <height>17</height>
+      </rect>
+     </property>
+     <property name="font">
+      <font>
+       <family>JetBrains Mono</family>
+       <pointsize>12</pointsize>
+      </font>
+     </property>
+     <property name="text">
+      <string>Interface IP:</string>
+     </property>
+     <property name="alignment">
+      <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+     </property>
+    </widget>
+    <widget class="QLineEdit" name="multicast_group">
+     <property name="geometry">
+      <rect>
+       <x>350</x>
+       <y>90</y>
+       <width>113</width>
+       <height>25</height>
+      </rect>
+     </property>
+     <property name="font">
+      <font>
+       <family>JetBrains Mono</family>
+       <pointsize>12</pointsize>
+      </font>
+     </property>
+     <property name="text">
+      <string>224.1.1.1</string>
+     </property>
+    </widget>
+    <widget class="QLineEdit" name="multicast_port">
+     <property name="geometry">
+      <rect>
+       <x>350</x>
+       <y>140</y>
+       <width>113</width>
+       <height>25</height>
+      </rect>
+     </property>
+     <property name="font">
+      <font>
+       <family>JetBrains Mono</family>
+       <pointsize>12</pointsize>
+      </font>
+     </property>
+     <property name="text">
+      <string>2239</string>
+     </property>
+    </widget>
+    <widget class="QLineEdit" name="interface_ip">
+     <property name="geometry">
+      <rect>
+       <x>350</x>
+       <y>190</y>
+       <width>113</width>
+       <height>25</height>
+      </rect>
+     </property>
+     <property name="font">
+      <font>
+       <family>JetBrains Mono</family>
+       <pointsize>12</pointsize>
+      </font>
+     </property>
+     <property name="text">
+      <string>0.0.0.0</string>
+     </property>
+    </widget>
+   </widget>
   </widget>
  </widget>
  <resources/>
diff --git a/fielddaylogger.py b/fielddaylogger.py
old mode 100644
new mode 100755
index fc8b56a..fbe26b1
--- a/fielddaylogger.py
+++ b/fielddaylogger.py
@@ -5,9 +5,7 @@
 https://github.com/mbridak/FieldDayLogger
 GPL V3
 """
-# pylint: disable=too-many-lines
-# pylint: disable=invalid-name
-# pylint: disable=no-member
+# pylint: disable=too-many-lines, invalid-name, no-member, no-value-for-parameter
 # Nothing to see here move along.
 # xplanet -body earth -window -longitude -117 -latitude 38
 # -config Default -projection azmithal -radius 200 -wait 5
@@ -15,7 +13,7 @@
 from math import radians, sin, cos, atan2, sqrt, asin, pi
 from pathlib import Path
 from datetime import datetime
-from json import dumps, loads
+from json import dumps, loads, JSONDecodeError
 from shutil import copyfile
 
 # from xmlrpc.client import ServerProxy, Error
@@ -25,6 +23,12 @@
 import sys
 import logging
 import threading
+import uuid
+import queue
+import time
+from itertools import chain
+
+# from time import gmtime, strftime
 
 import requests
 from PyQt5.QtNetwork import QUdpSocket, QHostAddress
@@ -40,7 +44,6 @@
 from lib.version import __version__
 
 
-
 def relpath(filename):
     """
     Checks to see if program has been packaged with pyinstaller.
@@ -105,20 +108,26 @@ class MainWindow(QtWidgets.QMainWindow):
     datadict = {}
     dupdict = {}
     ft8dupe = ""
-    fkeys = dict()
+    fkeys = {}
+    people = {}
     mygrid = None
     run_state = False
+    groupcall = None
+    server_commands = []
 
     def __init__(self, *args, **kwargs):
         """Initialize"""
         super().__init__(*args, **kwargs)
         uic.loadUi(self.relpath("data/main.ui"), self)
+        self.chat_window.hide()
         self.db = DataBase(self.database)
+        self.udp_fifo = queue.Queue()
         self.listWidget.itemDoubleClicked.connect(self.qsoclicked)
         self.callsign_entry.textEdited.connect(self.calltest)
         self.class_entry.textEdited.connect(self.classtest)
         self.section_entry.textEdited.connect(self.sectiontest)
         self.callsign_entry.returnPressed.connect(self.log_contact)
+        self.chat_entry.returnPressed.connect(self.send_chat)
         self.class_entry.returnPressed.connect(self.log_contact)
         self.section_entry.returnPressed.connect(self.log_contact)
         self.mycallEntry.textEdited.connect(self.changemycall)
@@ -186,11 +195,20 @@ def __init__(self, *args, **kwargs):
             "cwtype": 0,
             "cwip": "localhost",
             "cwport": 6789,
+            "useserver": 0,
+            "multicast_group": "224.1.1.1",
+            "multicast_port": 2239,
+            "interface_ip": "0.0.0.0",
         }
         self.reference_preference = self.preference.copy()
         self.look_up = None
         self.cat_control = None
         self.cw = None
+        self.connect_to_server = False
+        self.multicast_group = None
+        self.multicast_port = None
+        self.interface_ip = None
+        self._udpwatch = None
         self.readpreferences()
         self.radiochecktimer = QtCore.QTimer()
         self.radiochecktimer.timeout.connect(self.poll_radio)
@@ -198,10 +216,172 @@ def __init__(self, *args, **kwargs):
         self.ft8dupechecktimer = QtCore.QTimer()
         self.ft8dupechecktimer.timeout.connect(self.ft8dupecheck)
         self.ft8dupechecktimer.start(1000)
+
+        # ft8 udp server
         self.udp_socket = QUdpSocket()
         self.udp_socket.bind(QHostAddress.LocalHost, 2237)
         self.udp_socket.readyRead.connect(self.on_udp_socket_ready_read)
 
+    def show_people(self):
+        """Display operators"""
+        rev_dict = {}
+        for key, value in self.people.items():
+            rev_dict.setdefault(value, set()).add(key)
+        result = set(
+            chain.from_iterable(
+                values for key, values in rev_dict.items() if len(values) > 1
+            )
+        )
+        self.users_list.clear()
+        self.users_list.insertPlainText("    Operators\n")
+        for op_callsign in self.people:
+            if op_callsign in result:
+                self.users_list.setTextColor(QtGui.QColor(245, 121, 0))
+                self.users_list.insertPlainText(
+                    f"{op_callsign.rjust(6,' ')} {self.people.get(op_callsign).rjust(6, ' ')}\n"
+                )
+                self.users_list.setTextColor(QtGui.QColor(211, 215, 207))
+            else:
+                self.users_list.insertPlainText(
+                    f"{op_callsign.rjust(6,' ')} {self.people.get(op_callsign).rjust(6, ' ')}\n"
+                )
+
+    def clear_dirty_flag(self, unique_id):
+        """clear the dirty flag on record once response is returned from server."""
+        self.db.clear_dirty_flag(unique_id)
+
+    def remove_confirmed_commands(self, data):
+        """Removed confirmed commands from the sent commands list."""
+        for index, item in enumerate(self.server_commands):
+            if item.get("unique_id") == data.get("unique_id") and item.get(
+                "cmd"
+            ) == data.get("subject"):
+                self.server_commands.pop(index)
+                self.clear_dirty_flag(data.get("unique_id"))
+                self.infoline.setText(f"Confirmed {data.get('subject')}")
+
+    def send_chat(self):
+        """Sends UDP chat packet with text entered in chat_entry field."""
+        message = self.chat_entry.text()
+        packet = {"cmd": "CHAT"}
+        packet["sender"] = self.preference.get("mycall")
+        packet["message"] = message
+        bytesToSend = bytes(dumps(packet), encoding="ascii")
+        try:
+            self.server_udp.sendto(
+                bytesToSend, (self.multicast_group, int(self.multicast_port))
+            )
+        except OSError as err:
+            logging.warning("%s", err)
+        self.chat_entry.setText("")
+
+    def display_chat(self, sender, body):
+        """Displays the chat history."""
+        if self.preference.get("mycall") in body.upper():
+            self.chatlog.setTextColor(QtGui.QColor(245, 121, 0))
+        self.chatlog.insertPlainText(f"{sender}: {body}\n")
+        self.chatlog.setTextColor(QtGui.QColor(211, 215, 207))
+
+    def watch_udp(self):
+        """Puts UDP datagrams in a FIFO queue"""
+        while self.connect_to_server:
+            try:
+                datagram = self.server_udp.recv(1500)
+            except socket.timeout:
+                time.sleep(1)
+                continue
+            if datagram:
+                self.udp_fifo.put(datagram)
+
+    def check_udp_queue(self):
+        """checks the UDP datagram queue."""
+        while not self.udp_fifo.empty():
+            datagram = self.udp_fifo.get()
+            try:
+                json_data = loads(datagram.decode())
+            except UnicodeDecodeError as err:
+                the_error = f"Not Unicode: {err}\n{datagram}"
+                logging.info(the_error)
+                continue
+            except JSONDecodeError as err:
+                the_error = f"Not JSON: {err}\n{datagram}"
+                logging.info(the_error)
+                continue
+            logging.info("%s", json_data)
+            if json_data.get("cmd") == "PING":
+                if json_data.get("station"):
+                    band_mode = f"{json_data.get('band')} {json_data.get('mode')}"
+                    if self.people.get(json_data.get("station")) != band_mode:
+                        self.people[json_data.get("station")] = band_mode
+                    self.show_people()
+                continue
+            # if json_data.get("cmd") == "CONFLICT":
+            #     band, mode = json_data.get("bandmode").split()
+            #     if (
+            #         band == self.band
+            #         and mode == self.mode
+            #         and json_data.get("recipient") == self.preference.get("mycall")
+            #     ):
+            #         self.flash()
+            #         self.infoline.setText(f"CONFLICT ON {json_data.get('bandmode')}")
+            #     continue
+            if json_data.get("cmd") == "RESPONSE":
+                if json_data.get("recipient") == self.preference.get("mycall"):
+                    if json_data.get("subject") == "HOSTINFO":
+                        self.groupcall = str(json_data.get("groupcall"))
+                        self.myclassEntry.setText(str(json_data.get("groupclass")))
+                        self.mysectionEntry.setText(str(json_data.get("groupsection")))
+                        self.group_call_indicator.setText(self.groupcall)
+                        self.changemyclass()
+                        self.changemysection()
+                        self.mycallEntry.hide()
+                        return
+                    if json_data.get("subject") == "LOG":
+                        self.infoline.setText("Server Generated Log.")
+                    self.remove_confirmed_commands(json_data)
+                    continue
+            if json_data.get("cmd") == "CHAT":
+                self.display_chat(json_data.get("sender"), json_data.get("message"))
+                continue
+            if json_data.get("cmd") == "GROUPQUERY":
+                if self.groupcall:
+                    self.send_status_udp()
+
+    def query_group(self):
+        """Sends request to server asking for group call/class/section."""
+        update = {
+            "cmd": "GROUPQUERY",
+            "station": self.preference["mycall"],
+        }
+        bytesToSend = bytes(dumps(update), encoding="ascii")
+        try:
+            self.server_udp.sendto(
+                bytesToSend, (self.multicast_group, int(self.multicast_port))
+            )
+        except OSError as err:
+            logging.warning("%s", err)
+
+    def send_status_udp(self):
+        """Send status update to server informing of our band and mode"""
+        if self.connect_to_server:
+            if self.groupcall is None and self.preference["mycall"] != "":
+                self.query_group()
+                return
+
+            update = {
+                "cmd": "PING",
+                "mode": self.mode,
+                "band": self.band,
+                "station": self.preference["mycall"],
+            }
+            bytesToSend = bytes(dumps(update), encoding="ascii")
+            try:
+                self.server_udp.sendto(
+                    bytesToSend, (self.multicast_group, int(self.multicast_port))
+                )
+            except OSError as err:
+                logging.warning("%s", err)
+
     def clearcontactlookup(self):
         """clearout the contact lookup"""
         self.contactlookup["call"] = ""
@@ -457,8 +637,6 @@ def run_button_pressed(self):
             self.run_button.setText("Run")
         self.read_cw_macros()
 
-
-
     def read_cw_macros(self):
         """
         Reads in the CW macros, firsts it checks to see if the file exists. If it does not,
@@ -466,9 +644,7 @@ def read_cw_macros(self):
         temp directory this is running from... In theory.
         """
 
-        if (
-            not Path("./cwmacros_fd.txt").exists()
-        ):
+        if not Path("./cwmacros_fd.txt").exists():
             logging.info("read_cw_macros: copying default macro file.")
             copyfile(self.relpath("data/cwmacros_fd.txt"), "./cwmacros_fd.txt")
         with open("./cwmacros_fd.txt", "r", encoding="utf-8") as file_descriptor:
@@ -901,10 +1077,12 @@ def clearinputs(self):
     def changeband(self):
         """change band"""
         self.band = self.band_selector.currentText()
+        self.send_status_udp()
 
     def changemode(self):
         """change mode"""
         self.mode = self.mode_selector.currentText()
+        self.send_status_udp()
 
     def changepower(self):
         """change power"""
@@ -1043,20 +1221,18 @@ def readpreferences(self):
         """
         Restore preferences if they exist, otherwise create some sane defaults.
         """
-        logging.info("readpreferences:")
         try:
             if os.path.exists("./fd_preferences.json"):
                 with open(
                     "./fd_preferences.json", "rt", encoding="utf-8"
                 ) as file_descriptor:
                     self.preference = loads(file_descriptor.read())
-                    logging.info("reading: %s", self.preference)
             else:
                 with open(
                     "./fd_preferences.json", "wt", encoding="utf-8"
                 ) as file_descriptor:
                     file_descriptor.write(dumps(self.preference, indent=4))
-                    logging.info("writing: %s", self.preference)
+                    logging.info("No preference, writing dafult.")
         except IOError as exception:
             logging.critical("readpreferences: %s", exception)
         logging.info(self.preference)
@@ -1120,6 +1296,43 @@ def readpreferences(self):
                     self.preference["cwport"],
                 )
                 self.cw.speed = 20
+
+            self.connect_to_server = self.preference.get("useserver")
+            self.multicast_group = self.preference.get("multicast_group")
+            self.multicast_port = self.preference.get("multicast_port")
+            self.interface_ip = self.preference.get("interface_ip")
+            # group upd server
+            logging.info("Use group server: %s", self.connect_to_server)
+            if self.connect_to_server:
+                logging.info(
+                    "Connecting: %s:%s %s",
+                    self.multicast_group,
+                    self.multicast_port,
+                    self.interface_ip,
+                )
+                self.chat_window.show()
+                self.server_udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+                self.server_udp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+                self.server_udp.bind(("", int(self.multicast_port)))
+                mreq = socket.inet_aton(self.multicast_group) + socket.inet_aton(
+                    self.interface_ip
+                )
+                self.server_udp.setsockopt(
+                    socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, bytes(mreq)
+                )
+                self.server_udp.settimeout(0.01)
+
+                if self._udpwatch is None:
+                    self._udpwatch = threading.Thread(
+                        target=self.watch_udp,
+                        daemon=True,
+                    )
+                    self._udpwatch.start()
+            else:
+                self.groupcall = None
+                self.mycallEntry.show()
+                self.chat_window.hide()
+
         except KeyError as err:
             logging.warning("Corrupt preference, %s, loading clean version.", err)
             self.preference = self.reference_preference.copy()
@@ -1134,7 +1347,6 @@ def writepreferences(self):
         Write preferences to json file.
         """
         try:
-            logging.info("writepreferences:")
             with open(
                 "./fd_preferences.json", "wt", encoding="utf-8"
             ) as file_descriptor:
@@ -1153,6 +1365,7 @@ def log_contact(self):
             return
         if not self.cat_control:
             self.oldfreq = int(float(self.fakefreq(self.band, self.mode)) * 1000)
+        unique_id = uuid.uuid4().hex
         contact = (
             self.callsign_entry.text(),
             self.class_entry.text(),
@@ -1163,8 +1376,35 @@ def log_contact(self):
             int(self.power_selector.value()),
             self.contactlookup["grid"],
             self.contactlookup["name"],
+            unique_id,
         )
         self.db.log_contact(contact)
+
+        if self.connect_to_server:
+            contact = {
+                "cmd": "POST",
+                "hiscall": self.callsign_entry.text(),
+                "class": self.class_entry.text(),
+                "section": self.section_entry.text(),
+                "mode": self.mode,
+                "band": self.band,
+                "frequency": self.oldfreq,
+                "date_and_time": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
+                "power": int(self.power_selector.value()),
+                "grid": self.contactlookup["grid"],
+                "opname": self.contactlookup["name"],
+                "station": self.preference["mycall"],
+                "unique_id": unique_id,
+            }
+            self.server_commands.append(contact)
+            bytesToSend = bytes(dumps(contact, indent=4), encoding="ascii")
+            try:
+                self.server_udp.sendto(
+                    bytesToSend, (self.multicast_group, int(self.multicast_port))
+                )
+            except OSError as err:
+                logging.warning("%s", err)
+
         self.sections()
         self.stats()
         self.updatemarker()
@@ -1237,6 +1477,8 @@ def logwindow(self):
                 power,
                 _,
                 _,
+                _,
+                _,
             ) = contact
             logline = (
                 f"{str(logid).rjust(3,'0')} {hiscall.ljust(15)} {hisclass.rjust(3)} "
@@ -1570,7 +1812,7 @@ def updatemarker(self):
         """
         Updates the xplanet marker file with a list of logged contact lat & lon
         """
-        if self.preference['usemarker']:
+        if self.preference["usemarker"]:
             filename = str(Path.home()) + "/" + self.markerfile
             try:
                 list_of_grids = self.db.get_grids()
@@ -1625,6 +1867,8 @@ def adif(self):
                         _,
                         grid,
                         opname,
+                        _,
+                        _,
                     ) = contact
                     if mode == "DI":
                         mode = "FT8"
@@ -1752,6 +1996,7 @@ def postcloudlog(self):
             _,
             grid,
             opname,
+            _,
         ) = contact
         if mode == "DI":
             mode = "FT8"
@@ -1882,6 +2127,8 @@ def cabrillo(self):
                         _,
                         _,
                         _,
+                        _,
+                        _,
                     ) = contact
                     if mode == "DI":
                         mode = "DG"
@@ -1919,6 +2166,18 @@ def generate_logs(self):
         self.cabrillo()
         self.generate_band_mode_tally()
         self.adif()
+        if self.connect_to_server:
+            update = {
+                "cmd": "LOG",
+                "station": self.preference["mycall"],
+            }
+            bytesToSend = bytes(dumps(update), encoding="ascii")
+            try:
+                self.server_udp.sendto(
+                    bytesToSend, (self.multicast_group, int(self.multicast_port))
+                )
+            except OSError as err:
+                logging.warning("%s", err)
 
 
 class EditQSODialog(QtWidgets.QDialog):
@@ -1934,6 +2193,7 @@ def __init__(self, parent=None):
         self.deleteButton.clicked.connect(self.delete_contact)
         self.buttonBox.accepted.connect(self.save_changes)
         self.change = QsoEdit()
+        self.unique_id = None
 
     def set_up(self, linetopass, thedatabase):
         """Set up variables"""
@@ -1960,6 +2220,7 @@ def set_up(self, linetopass, thedatabase):
         now = QtCore.QDateTime.fromString(date_time, "yyyy-MM-dd hh:mm:ss")
         self.editDateTime.setDateTime(now)
         self.database = thedatabase
+        self.unique_id = self.database.get_unique_id(self.theitem)
 
     @staticmethod
     def relpath(filename: str) -> str:
@@ -1987,11 +2248,44 @@ def save_changes(self):
             self.theitem,
         ]
         self.database.change_contact(qso)
+        if window.connect_to_server:
+            command = {"cmd": "UPDATE"}
+            command["hiscall"] = self.editCallsign.text().upper()
+            command["class"] = self.editClass.text().upper()
+            command["section"] = self.editSection.text().upper()
+            command["date_time"] = self.editDateTime.text()
+            command["frequency"] = self.editFreq.text()
+            command["band"] = self.editBand.currentText()
+            command["mode"] = self.editMode.currentText().upper()
+            command["power"] = self.editPower.value()
+            command["station"] = window.preference["mycall"].upper()
+            command["unique_id"] = self.unique_id
+            window.server_commands.append(command)
+            bytesToSend = bytes(dumps(command, indent=4), encoding="ascii")
+            try:
+                window.server_udp.sendto(
+                    bytesToSend, (window.multicast_group, int(window.multicast_port))
+                )
+            except OSError as err:
+                logging.warning("%s", err)
         self.change.lineChanged.emit()
 
     def delete_contact(self):
         """delete the contact"""
         self.database.delete_contact(self.theitem)
+        if window.connect_to_server:
+            command = {}
+            command["cmd"] = "DELETE"
+            command["unique_id"] = self.unique_id
+            command["station"] = window.preference["mycall"].upper()
+            window.server_commands.append(command)
+            bytesToSend = bytes(dumps(command, indent=4), encoding="ascii")
+            try:
+                window.server_udp.sendto(
+                    bytesToSend, (window.multicast_group, int(window.multicast_port))
+                )
+            except OSError as err:
+                logging.warning("%s", err)
         self.change.lineChanged.emit()
         self.close()  # try:
 
@@ -2081,7 +2375,6 @@ def startup_dialog_finished():
     app.setStyle("Fusion")
     font_dir = relpath("font")
     families = load_fonts_from_dir(os.fspath(font_dir))
-    logging.info(families)
     window = MainWindow()
     window.setWindowTitle(f"K6GTE Field Day Logger v{__version__}")
     window.show()
@@ -2117,4 +2410,12 @@ def startup_dialog_finished():
     timer.timeout.connect(window.update_time)
     timer.start(1000)
 
+    timer2 = QtCore.QTimer()
+    timer2.timeout.connect(window.check_udp_queue)
+    timer2.start(1000)
+
+    timer3 = QtCore.QTimer()
+    timer3.timeout.connect(window.send_status_udp)
+    timer3.start(60000)
+
     app.exec()
diff --git a/lib/database.py b/lib/database.py
index f4b4b84..465449d 100644
--- a/lib/database.py
+++ b/lib/database.py
@@ -30,12 +30,26 @@ def create_db(self) -> None:
                     "mode text NOT NULL, "
                     "power INTEGER NOT NULL, "
                     "grid text NOT NULL, "
-                    "opname text NOT NULL);"
+                    "opname text NOT NULL, "
+                    "unique_id text NOT NULL, "
+                    "dirty INTEGER DEFAULT 1);"
                 )
                 cursor.execute(sql_table)
                 conn.commit()
         except sqlite3.Error as exception:
-            logging.critical("create_db: Unable to create database: %s", exception)
+            logging.critical("%s", exception)
+
+    def clear_dirty_flag(self, unique_id) -> None:
+        """Clears the dirty flag."""
+        if unique_id:
+            try:
+                with sqlite3.connect(self.database) as conn:
+                    sql = f"update contacts set dirty=0 where unique_id='{unique_id}';"
+                    cursor = conn.cursor()
+                    cursor.execute(sql)
+                    conn.commit()
+            except sqlite3.Error as exception:
+                logging.critical("%s", exception)
 
     def log_contact(self, logme: tuple) -> None:
         """
@@ -47,8 +61,8 @@ def log_contact(self, logme: tuple) -> None:
                 sql = (
                     "INSERT INTO contacts"
                     "(callsign, class, section, date_time, frequency, "
-                    "band, mode, power, grid, opname) "
-                    "VALUES(?,?,?,datetime('now'),?,?,?,?,?,?)"
+                    "band, mode, power, grid, opname, unique_id, dirty) "
+                    "VALUES(?,?,?,datetime('now'),?,?,?,?,?,?,?,1)"
                 )
                 cur = conn.cursor()
                 cur.execute(sql, logme)
@@ -56,6 +70,20 @@ def log_contact(self, logme: tuple) -> None:
         except sqlite3.Error as exception:
             logging.debug("DataBase log_contact: %s", exception)
 
+    def get_unique_id(self, contact) -> str:
+        """get unique id"""
+        unique_id = ""
+        if contact:
+            try:
+                with sqlite3.connect(self.database) as conn:
+                    sql = f"select unique_id from contacts where id={int(contact)}"
+                    cursor = conn.cursor()
+                    cursor.execute(sql)
+                    unique_id = str(cursor.fetchone()[0])
+            except sqlite3.Error as exception:
+                logging.debug("%s", exception)
+        return unique_id
+
     def delete_contact(self, contact) -> None:
         """Deletes a contact from the db."""
         if contact:
diff --git a/lib/lookup.py b/lib/lookup.py
index 50df224..dcbab60 100644
--- a/lib/lookup.py
+++ b/lib/lookup.py
@@ -6,9 +6,12 @@
 """
 
 import logging
+import warnings
 from bs4 import BeautifulSoup as bs
+
 import requests
 
+warnings.filterwarnings('ignore', category=UserWarning, module='bs4')
 
 class HamDBlookup:
     """
diff --git a/lib/server_database.py b/lib/server_database.py
new file mode 100644
index 0000000..13f0723
--- /dev/null
+++ b/lib/server_database.py
@@ -0,0 +1,278 @@
+"""Database class to store contacts"""
+import logging
+import sqlite3
+
+
+class DataBase:
+    """Database class for our database."""
+
+    def __init__(self, database):
+        """initializes DataBase instance"""
+        self.database = database
+        self.create_db()
+
+    def create_db(self) -> None:
+        """
+        create database tables contacts if they do not exist.
+        """
+        try:
+            with sqlite3.connect(self.database) as conn:
+                cursor = conn.cursor()
+                sql_table = (
+                    "CREATE TABLE IF NOT EXISTS contacts "
+                    "(id INTEGER PRIMARY KEY, "
+                    "unique_id text NOT NULL, "
+                    "callsign text NOT NULL, "
+                    "class text NOT NULL, "
+                    "section text NOT NULL, "
+                    "date_time text NOT NULL, "
+                    "frequency INTEGER DEFAULT 0, "
+                    "band text NOT NULL, "
+                    "mode text NOT NULL, "
+                    "power INTEGER NOT NULL, "
+                    "grid text NOT NULL, "
+                    "opname text NOT NULL, "
+                    "station text NOT NULL);"
+                )
+                cursor.execute(sql_table)
+                conn.commit()
+        except sqlite3.Error as exception:
+            logging.critical("create_db: Unable to create database: %s", exception)
+
+    def log_contact(self, logme: tuple) -> None:
+        """
+        Inserts a contact into the db.
+        pass in (unique_id, hiscall, hisclass, hissection, band, mode, int(power), grid, name)
+        """
+        try:
+            with sqlite3.connect(self.database) as conn:
+                sql = (
+                    "INSERT INTO contacts"
+                    "(unique_id, callsign, class, section, date_time, frequency, "
+                    "band, mode, power, grid, opname, station) "
+                    "VALUES(?,?,?,?,?,?,?,?,?,?,?,?)"
+                )
+                cur = conn.cursor()
+                cur.execute(sql, logme)
+                conn.commit()
+        except sqlite3.Error as exception:
+            logging.debug("DataBase log_contact: %s", exception)
+
+    def delete_contact(self, unique_id) -> None:
+        """Deletes a contact from the db by passing in UUID."""
+        if unique_id:
+            try:
+                with sqlite3.connect(self.database) as conn:
+                    sql = f"delete from contacts where unique_id='{unique_id}'"
+                    cur = conn.cursor()
+                    cur.execute(sql)
+                    conn.commit()
+            except sqlite3.Error as exception:
+                logging.debug("DataBase delete_contact: %s", exception)
+
+    def change_contact(self, qso):
+        """Update an existing contact."""
+        try:
+            with sqlite3.connect(self.database) as conn:
+                sql = (
+                    f"update contacts set callsign = '{qso[0]}', class = '{qso[1]}', "
+                    f"section = '{qso[2]}', date_time = '{qso[3]}', band = '{qso[4]}', "
+                    f"mode = '{qso[5]}', power = '{qso[6]}', station = '{qso[7]}', "
+                    f"frequency = '{qso[8]}' where unique_id='{qso[9]}';"
+                )
+                cur = conn.cursor()
+                cur.execute(sql)
+                conn.commit()
+        except sqlite3.Error as exception:
+            logging.critical("DataBase change_contact: %s", exception)
+
+    def get_operators(self) -> list:
+        """Return a list of station calls used."""
+        with sqlite3.connect(self.database) as conn:
+            cursor = conn.cursor()
+            cursor.execute("select distinct station from contacts;")
+            ops = cursor.fetchall()
+        return ops
+
+    def stats(self) -> tuple:
+        """
+        returns a tuple with some stats:
+        cwcontacts, phonecontacts, digitalcontacts, bandmodemult, last15, lasthour, hignpower, qrp
+        """
+        with sqlite3.connect(self.database) as conn:
+            cursor = conn.cursor()
+            cursor.execute("select count(*) from contacts where mode = 'CW'")
+            cwcontacts = str(cursor.fetchone()[0])
+            cursor.execute("select count(*) from contacts where mode = 'PH'")
+            phonecontacts = str(cursor.fetchone()[0])
+            cursor.execute("select count(*) from contacts where mode = 'DI'")
+            digitalcontacts = str(cursor.fetchone()[0])
+            cursor.execute("select distinct band, mode from contacts")
+            bandmodemult = len(cursor.fetchall())
+            cursor.execute(
+                "SELECT count(*) FROM contacts "
+                "where datetime(date_time) >=datetime('now', '-15 Minutes')"
+            )
+            last15 = str(cursor.fetchone()[0])
+            cursor.execute(
+                "SELECT count(*) FROM contacts "
+                "where datetime(date_time) >=datetime('now', '-1 Hours')"
+            )
+            lasthour = str(cursor.fetchone()[0])
+            cursor.execute(
+                "select count(*) as qrpc from contacts where mode = 'CW' and power > 5"
+            )
+            log = cursor.fetchall()
+            qrpc = list(log[0])[0]
+            cursor.execute(
+                "select count(*) as qrpp from contacts where mode = 'PH' and power > 10"
+            )
+            log = cursor.fetchall()
+            qrpp = list(log[0])[0]
+            cursor.execute(
+                "select count(*) as qrpd from contacts where mode = 'DI' and power > 10"
+            )
+            log = cursor.fetchall()
+            qrpd = list(log[0])[0]
+            cursor.execute(
+                "select count(*) as highpower from contacts where power > 100"
+            )
+            log = cursor.fetchall()
+            highpower = bool(list(log[0])[0])
+            qrp = not qrpc + qrpp + qrpd
+
+            return (
+                cwcontacts,
+                phonecontacts,
+                digitalcontacts,
+                bandmodemult,
+                last15,
+                lasthour,
+                highpower,
+                qrp,
+            )
+
+    def contacts_under_101watts(self) -> tuple:
+        """return contact tallies for contacts made below 101 watts."""
+        try:
+            with sqlite3.connect(self.database) as conn:
+                cursor = conn.cursor()
+                cursor.execute(
+                    "select count(*) as cw from contacts where mode = 'CW' and power < 101"
+                )
+                c_dubs = str(cursor.fetchone()[0])
+                cursor.execute(
+                    "select count(*) as ph from contacts where mode = 'PH' and power < 101"
+                )
+                phone = str(cursor.fetchone()[0])
+                cursor.execute(
+                    "select count(*) as di from contacts where mode = 'DI' and power < 101"
+                )
+                digital = str(cursor.fetchone()[0])
+        except sqlite3.Error as exception:
+            logging.critical("DB-contacts_under_101watts: %s", exception)
+            return 0, 0, 0
+        return c_dubs, phone, digital
+
+    def qrp_check(self) -> tuple:
+        """check to see if all contacts were QRP"""
+        try:
+            with sqlite3.connect(self.database) as conn:
+                cursor = conn.cursor()
+                cursor.execute(
+                    "select count(*) as qrpc from contacts where mode = 'CW' and power > 5"
+                )
+                log = cursor.fetchall()
+                qrpc = list(log[0])[0]
+                cursor.execute(
+                    "select count(*) as qrpp from contacts where mode = 'PH' and power > 10"
+                )
+                log = cursor.fetchall()
+                qrpp = list(log[0])[0]
+                cursor.execute(
+                    "select count(*) as qrpd from contacts where mode = 'DI' and power > 10"
+                )
+                log = cursor.fetchall()
+                qrpd = list(log[0])[0]
+                cursor.execute(
+                    "select count(*) as highpower from contacts where power > 100"
+                )
+                log = cursor.fetchall()
+                highpower = bool(list(log[0])[0])
+                qrp = not qrpc + qrpp + qrpd
+        except sqlite3.Error as exception:
+            logging.critical("qrpcheck: %s", exception)
+            return 0, 0
+        return qrp, highpower
+
+    def get_band_mode_tally(self, band, mode):
+        """
+        returns the amount of contacts and the maximum power used
+        for a given band using a particular mode.
+        """
+        with sqlite3.connect(self.database) as conn:
+            cursor = conn.cursor()
+            cursor.execute(
+                "select count(*) as tally, MAX(power) as mpow from contacts "
+                f"where band = '{band}' AND mode ='{mode}'"
+            )
+            return cursor.fetchone()
+
+    def get_bands(self) -> tuple:
+        """returns a list of bands"""
+        with sqlite3.connect(self.database) as conn:
+            cursor = conn.cursor()
+            cursor.execute("select DISTINCT band from contacts")
+            return cursor.fetchall()
+
+    def fetch_all_contacts_asc(self) -> tuple:
+        """returns a tuple of all contacts in the database."""
+        with sqlite3.connect(self.database) as conn:
+            cursor = conn.cursor()
+            cursor.execute("select * from contacts order by date_time ASC")
+            return cursor.fetchall()
+
+    def fetch_all_contacts_desc(self) -> tuple:
+        """returns a tuple of all contacts in the database."""
+        with sqlite3.connect(self.database) as conn:
+            cursor = conn.cursor()
+            cursor.execute("select * from contacts order by date_time desc")
+            return cursor.fetchall()
+
+    def fetch_last_contact(self) -> tuple:
+        """returns a tuple of all contacts in the database."""
+        with sqlite3.connect(self.database) as conn:
+            cursor = conn.cursor()
+            cursor.execute("select * from contacts order by id desc")
+            return cursor.fetchone()
+
+    def dup_check(self, acall: str) -> tuple:
+        """returns a list of possible dups"""
+        with sqlite3.connect(self.database) as conn:
+            cursor = conn.cursor()
+            cursor.execute(
+                "select callsign, class, section, band, mode "
+                f"from contacts where callsign like '{acall}' order by band"
+            )
+            return cursor.fetchall()
+
+    def sections(self) -> tuple:
+        """returns a list of sections worked."""
+        with sqlite3.connect(self.database) as conn:
+            cursor = conn.cursor()
+            cursor.execute("select distinct section from contacts")
+            return cursor.fetchall()
+
+    def contact_by_id(self, record) -> tuple:
+        """returns a contact matching an id"""
+        with sqlite3.connect(self.database) as conn:
+            cursor = conn.cursor()
+            cursor.execute("select * from contacts where id=" + record)
+            return cursor.fetchall()
+
+    def get_grids(self) -> tuple:
+        """returns a tuple of unique grids in the log."""
+        with sqlite3.connect(self.database) as conn:
+            cursor = conn.cursor()
+            cursor.execute("select DISTINCT grid from contacts")
+            return cursor.fetchall()
diff --git a/lib/settings.py b/lib/settings.py
index 6067f6b..805e3e8 100644
--- a/lib/settings.py
+++ b/lib/settings.py
@@ -45,6 +45,12 @@ def setup(self):
             self.usepywinkeyer_radioButton.setChecked(
                 bool(self.preference["cwtype"] == 2)
             )
+            self.connect_to_server.setChecked(
+                bool(self.preference.get("useserver"))
+            )
+            self.multicast_group.setText(self.preference.get("multicast_group"))
+            self.multicast_port.setText(str(self.preference.get("multicast_port")))
+            self.interface_ip.setText(self.preference.get("interface_ip"))
 
     @staticmethod
     def relpath(filename: str) -> str:
@@ -85,6 +91,10 @@ def save_changes(self):
             self.preference["cwtype"] = 1
         if self.usepywinkeyer_radioButton.isChecked():
             self.preference["cwtype"] = 2
+        self.preference["useserver"] = self.connect_to_server.isChecked()
+        self.preference["multicast_group"] = self.multicast_group.text()
+        self.preference["multicast_port"] = self.multicast_port.text()
+        self.preference["interface_ip"] = self.interface_ip.text()
         try:
             logging.info("save_changes:")
             with open(
diff --git a/lib/version.py b/lib/version.py
index c750d1e..f211d4d 100644
--- a/lib/version.py
+++ b/lib/version.py
@@ -1,2 +1,2 @@
 """It's the version"""
-__version__ = "22.7.21"
+__version__ = "22.9.13"
diff --git a/pics/band_conflict.png b/pics/band_conflict.png
new file mode 100644
index 0000000..24505a9
Binary files /dev/null and b/pics/band_conflict.png differ
diff --git a/pics/group_chat.png b/pics/group_chat.png
new file mode 100644
index 0000000..a71ad30
Binary files /dev/null and b/pics/group_chat.png differ
diff --git a/pics/group_server_settings.png b/pics/group_server_settings.png
new file mode 100644
index 0000000..be9e8be
Binary files /dev/null and b/pics/group_server_settings.png differ
diff --git a/pics/server_pic.png b/pics/server_pic.png
new file mode 100644
index 0000000..84aaa9e
Binary files /dev/null and b/pics/server_pic.png differ
diff --git a/server.py b/server.py
new file mode 100755
index 0000000..cadfce4
--- /dev/null
+++ b/server.py
@@ -0,0 +1,668 @@
+#!/usr/bin/env python3
+"""This is the description"""
+
+# COLOR_BLACK	Black
+# COLOR_BLUE	Blue
+# COLOR_CYAN	Cyan (light greenish blue)
+# COLOR_GREEN	Green
+# COLOR_MAGENTA	Magenta (purplish red)
+# COLOR_RED	Red
+# COLOR_WHITE	White
+# COLOR_YELLOW	Yellow
+
+import curses
+from curses import wrapper
+from curses.textpad import rectangle
+import logging
+import socket
+import time
+from time import gmtime, strftime
+import os
+import sys
+import threading
+
+from itertools import chain
+from json import JSONDecodeError, loads, dumps
+from pathlib import Path
+from lib.server_database import DataBase
+from lib.version import __version__
+
+# from lib.settings import Settings
+# pylint: disable=no-name-in-module, invalid-name, c-extension-no-member, global-statement
+
+
+if Path("./debug").exists():
+    logging.basicConfig(
+        filename="server_debug.log",
+        filemode="w+",
+        format=(
+            "[%(asctime)s] %(levelname)s %(module)s - "
+            "%(funcName)s Line %(lineno)d: %(message)s"
+        ),
+        datefmt="%H:%M:%S",
+        level=logging.DEBUG,
+    )
+    logging.debug("Debug started")
+else:
+    logging.basicConfig(level=logging.CRITICAL)
+
+
+class Trafficlog:
+    """holds recent udp log traffic"""
+
+    def __init__(self):
+        self.items = []
+
+    def add_item(self, item):
+        """adds an item to the log and trims the log"""
+        self.items.append(item)
+        if len(self.items) > 16:
+            self.items = self.items[1 : len(self.items)]
+
+    def get_log(self):
+        """returns a list of log items"""
+        return self.items
+
+
+DB = DataBase("server_database.db")
+
+MULTICAST_PORT = 2239
+MULTICAST_GROUP = "224.1.1.1"
+INTERFACE_IP = "0.0.0.0"
+
+OURCALL = "XXXX"
+OURCLASS = "XX"
+OURSECTION = "XXX"
+ALTPOWER = 0
+QRP = 0
+HIGHPOWER = 0
+NAME = "Hiram Maxim"
+ADDRESS = "225 Main Street"
+CITY = "Newington"
+STATE = "CT"
+POSTALCODE = "06111"
+COUNTRY = "USA"
+EMAIL = "Hiram.Maxim@arrl.net"
+
+POINTS = 0
+LASTHOUR = 0
+LAST15 = 0
+
+LOG = Trafficlog()
+THE_SCREEN = curses.initscr()
+LOGWINDOW = curses.newwin(16, 49, 7, 1)
+QUEWINDOW = curses.newwin(9, 26, 7, 52)
+PEOPLEWINDOW = curses.newwin(6, 28, 17, 51)
+
+people = {}
+
+
+height, width = THE_SCREEN.getmaxyx()
+if height < 24 or width < 80:
+    print("Terminal size needs to be at least 80x24")
+    curses.endwin()
+    sys.exit()
+
+try:
+    if os.path.exists("./server_preferences.json"):
+        with open(
+            "./server_preferences.json", "rt", encoding="utf-8"
+        ) as _file_descriptor:
+            preference = loads(_file_descriptor.read())
+            logging.info("%s", preference)
+            MULTICAST_PORT = preference.get("multicast_port")
+            MULTICAST_GROUP = preference.get("mullticast_group")
+            INTERFACE_IP = preference.get("interface_ip")
+            OURCALL = preference.get("ourcall")
+            OURCLASS = preference.get("ourclass")
+            OURSECTION = preference.get("oursection")
+            ALTPOWER = preference.get("altpower")
+            NAME = preference.get("name")
+            ADDRESS = preference.get("address")
+            CITY = preference.get("city")
+            STATE = preference.get("state")
+            POSTALCODE = preference.get("postalcode")
+            COUNTRY = preference.get("country")
+            EMAIL = preference.get("email")
+    else:
+        print("-=* No Settings File Using Defaults *=-")
+except IOError as exception:
+    logging.critical("%s", exception)
+
+
+def prectangle(win, uly, ulx, lry, lrx):
+    """Draw a rectangle with corners at the provided upper-left
+    and lower-right coordinates.
+    """
+    win.vline(uly + 1, ulx, curses.ACS_VLINE, lry - uly - 1)
+    win.hline(uly, ulx + 1, curses.ACS_HLINE, lrx - ulx - 1)
+    win.hline(lry, ulx + 1, curses.ACS_HLINE, lrx - ulx - 1)
+    win.vline(uly + 1, lrx, curses.ACS_VLINE, lry - uly - 1)
+    win.addch(uly, ulx, curses.ACS_LTEE)  # UL
+    win.addch(uly, lrx, curses.ACS_RTEE)  # UR
+    try:
+        win.addch(lry, lrx, curses.ACS_LRCORNER)  # LR
+    except curses.error:
+        pass
+    win.addch(lry, ulx, curses.ACS_BTEE)  # LL
+    win.vline(1, 0, curses.ACS_VLINE, 5)
+    win.vline(1, 79, curses.ACS_VLINE, 5)
+    win.vline(1, 22, curses.ACS_VLINE, 5)
+    win.vline(1, 58, curses.ACS_VLINE, 5)
+    win.addch(6, 0, curses.ACS_LTEE)
+    win.addch(6, 79, curses.ACS_RTEE)
+    win.addch(6, 50, curses.ACS_TTEE)
+    win.addch(6, 58, curses.ACS_BTEE)
+    win.addch(6, 22, curses.ACS_BTEE)
+
+
+def ptitle(win, y, x1, x2, title):
+    """Prints title"""
+    title_length = len(title)
+    middle_window = x2 - (x2 - x1) / 2
+    x = int(middle_window - (title_length / 2))
+    win.addstr(y, x, title, curses.color_pair(7))
+    win.addch(y, x - 1, curses.ACS_RTEE)
+    win.addch(y, x + title_length, curses.ACS_LTEE)
+
+
+def send_pulse():
+    """send heartbeat"""
+    while True:
+        pulse = b'{"cmd": "PING", "host": "server"}'
+        s.sendto(pulse, (MULTICAST_GROUP, MULTICAST_PORT))
+        time.sleep(10)
+
+
+s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+s.bind(("", MULTICAST_PORT))
+mreq = socket.inet_aton(MULTICAST_GROUP) + socket.inet_aton(INTERFACE_IP)
+s.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, bytes(mreq))
+
+
+_heartbeat = threading.Thread(
+    target=send_pulse,
+    daemon=True,
+)
+_heartbeat.start()
+
+
+def comm_log():
+    """Display recent UDP traffic"""
+    try:
+        LOGWINDOW.clear()
+        # LOGWINDOW.box()
+        for display_line, display_item in enumerate(LOG.get_log()):
+            LOGWINDOW.addstr(display_line, 0, display_item)
+        LOGWINDOW.refresh()
+    except curses.error as err:
+        logging.debug("%s", err)
+    bands = ["160", "80", "40", "20", "15", "10", "6", "2"]
+    blist = []
+    list_o_bands = DB.get_bands()
+    if list_o_bands:
+        for count in list_o_bands:
+            blist.append(count[0])
+    QUEWINDOW.clear()
+    # QUEWINDOW.box()
+    QUEWINDOW.addstr(0, 0, "Band   CW    PH    DI", curses.color_pair(1))
+    for yline, band in enumerate(bands):
+        cwt = DB.get_band_mode_tally(band, "CW")
+        dit = DB.get_band_mode_tally(band, "DI")
+        pht = DB.get_band_mode_tally(band, "PH")
+        line = (
+            f"{str(band).rjust(4, ' ')} "
+            f"{str(cwt[0]).rjust(5, ' ')} "
+            f"{str(pht[0]).rjust(5, ' ')} "
+            f"{str(dit[0]).rjust(5, ' ')}"
+        )
+        QUEWINDOW.addstr(yline + 1, 0, line)
+    THE_SCREEN.refresh()
+    QUEWINDOW.refresh()
+
+
+def show_people():
+    """Display operators"""
+    rev_dict = {}
+    for key, value in people.items():
+        rev_dict.setdefault(value, set()).add(key)
+    result = set(
+        chain.from_iterable(
+            values for key, values in rev_dict.items() if len(values) > 1
+        )
+    )
+    PEOPLEWINDOW.clear()
+    xcol = 0
+    for yline, op_callsign in enumerate(people.keys()):
+        if yline > 5:
+            yline -= 6
+            xcol = 15
+        try:
+            if op_callsign in result:
+                # packet = {"cmd": "CONFLICT"}
+                # packet["bandmode"] = people.get(op_callsign)
+                # packet["recipient"] = op_callsign
+                # bytes_to_send = bytes(dumps(packet), encoding="ascii")
+                # s.sendto(bytes_to_send, (MULTICAST_GROUP, MULTICAST_PORT))
+                PEOPLEWINDOW.addnstr(
+                    yline,
+                    xcol,
+                    f"{op_callsign.rjust(6,' ')} {people.get(op_callsign).rjust(6, ' ')}",
+                    13,
+                    curses.color_pair(2) | curses.A_BOLD,
+                )
+            else:
+                PEOPLEWINDOW.addnstr(
+                    yline,
+                    xcol,
+                    f"{op_callsign.rjust(6,' ')} {people.get(op_callsign).rjust(6, ' ')}",
+                    13,
+                )
+        except curses.error:
+            logging.debug("yline: %d xcol: %d", yline, xcol)
+    THE_SCREEN.refresh()
+    PEOPLEWINDOW.refresh()
+
+
+def get_stats():
+    """Get statistics"""
+    global QRP
+    (
+        cwcontacts,
+        phonecontacts,
+        digitalcontacts,
+        _,
+        last15,
+        lasthour,
+        _,
+        QRP,
+    ) = DB.stats()
+
+    points = (int(cwcontacts) * 2) + (int(digitalcontacts) * 2) + int(phonecontacts)
+
+    score = (((QRP * 3) * ALTPOWER) + 2) * points
+
+    THE_SCREEN.addstr(2, 73, f"{score}", curses.color_pair(7))
+    THE_SCREEN.addstr(3, 73, f"{lasthour}", curses.color_pair(7))
+    THE_SCREEN.addstr(4, 73, f"{last15}", curses.color_pair(7))
+
+
+def fakefreq(band, mode):
+    """
+    If unable to obtain a frequency from the rig,
+    This will return a sane value for a frequency mainly for the cabrillo and adif log.
+    Takes a band and mode as input and returns freq in khz.
+    """
+    logging.info("fakefreq: band:%s mode:%s", band, mode)
+    modes = {"CW": 0, "DI": 1, "PH": 2, "FT8": 1, "SSB": 2}
+    fakefreqs = {
+        "160": ["1830", "1805", "1840"],
+        "80": ["3530", "3559", "3970"],
+        "60": ["5332", "5373", "5405"],
+        "40": ["7030", "7040", "7250"],
+        "30": ["10130", "10130", "0000"],
+        "20": ["14030", "14070", "14250"],
+        "17": ["18080", "18100", "18150"],
+        "15": ["21065", "21070", "21200"],
+        "12": ["24911", "24920", "24970"],
+        "10": ["28065", "28070", "28400"],
+        "6": ["50.030", "50300", "50125"],
+        "2": ["144030", "144144", "144250"],
+        "222": ["222100", "222070", "222100"],
+        "432": ["432070", "432200", "432100"],
+        "SAT": ["144144", "144144", "144144"],
+    }
+    freqtoreturn = fakefreqs[band][modes[mode]]
+    logging.info("fakefreq: returning:%s", freqtoreturn)
+    return freqtoreturn
+
+
+def calcscore():
+    """
+    Return our current score based on operating power,
+    altpower and types of contacts.
+
+    2022 scoring: contacts over 100w are disallowed.
+    QRP and Low Power (<100W) have base multiplier of 2.
+    QRP with Alt Power has base multiplier of 5
+    """
+    global QRP
+    QRP, _ = DB.qrp_check()
+    c_dubs, phone, digital = DB.contacts_under_101watts()
+    score = (int(c_dubs) * 2) + int(phone) + (int(digital) * 2)
+    multiplier = 2
+    if QRP and ALTPOWER:
+        multiplier = 5
+    score = score * multiplier
+    return score
+
+
+def cabrillo():
+    """
+    Generates a cabrillo log file.
+    """
+    filename = f"{OURCALL}.log"
+    log = DB.fetch_all_contacts_asc()
+    if not log:
+        return
+    catpower = ""
+    if QRP:
+        catpower = "QRP"
+    elif HIGHPOWER:
+        catpower = "HIGH"
+    else:
+        catpower = "LOW"
+    try:
+        with open(filename, "w", encoding="ascii") as file_descriptor:
+            print("START-OF-LOG: 3.0", end="\r\n", file=file_descriptor)
+            print(
+                "CREATED-BY: K6GTE Field Day Logger",
+                end="\r\n",
+                file=file_descriptor,
+            )
+            print("CONTEST: ARRL-FD", end="\r\n", file=file_descriptor)
+            print(
+                f"CALLSIGN: {OURCALL}",
+                end="\r\n",
+                file=file_descriptor,
+            )
+            print(
+                f"LOCATION: {OURSECTION}",
+                end="\r\n",
+                file=file_descriptor,
+            )
+            print(
+                f"CATEGORY: {OURCLASS}",
+                end="\r\n",
+                file=file_descriptor,
+            )
+
+            print("CATEGORY-BAND: ALL", end="\r\n", file=file_descriptor)
+            print("CATEGORY-MODE: MIXED", end="\r\n", file=file_descriptor)
+            print("CATEGORY-OPERATOR: MULTI-OP", end="\r\n", file=file_descriptor)
+            print("CATEGORY-STATION: PORTABLE", end="\r\n", file=file_descriptor)
+            print(f"CATEGORY-POWER: {catpower}", end="\r\n", file=file_descriptor)
+            print("CLUB: Test club", end="\r\n", file=file_descriptor)
+            print(
+                f"CLAIMED-SCORE: {calcscore()}",
+                end="\r\n",
+                file=file_descriptor,
+            )
+            print(f"NAME: {NAME}", end="\r\n", file=file_descriptor)
+            print(f"ADDRESS: {ADDRESS}", end="\r\n", file=file_descriptor)
+            print(f"ADDRESS-CITY: {CITY}", end="\r\n", file=file_descriptor)
+            print(f"ADDRESS-STATE: {STATE}", end="\r\n", file=file_descriptor)
+            print(f"ADDRESS-POSTALCODE: {POSTALCODE}", end="\r\n", file=file_descriptor)
+            print(f"ADDRESS-COUNTRY: {COUNTRY}", end="\r\n", file=file_descriptor)
+            print(f"EMAIL: {EMAIL}", end="\r\n", file=file_descriptor)
+            ops = DB.get_operators()
+            operator_list = []
+            for operators in ops:
+                operator_list.append(operators[0])
+            print(
+                f"OPERATORS: {' '.join(operator_list)}",
+                f"@{OURCALL}",
+                end="\r\n",
+                file=file_descriptor,
+            )
+            for contact in log:
+                (
+                    _,
+                    _,
+                    hiscall,
+                    hisclass,
+                    hissection,
+                    the_datetime,
+                    freq,
+                    band,
+                    mode,
+                    _,
+                    _,
+                    _,
+                    _,
+                ) = contact
+                if mode == "DI":
+                    mode = "DG"
+                loggeddate = the_datetime[:10]
+                loggedtime = the_datetime[11:13] + the_datetime[14:16]
+                try:
+                    temp = str(freq / 1000000).split(".")
+                    freq = temp[0] + temp[1].ljust(3, "0")[:3]
+                except TypeError:
+                    freq = "UNKNOWN"
+                if freq == "0000":
+                    freq = fakefreq(band, mode)
+                print(
+                    f"QSO: {freq.rjust(6)} {mode} {loggeddate} {loggedtime} "
+                    f"{OURCALL} {OURCLASS} "
+                    f"{OURSECTION} {hiscall} "
+                    f"{hisclass} {hissection}",
+                    end="\r\n",
+                    file=file_descriptor,
+                )
+            print("END-OF-LOG:", end="\r\n", file=file_descriptor)
+    except IOError as err:
+        logging.critical("cabrillo: IO error: %s, writing to %s", err, filename)
+        return
+
+
+def main(_):
+    """Main loop"""
+    os.environ.setdefault("ESCDELAY", "25")
+    curses.curs_set(0)
+    curses.set_tabsize(4)
+    curses.start_color()
+    curses.use_default_colors()
+    if curses.can_change_color():
+        curses.init_color(curses.COLOR_MAGENTA, 1000, 640, 0)
+        curses.init_color(curses.COLOR_BLACK, 0, 0, 0)
+        curses.init_color(curses.COLOR_CYAN, 500, 500, 500)
+        curses.init_pair(1, curses.COLOR_MAGENTA, -1)
+        curses.init_pair(2, curses.COLOR_RED, -1)
+        curses.init_pair(3, curses.COLOR_CYAN, -1)
+        curses.init_pair(4, curses.COLOR_GREEN, -1)
+        curses.init_pair(5, curses.COLOR_BLUE, -1)
+        curses.init_pair(6, curses.COLOR_YELLOW, -1)
+        curses.init_pair(7, curses.COLOR_WHITE, -1)
+        curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_MAGENTA)
+    curses.noecho()
+    curses.cbreak()
+    THE_SCREEN.attron(curses.color_pair(8))
+    line = f"Field Day aggregation server v{__version__}".center(width, " ")
+    THE_SCREEN.addstr(0, 0, line)
+    THE_SCREEN.attron(curses.color_pair(1))
+    THE_SCREEN.addstr(
+        1,
+        0,
+        "   Group information           Network Information                Scoring\n",
+    )
+
+    THE_SCREEN.addstr(2, 2, "Call____:")
+    THE_SCREEN.addstr(f" {OURCALL}", curses.color_pair(7))
+    THE_SCREEN.addstr(3, 2, "Class___:")
+    THE_SCREEN.addstr(f" {OURCLASS}", curses.color_pair(7))
+    THE_SCREEN.addstr(4, 2, "Section_:")
+    THE_SCREEN.addstr(f" {OURSECTION}", curses.color_pair(7))
+    THE_SCREEN.addstr(5, 2, "AltPower:")
+    THE_SCREEN.addstr(f" {bool(ALTPOWER)}", curses.color_pair(7))
+
+    THE_SCREEN.addstr(2, 25, "Multicast Group: ")
+    THE_SCREEN.addstr(f"{MULTICAST_GROUP}", curses.color_pair(7))
+    THE_SCREEN.addstr(3, 25, "Multicast Port_: ")
+    THE_SCREEN.addstr(f"{MULTICAST_PORT}", curses.color_pair(7))
+    THE_SCREEN.addstr(4, 25, "Interface IP___: ")
+    THE_SCREEN.addstr(f"{INTERFACE_IP}", curses.color_pair(7))
+
+    THE_SCREEN.addstr(2, 60, "Points_____: ")
+    THE_SCREEN.addstr(f"{POINTS}", curses.color_pair(7))
+    THE_SCREEN.addstr(3, 60, "Last Hour__: ")
+    THE_SCREEN.addstr(f"{LASTHOUR}", curses.color_pair(7))
+    THE_SCREEN.addstr(4, 60, "Last 15 Min: ")
+    THE_SCREEN.addstr(f"{LAST15}", curses.color_pair(7))
+
+    rectangle(THE_SCREEN, 6, 0, 23, 50)
+    rectangle(THE_SCREEN, 6, 50, 16, 79)
+
+    prectangle(THE_SCREEN, 16, 50, 23, 79)
+    ptitle(THE_SCREEN, 6, 0, 50, "UDP Activity")
+    ptitle(THE_SCREEN, 6, 50, 79, "Contacts")
+    ptitle(THE_SCREEN, 16, 50, 79, "Operators")
+    get_stats()
+    THE_SCREEN.refresh()
+    show_people()
+    while 1:
+        try:
+            payload = s.recv(1500)
+            try:
+                json_data = loads(payload.decode())
+            except UnicodeDecodeError as err:
+                the_error = f"Not Unicode: {err}\n{payload}\n"
+                logging.debug(the_error)
+                continue
+            except JSONDecodeError as err:
+                the_error = f"Not JSON: {err}\n{payload}\n"
+                logging.debug(the_error)
+                continue
+            logging.debug("%s", json_data)
+            timestamp = strftime("%H:%M:%S", gmtime())
+            if json_data.get("cmd") == "POST":
+
+                DB.log_contact(
+                    (
+                        json_data.get("unique_id"),
+                        json_data.get("hiscall"),
+                        json_data.get("class"),
+                        json_data.get("section"),
+                        json_data.get("date_and_time"),
+                        json_data.get("frequency"),
+                        json_data.get("band"),
+                        json_data.get("mode"),
+                        json_data.get("power"),
+                        json_data.get("grid"),
+                        json_data.get("opname"),
+                        json_data.get("station"),
+                    )
+                )
+                LOG.add_item(
+                    f"[{timestamp}] New Contact "
+                    f"{json_data.get('station')}: "
+                    f"{json_data.get('hiscall')} "
+                    f"{json_data.get('band')}M "
+                    f"{json_data.get('mode')}"
+                )
+                comm_log()
+                packet = {"cmd": "RESPONSE"}
+                packet["recipient"] = json_data.get("station")
+                packet["subject"] = "POST"
+                packet["unique_id"] = json_data.get("unique_id")
+                bytes_to_send = bytes(dumps(packet), encoding="ascii")
+                s.sendto(bytes_to_send, (MULTICAST_GROUP, MULTICAST_PORT))
+                LOG.add_item(f"[{timestamp}] CONFIRM POST: {json_data.get('station')}")
+                comm_log()
+                get_stats()
+                continue
+
+            if json_data.get("cmd") == "LOG":
+                LOG.add_item(f"[{timestamp}] GENERATE LOG: {json_data.get('station')}")
+                cabrillo()
+                packet = {"cmd": "RESPONSE"}
+                packet["recipient"] = json_data.get("station")
+                packet["subject"] = "LOG"
+                bytes_to_send = bytes(dumps(packet), encoding="ascii")
+                s.sendto(bytes_to_send, (MULTICAST_GROUP, MULTICAST_PORT))
+                LOG.add_item(
+                    f"[{timestamp}] GENERATE LOG CONF: {json_data.get('station')}"
+                )
+                continue
+
+            if json_data.get("cmd") == "GET":
+                LOG.add_item(f"[{timestamp}] {json_data}")
+                comm_log()
+                continue
+
+            if json_data.get("cmd") == "DELETE":
+                LOG.add_item(f"[{timestamp}] Deleting: {json_data.get('unique_id')}")
+                DB.delete_contact(json_data.get("unique_id"))
+                comm_log()
+                packet = {"cmd": "RESPONSE"}
+                packet["recipient"] = json_data.get("station")
+                packet["subject"] = "DELETE"
+                packet["unique_id"] = json_data.get("unique_id")
+                bytes_to_send = bytes(dumps(packet), encoding="ascii")
+                s.sendto(bytes_to_send, (MULTICAST_GROUP, MULTICAST_PORT))
+                LOG.add_item(
+                    f"[{timestamp}] CONFIRM DELETE: {json_data.get('station')}"
+                )
+                comm_log()
+                get_stats()
+                continue
+
+            if json_data.get("cmd") == "UPDATE":
+                LOG.add_item(
+                    f"[{timestamp}] Updating "
+                    f"{json_data.get('unique_id')} "
+                    f"{json_data.get('hiscall')} "
+                    f"{json_data.get('band')} "
+                    f"{json_data.get('mode')}"
+                )
+                DB.change_contact(
+                    (
+                        json_data.get("hiscall"),
+                        json_data.get("class"),
+                        json_data.get("section"),
+                        json_data.get("date_time"),
+                        json_data.get("band"),
+                        json_data.get("mode"),
+                        json_data.get("power"),
+                        json_data.get("station"),
+                        json_data.get("frequency"),
+                        json_data.get("unique_id"),
+                    )
+                )
+                comm_log()
+                packet = {"cmd": "RESPONSE"}
+                packet["recipient"] = json_data.get("station")
+                packet["subject"] = "UPDATE"
+                packet["unique_id"] = json_data.get("unique_id")
+                bytes_to_send = bytes(dumps(packet), encoding="ascii")
+                s.sendto(bytes_to_send, (MULTICAST_GROUP, MULTICAST_PORT))
+                LOG.add_item(
+                    f"[{timestamp}] CONFIRM UPDATE: {json_data.get('station')}"
+                )
+                comm_log()
+                get_stats()
+                continue
+
+            if json_data.get("cmd") == "PING":
+                if json_data.get("station"):
+                    band_mode = f"{json_data.get('band')} {json_data.get('mode')}"
+                    if people.get(json_data.get("station")) != band_mode:
+                        people[json_data.get("station")] = band_mode
+                        LOG.add_item(
+                            f"[{timestamp}] Band/Mode Change: {json_data.get('station')} "
+                            f"{json_data.get('band')}M {json_data.get('mode')}"
+                        )
+                    show_people()
+                comm_log()
+                continue
+
+            if json_data.get("cmd") == "GROUPQUERY":
+                packet = {"cmd": "RESPONSE"}
+                packet["recipient"] = json_data.get("station")
+                packet["subject"] = "HOSTINFO"
+                packet["groupcall"] = OURCALL
+                packet["groupclass"] = OURCLASS
+                packet["groupsection"] = OURSECTION
+                bytes_to_send = bytes(dumps(packet), encoding="ascii")
+                s.sendto(bytes_to_send, (MULTICAST_GROUP, MULTICAST_PORT))
+                LOG.add_item(f"[{timestamp}] GROUPQUERY: {json_data.get('station')}")
+                comm_log()
+
+        except UnicodeDecodeError as err:
+            print(f"[{timestamp}] Not JSON: {err}\n{payload}\n")
+        except JSONDecodeError as err:
+            print(f"[{timestamp}] Not JSON: {err}\n{payload}\n")
+
+
+wrapper(main)
diff --git a/server_preferences.json b/server_preferences.json
new file mode 100644
index 0000000..85539de
--- /dev/null
+++ b/server_preferences.json
@@ -0,0 +1,16 @@
+{
+    "ourcall": "W1AW",
+    "ourclass": "3A",
+    "oursection": "ORG",
+    "altpower": 1,
+    "name": "Hiram Maxim",
+    "address": "225 Main Street",
+    "city": "Newington",
+    "state": "CT",
+    "postalcode": "06111",
+    "country": "USA",
+    "email": "Hiram.Maxim@arrl.net",
+    "mullticast_group": "224.1.1.1",
+    "multicast_port": 2239,
+    "interface_ip": "0.0.0.0"
+}
\ No newline at end of file


Test patch:
diff --git a/testing/inject_multicast.py b/testing/inject_multicast.py
index c5a5162..de240a3 100644
--- a/testing/inject_multicast.py
+++ b/testing/inject_multicast.py
@@ -4,20 +4,87 @@
 # pylint: disable=line-too-long
 import socket
 import time
+import uuid
+from json import dumps, loads
 
 
 def main():
     """Wee"""
-    bytesToSend = b"\xad\xbc\xcb\xda\x00\x00\x00\x02\x00\x00\x00\x0c\x00\x00\x00\x06WSJT-X\x00\x00\x01l\n<adif_ver:5>3.1.0\n<programid:6>WSJT-X\n<EOH>\n<call:5>KE0OG <gridsquare:6>DM10AT <mode:3>FT8 <rst_sent:0> <rst_rcvd:0> <qso_date:8>20210329 <time_on:6>183213 <qso_date_off:8>20210329 <time_off:6>183213 <band:3>20m <freq:9>14.074754 <station_callsign:5>K6GTE <my_gridsquare:6>DM13AT <contest_id:14>ARRL-FIELD-DAY <SRX_STRING:5>1D UT <class:2>1D <arrl_sect:2>UT <EOR>"
-    # bytesToSend = bytes("Hello", encoding="ascii")
     MULTICAST_GROUP = "224.1.1.1"
-    MULTICAST_PORT = 2237
+    MULTICAST_PORT = 2239
 
     sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
     while True:
+        preference = {
+            "cmd": "POST",
+            "hiscall": "K5TUX",
+            "section": "ORG",
+            "class": "1E",
+            "mode": "CW",
+            "band": "20",
+            "frequency": "14032000",
+            "power": "10",
+            "grid": "DM13at",
+            "opname": "Jim",
+            "station": "K6GTE",
+        }
+        preference["unique_id"] = uuid.uuid4().hex
+        bytesToSend = bytes(dumps(preference, indent=4), encoding="ascii")
+        sock.sendto(bytesToSend, (MULTICAST_GROUP, MULTICAST_PORT))
+
+        preference["unique_id"] = "8f73b3fce277473eaf2c923ff4e6a15e"
+        preference["cmd"] = "DELETE"
+        bytesToSend = bytes(dumps(preference, indent=4), encoding="ascii")
         sock.sendto(bytesToSend, (MULTICAST_GROUP, MULTICAST_PORT))
         time.sleep(1)
 
+        preference = {
+            "cmd": "UPDATE",
+            "hiscall": "KM6HQI",
+            "section": "WWA",
+            "class": "12A",
+            "mode": "CW",
+            "band": "40",
+            "frequency": "14032000",
+            "power": "5",
+            "grid": "XX13at",
+            "opname": "BOB",
+            "station": "K6GTE",
+            "date_time": "2022-08-02 21:37:14",
+        }
+        preference["unique_id"] = "a4819bae797e4d60839c94dcae85a38d"
+        bytesToSend = bytes(dumps(preference, indent=4), encoding="ascii")
+        sock.sendto(bytesToSend, (MULTICAST_GROUP, MULTICAST_PORT))
+
+        preference = {
+            "cmd": "PING",
+            "call": "K6GTE",
+            "band": "20",
+            "mode": "CW",
+        }
+        bytesToSend = bytes(dumps(preference, indent=4), encoding="ascii")
+        sock.sendto(bytesToSend, (MULTICAST_GROUP, MULTICAST_PORT))
+
+        preference = {
+            "cmd": "PING",
+            "call": "N6QW",
+            "band": "20",
+            "mode": "PH",
+        }
+        bytesToSend = bytes(dumps(preference, indent=4), encoding="ascii")
+        sock.sendto(bytesToSend, (MULTICAST_GROUP, MULTICAST_PORT))
+
+        preference = {
+            "cmd": "PING",
+            "call": "K6GTE",
+            "band": "40",
+            "mode": "CW",
+        }
+        bytesToSend = bytes(dumps(preference, indent=4), encoding="ascii")
+        sock.sendto(bytesToSend, (MULTICAST_GROUP, MULTICAST_PORT))
+
+        break
+
 
 if __name__ == "__main__":
     main()
diff --git a/testing/multicast_listener.py b/testing/multicast_listener.py
old mode 100644
new mode 100755
index 22ca030..f7fabf7
--- a/testing/multicast_listener.py
+++ b/testing/multicast_listener.py
@@ -3,16 +3,43 @@
 # pylint: disable=invalid-name
 import socket
 import time
+import threading
+import queue
 
-multicast_port = 2237
+multicast_port = 2239
 multicast_group = "224.1.1.1"
 interface_ip = "0.0.0.0"
 
+fifo = queue.Queue()
+
 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
 s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
 s.bind(("", multicast_port))
 mreq = socket.inet_aton(multicast_group) + socket.inet_aton(interface_ip)
 s.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, bytes(mreq))
+s.settimeout(0.1)
+
+
+def watch_udp():
+    """watch udp"""
+    while True:
+        try:
+            datagram = s.recv(1500)
+        except socket.timeout:
+            time.sleep(1)
+            continue
+        if datagram:
+            fifo.put(datagram)
+
+
+_udpwatch = threading.Thread(
+    target=watch_udp,
+    daemon=True,
+)
+_udpwatch.start()
 
 while 1:
-    print(f"[{time.time()}] {s.recv(1500)}")
+    # print("Waiting...")
+    while not fifo.empty():
+        print(f"[{time.time()}] {fifo.get()}\n")
+    time.sleep(1)
diff --git a/testing/simulant.py b/testing/simulant.py
new file mode 100755
index 0000000..20af109
--- /dev/null
+++ b/testing/simulant.py
@@ -0,0 +1,353 @@
+#!/usr/bin/env python3
+"""Simulated Field Day club participant"""
+
+# pylint: disable=global-statement, raise-missing-from
+
+import random
+import socket
+import uuid
+import time
+import threading
+import queue
+import argparse
+from datetime import datetime
+from json import dumps, loads, JSONDecodeError
+
+
+parser = argparse.ArgumentParser(description="Simulate a Field Day participant.")
+parser.add_argument("-c", "--call", type=str, help="Your Callsign")
+parser.add_argument("-b", "--band", type=str, help="Your Band")
+parser.add_argument("-m", "--mode", type=str, help="Your Mode")
+parser.add_argument("-p", "--power", type=str, help="Your Power")
+
+args = parser.parse_args()
+
+MULTICAST_PORT = 2239
+MULTICAST_GROUP = "224.1.1.1"
+INTERFACE_IP = "0.0.0.0"
+GROUP_CALL = None
+
+bands = ("160", "80", "40", "20", "15", "10", "6", "2")
+if args.band:
+    if args.band in bands:
+        BAND = args.band
+    else:
+        print('Allowed bands: "160", "80", "40", "20", "15", "10", "6", "2"')
+        raise SystemExit(1)
+else:
+    BAND = bands[random.randint(0, len(bands) - 1)]
+
+modes = ("CW", "PH", "DI")
+if args.mode:
+    if args.mode.upper() in modes:
+        MODE = args.mode.upper()
+    else:
+        print('Allowed modes: "CW", "PH", "DI"')
+        raise SystemExit(1)
+else:
+    MODE = modes[random.randint(0, len(modes) - 1)]
+
+if args.power:
+    try:
+        POWER = int(args.power)
+        if POWER < 1 or POWER > 100:
+            raise ValueError
+    except ValueError:
+        print("Power is a number between 1 and 100")
+        raise SystemExit(1)
+else:
+    POWER = 5
+
+udp_fifo = queue.Queue()
+server_commands = []
+
+
+def generate_class():
+    """Generates a valid Field Day class"""
+    suffix = ["A", "B", "C", "D", "E", "F"][random.randint(0, 5)]
+    if "C" in suffix:
+        return "1C"
+    if "D" in suffix:
+        return "1D"
+    if "E" in suffix:
+        return "1E"
+    if "B" in suffix:
+        return str(random.randint(1, 2)) + suffix
+    if "A" in suffix:
+        return str(random.randint(3, 20)) + suffix
+
+    return str(random.randint(1, 20)) + suffix
+
+
+def generate_callsign():
+    """Generates a US callsign, Need to add the land of maple syrup."""
+    prefix = ["A", "K", "N", "W"]
+    letters = [
+        "A",
+        "B",
+        "C",
+        "D",
+        "E",
+        "F",
+        "G",
+        "H",
+        "I",
+        "J",
+        "K",
+        "L",
+        "M",
+        "N",
+        "O",
+        "P",
+        "Q",
+        "R",
+        "S",
+        "T",
+        "U",
+        "V",
+        "W",
+        "X",
+        "Y",
+        "Z",
+    ]
+    callsign = prefix[random.randint(0, 3)]
+
+    add_second_prefix_letter = random.randint(0, 2) == 0
+    if "A" in callsign:  # We have no choice. Must add second prefix.
+        callsign += letters[random.randint(0, 11)]
+        add_second_prefix_letter = False
+
+    if add_second_prefix_letter:
+        callsign += letters[random.randint(0, 25)]
+
+    callsign += str(random.randint(0, 9))
+    if "A" in callsign[0]:
+        suffix_length = random.randint(1, 2)
+    else:
+        length = [
+            1,
+            2,
+            2,
+            3,
+            3,
+            3,
+        ]  # Stupid way to get a weighted result. But I'm stupid so it's normal.
+        suffix_length = length[random.randint(0, 5)]
+
+    for unused_variable in range(suffix_length):
+        callsign += letters[random.randint(0, 25)]
+
+    return callsign
+
+
+def generate_section(call):
+    """Generate section based on call region"""
+    call_areas = {
+        "0": "CO MO IA ND KS NE MN SD",
+        "1": "CT RI EMA VT ME WMA NH",
+        "2": "ENY NNY NLI SNJ NNJ WNY",
+        "3": "DE MDC EPA WPA",
+        "4": "AL SC GA SFL KY TN NC VA NFL VI PR WCF",
+        "5": "AR NTX LA OK MS STX NM WTX",
+        "6": "EBA SCV LAX SDG ORG SF PAC SJV SB SV",
+        "7": "AK NV AZ OR EWA UT ID WWA MT WY",
+        "8": "MI WV OH",
+        "9": "IL WI IN",
+    }
+    if call[1].isdigit():
+        area = call[1]
+    else:
+        area = call[2]
+    sections = call_areas[area].split()
+    return sections[random.randint(0, len(sections) - 1)]
+
+
+def fakefreq(band, mode):
+    """
+    If unable to obtain a frequency from the rig,
+    This will return a sane value for a frequency mainly for the cabrillo and adif log.
+    Takes a band and mode as input and returns freq in khz.
+    """
+    _modes = {"CW": 0, "DI": 1, "PH": 2, "FT8": 1, "SSB": 2}
+    fakefreqs = {
+        "160": ["1830", "1805", "1840"],
+        "80": ["3530", "3559", "3970"],
+        "60": ["5332", "5373", "5405"],
+        "40": ["7030", "7040", "7250"],
+        "30": ["10130", "10130", "0000"],
+        "20": ["14030", "14070", "14250"],
+        "17": ["18080", "18100", "18150"],
+        "15": ["21065", "21070", "21200"],
+        "12": ["24911", "24920", "24970"],
+        "10": ["28065", "28070", "28400"],
+        "6": ["50.030", "50300", "50125"],
+        "2": ["144030", "144144", "144250"],
+        "222": ["222100", "222070", "222100"],
+        "432": ["432070", "432200", "432100"],
+        "SAT": ["144144", "144144", "144144"],
+    }
+    freqtoreturn = fakefreqs[band][_modes[mode]]
+    return freqtoreturn
+
+
+def log_contact():
+    """Send a contgact to the server."""
+    unique_id = uuid.uuid4().hex
+    callsign = generate_callsign()
+    contact = {
+        "cmd": "POST",
+        "hiscall": callsign,
+        "class": generate_class(),
+        "section": generate_section(callsign),
+        "mode": MODE,
+        "band": BAND,
+        "frequency": int(float(fakefreq(BAND, MODE)) * 1000),
+        "date_and_time": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
+        "power": POWER,
+        "grid": "DM13at",
+        "opname": "John Doe",
+        "station": STATION_CALL,
+        "unique_id": unique_id,
+    }
+    server_commands.append(contact)
+    bytes_to_send = bytes(dumps(contact, indent=4), encoding="ascii")
+    try:
+        s.sendto(bytes_to_send, (MULTICAST_GROUP, int(MULTICAST_PORT)))
+    except OSError as err:
+        print(f"Error: {err}")
+        # logging.warning("%s", err)
+
+
+def remove_confirmed_commands(data):
+    """Removed confirmed commands from the sent commands list."""
+    for index, item in enumerate(server_commands):
+        if item.get("unique_id") == data.get("unique_id") and item.get(
+            "cmd"
+        ) == data.get("subject"):
+            server_commands.pop(index)
+            print(f"Confirmed {data.get('subject')}")
+
+
+def watch_udp():
+    """Puts UDP datagrams in a FIFO queue"""
+    while True:
+        try:
+            datagram = s.recv(1500)
+        except socket.timeout:
+            time.sleep(1)
+            continue
+        if datagram:
+            udp_fifo.put(datagram)
+
+
+def check_udp_queue():
+    """checks the UDP datagram queue."""
+    global GROUP_CALL
+    while not udp_fifo.empty():
+        datagram = udp_fifo.get()
+        try:
+            json_data = loads(datagram.decode())
+        except UnicodeDecodeError as err:
+            the_error = f"Not Unicode: {err}\n{datagram}"
+            print(the_error)
+            continue
+        except JSONDecodeError as err:
+            the_error = f"Not JSON: {err}\n{datagram}"
+            print(the_error)
+            continue
+        # logging.info("%s", json_data)
+        if json_data.get("cmd") == "PING":
+            pass
+            # print(f"[{strftime('%H:%M:%S', gmtime())}] {json_data}")
+        if json_data.get("cmd") == "RESPONSE":
+            if json_data.get("recipient") == STATION_CALL:
+                if json_data.get("subject") == "HOSTINFO":
+                    GROUP_CALL = str(json_data.get("groupcall"))
+                    return
+                if json_data.get("subject") == "LOG":
+                    print("Server Generated Log.")
+
+                remove_confirmed_commands(json_data)
+
+        if json_data.get("cmd") == "CONFLICT":
+            band, mode = json_data.get("bandmode").split()
+            if (
+                band == BAND
+                and mode == MODE
+                and json_data.get("recipient") == STATION_CALL
+            ):
+                print(f"CONFLICT ON {json_data.get('bandmode')}")
+        if json_data.get("cmd") == "GROUPQUERY":
+            if GROUP_CALL:
+                send_status_udp()
+
+
+def query_group():
+    """Sends request to server asking for group call/class/section."""
+    update = {
+        "cmd": "GROUPQUERY",
+        "station": STATION_CALL,
+    }
+    bytes_to_send = bytes(dumps(update), encoding="ascii")
+    try:
+        s.sendto(bytes_to_send, (MULTICAST_GROUP, int(MULTICAST_PORT)))
+    except OSError as err:
+        print(f"{err}")
+
+
+def send_status_udp():
+    """Send status update to server informing of our band and mode"""
+
+    if GROUP_CALL is None:
+        query_group()
+        # return
+
+    update = {
+        "cmd": "PING",
+        "mode": MODE,
+        "band": BAND,
+        "station": STATION_CALL,
+    }
+    bytes_to_send = bytes(dumps(update), encoding="ascii")
+    try:
+        s.sendto(bytes_to_send, (MULTICAST_GROUP, int(MULTICAST_PORT)))
+    except OSError as err:
+        print(f"Error: {err}")
+
+
+s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+s.bind(("", MULTICAST_PORT))
+mreq = socket.inet_aton(MULTICAST_GROUP) + socket.inet_aton(INTERFACE_IP)
+s.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, bytes(mreq))
+s.settimeout(0.01)
+
+if args.call:
+    STATION_CALL = args.call.upper()
+else:
+    STATION_CALL = generate_callsign()
+
+
+def main():
+    """The main loop"""
+    _udpwatch = threading.Thread(
+        target=watch_udp,
+        daemon=True,
+    )
+    _udpwatch.start()
+    print(f"Station: {STATION_CALL} on {BAND}M {MODE}")
+    send_status_udp()
+    count = 0
+    while True:
+        count += 1
+        if count % 30 == 0:
+            log_contact()
+        if count % 60 == 0:
+            send_status_udp()
+        check_udp_queue()
+
+        time.sleep(1)
+
+
+if __name__ == "__main__":
+    main()