292 lines
9.4 KiB
Python
292 lines
9.4 KiB
Python
from datetime import datetime, timezone, timedelta
|
|
from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass, SdrBusyState
|
|
from owrx.config import Config
|
|
import threading
|
|
import math
|
|
from abc import ABC, ABCMeta, abstractmethod
|
|
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ScheduleEntry(ABC):
|
|
def __init__(self, startTime, endTime, profile):
|
|
self.startTime = startTime
|
|
self.endTime = endTime
|
|
self.profile = profile
|
|
|
|
def getProfile(self):
|
|
return self.profile
|
|
|
|
def __str__(self):
|
|
return "{0} - {1}: {2}".format(self.startTime, self.endTime, self.profile)
|
|
|
|
@abstractmethod
|
|
def isCurrent(self, dt):
|
|
pass
|
|
|
|
@abstractmethod
|
|
def getScheduledEnd(self):
|
|
pass
|
|
|
|
@abstractmethod
|
|
def getNextActivation(self):
|
|
pass
|
|
|
|
|
|
class TimeScheduleEntry(ScheduleEntry):
|
|
def isCurrent(self, dt):
|
|
time = dt.time()
|
|
if self.startTime < self.endTime:
|
|
return self.startTime <= time < self.endTime
|
|
else:
|
|
return self.startTime <= time or time < self.endTime
|
|
|
|
def getScheduledEnd(self):
|
|
now = datetime.utcnow()
|
|
end = now.combine(date=now.date(), time=self.endTime)
|
|
while end < now:
|
|
end += timedelta(days=1)
|
|
return end
|
|
|
|
def getNextActivation(self):
|
|
now = datetime.utcnow()
|
|
start = now.combine(date=now.date(), time=self.startTime)
|
|
while start < now:
|
|
start += timedelta(days=1)
|
|
return start
|
|
|
|
|
|
class DatetimeScheduleEntry(ScheduleEntry):
|
|
def isCurrent(self, dt):
|
|
return self.startTime <= dt < self.endTime
|
|
|
|
def getScheduledEnd(self):
|
|
return self.endTime
|
|
|
|
def getNextActivation(self):
|
|
return self.startTime
|
|
|
|
|
|
class Schedule(ABC):
|
|
@staticmethod
|
|
def parse(props):
|
|
if "scheduler" in props:
|
|
sc = props["scheduler"]
|
|
t = sc["type"] if "type" in sc else "static"
|
|
if t == "static":
|
|
return StaticSchedule(sc["schedule"])
|
|
elif t == "daylight":
|
|
return DaylightSchedule(sc["schedule"])
|
|
else:
|
|
logger.warning("Invalid scheduler type: %s", t)
|
|
# downwards compatibility
|
|
elif "schedule" in props:
|
|
return StaticSchedule(props["schedule"])
|
|
|
|
@abstractmethod
|
|
def getCurrentEntry(self):
|
|
pass
|
|
|
|
@abstractmethod
|
|
def getNextEntry(self):
|
|
pass
|
|
|
|
|
|
class TimerangeSchedule(Schedule, metaclass=ABCMeta):
|
|
@abstractmethod
|
|
def getEntries(self):
|
|
pass
|
|
|
|
def getCurrentEntry(self):
|
|
current = [p for p in self.getEntries() if p.isCurrent(datetime.utcnow())]
|
|
if current:
|
|
return current[0]
|
|
return None
|
|
|
|
def getNextEntry(self):
|
|
s = sorted(self.getEntries(), key=lambda e: e.getNextActivation())
|
|
if s:
|
|
return s[0]
|
|
return None
|
|
|
|
|
|
class StaticSchedule(TimerangeSchedule):
|
|
def __init__(self, scheduleDict):
|
|
self.entries = []
|
|
for time, profile in scheduleDict.items():
|
|
if len(time) != 9:
|
|
logger.warning("invalid schedule spec: %s", time)
|
|
continue
|
|
|
|
startTime = datetime.strptime(time[0:4], "%H%M").replace(tzinfo=timezone.utc).time()
|
|
endTime = datetime.strptime(time[5:9], "%H%M").replace(tzinfo=timezone.utc).time()
|
|
self.entries.append(TimeScheduleEntry(startTime, endTime, profile))
|
|
|
|
def getEntries(self):
|
|
return self.entries
|
|
|
|
|
|
class DaylightSchedule(TimerangeSchedule):
|
|
greyLineTime = timedelta(hours=1)
|
|
|
|
def __init__(self, scheduleDict):
|
|
self.schedule = scheduleDict
|
|
|
|
def getSunTimes(self, date):
|
|
pm = Config.get()
|
|
lat = pm["receiver_gps"]["lat"]
|
|
lng = pm["receiver_gps"]["lon"]
|
|
degtorad = math.pi / 180
|
|
radtodeg = 180 / math.pi
|
|
|
|
# Number of days since 01/01
|
|
days = date.timetuple().tm_yday
|
|
|
|
# Longitudinal correction
|
|
longCorr = 4 * lng
|
|
|
|
# calibrate for solstice
|
|
b = 2 * math.pi * (days - 81) / 365
|
|
|
|
# Equation of Time Correction
|
|
eoTCorr = 9.87 * math.sin(2 * b) - 7.53 * math.cos(b) - 1.5 * math.sin(b)
|
|
|
|
# Solar correction
|
|
solarCorr = longCorr + eoTCorr
|
|
|
|
# Solar declination
|
|
declination = math.asin(math.sin(23.45 * degtorad) * math.sin(b))
|
|
|
|
sunrise = 12 - math.acos(-math.tan(lat * degtorad) * math.tan(declination)) * radtodeg / 15 - solarCorr / 60
|
|
sunset = 12 + math.acos(-math.tan(lat * degtorad) * math.tan(declination)) * radtodeg / 15 - solarCorr / 60
|
|
|
|
midnight = datetime.combine(date, datetime.min.time())
|
|
sunrise = midnight + timedelta(hours=sunrise)
|
|
sunset = midnight + timedelta(hours=sunset)
|
|
logger.debug("for {date} sunrise: {sunrise} sunset {sunset}".format(date=date, sunrise=sunrise, sunset=sunset))
|
|
|
|
return sunrise, sunset
|
|
|
|
def getEntries(self):
|
|
now = datetime.utcnow()
|
|
date = now.date()
|
|
# greyline is optional, it its set it will shorten the other profiles
|
|
useGreyline = "greyline" in self.schedule
|
|
entries = []
|
|
|
|
delta = DaylightSchedule.greyLineTime if useGreyline else timedelta()
|
|
events = []
|
|
# we need to start yesterday for longitudes close to the date line
|
|
offset = -1
|
|
while len(events) < 1:
|
|
sunrise, sunset = self.getSunTimes(date + timedelta(days=offset))
|
|
offset += 1
|
|
events += [{"type": "sunrise", "time": sunrise}, {"type": "sunset", "time": sunset}]
|
|
# keep only events in the future
|
|
events = [v for v in events if v["time"] + delta > now]
|
|
events.sort(key=lambda e: e["time"])
|
|
|
|
previousEvent = None
|
|
for event in events:
|
|
# night profile _until_ sunrise, day profile _until_ sunset
|
|
stype = "night" if event["type"] == "sunrise" else "day"
|
|
if previousEvent is not None or event["time"] - delta > now:
|
|
start = now if previousEvent is None else previousEvent
|
|
entries.append(DatetimeScheduleEntry(start, event["time"] - delta, self.schedule[stype]))
|
|
if useGreyline:
|
|
entries.append(
|
|
DatetimeScheduleEntry(event["time"] - delta, event["time"] + delta, self.schedule["greyline"])
|
|
)
|
|
previousEvent = event["time"] + delta
|
|
|
|
logger.debug([str(e) for e in entries])
|
|
return entries
|
|
|
|
|
|
class ServiceScheduler(SdrSourceEventClient):
|
|
def __init__(self, source):
|
|
self.source = source
|
|
self.selectionTimer = None
|
|
self.schedule = None
|
|
props = self.source.getProps()
|
|
props.filter("center_freq", "samp_rate").wire(self.onFrequencyChange)
|
|
props.wireProperty("scheduler", self.parseSchedule)
|
|
# wireProperty calls parseSchedule with the initial value
|
|
# self.parseSchedule()
|
|
|
|
def parseSchedule(self, *args):
|
|
props = self.source.getProps()
|
|
self.schedule = Schedule.parse(props)
|
|
self.scheduleSelection()
|
|
|
|
def shutdown(self):
|
|
self.cancelTimer()
|
|
self.source.removeClient(self)
|
|
|
|
def scheduleSelection(self, time=None):
|
|
if self.source.getState() is SdrSourceState.FAILED:
|
|
return
|
|
seconds = 10
|
|
if time is not None:
|
|
delta = time - datetime.utcnow()
|
|
seconds = delta.total_seconds()
|
|
self.cancelTimer()
|
|
self.selectionTimer = threading.Timer(seconds, self.selectProfile)
|
|
self.selectionTimer.start()
|
|
|
|
def cancelTimer(self):
|
|
if self.selectionTimer:
|
|
self.selectionTimer.cancel()
|
|
|
|
def getClientClass(self) -> SdrClientClass:
|
|
return SdrClientClass.BACKGROUND
|
|
|
|
def onStateChange(self, state: SdrSourceState):
|
|
if state is SdrSourceState.STOPPING:
|
|
self.scheduleSelection()
|
|
elif state is SdrSourceState.FAILED:
|
|
self.cancelTimer()
|
|
|
|
def onBusyStateChange(self, state: SdrBusyState):
|
|
if state is SdrBusyState.IDLE:
|
|
self.scheduleSelection()
|
|
|
|
def onFrequencyChange(self, changes):
|
|
self.scheduleSelection()
|
|
|
|
def selectProfile(self):
|
|
if self.source.hasClients(SdrClientClass.USER):
|
|
logger.debug("source has active users; not touching")
|
|
return
|
|
|
|
if self.schedule is None:
|
|
logger.debug("no active schedule. releasing source...")
|
|
self.source.removeClient(self)
|
|
return
|
|
|
|
logger.debug("source seems to be idle, selecting profile for background services")
|
|
entry = self.schedule.getCurrentEntry()
|
|
|
|
if entry is None:
|
|
logger.debug("schedule did not return a current profile. releasing source...")
|
|
self.source.removeClient(self)
|
|
logger.debug("checking next (future) entry...")
|
|
nextEntry = self.schedule.getNextEntry()
|
|
if nextEntry is not None:
|
|
self.scheduleSelection(nextEntry.getNextActivation())
|
|
else:
|
|
logger.debug("no next entry available, scheduler standing by for external events.")
|
|
return
|
|
|
|
self.source.addClient(self)
|
|
logger.debug("selected profile %s until %s", entry.getProfile(), entry.getScheduledEnd())
|
|
self.scheduleSelection(entry.getScheduledEnd())
|
|
|
|
try:
|
|
self.source.activateProfile(entry.getProfile())
|
|
self.source.start()
|
|
except KeyError:
|
|
pass
|