458 lines
12 KiB
Python
458 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
libexec_dir = __LIBEXECDIR__
|
|
|
|
offline_timeout = 5 * 60
|
|
|
|
import enum
|
|
import select
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
class statuses(enum.Enum):
|
|
available = 0
|
|
unavailable = 1
|
|
offline = 2
|
|
|
|
own_nick = None
|
|
own_status = None
|
|
|
|
default_target_mac = None
|
|
|
|
peers = {}
|
|
class Peer:
|
|
def __init__(self, status, nick, lastseen):
|
|
self.status = status
|
|
self.nick = nick
|
|
self.lastseen = lastseen
|
|
|
|
def __repr__(self):
|
|
r = 'Peer(%s, %s, %s)' % (repr(self.status), repr(self.nick), repr(self.lastseen))
|
|
if __name__ != '__main__':
|
|
return '%s.%s' % (__name__, r)
|
|
else:
|
|
return r
|
|
|
|
send_queue = []
|
|
ack_failed = {}
|
|
class Message:
|
|
def __init__(self, mac, message, queue_id = None, msgid = None):
|
|
self.mac = mac
|
|
self.message = message
|
|
self.queue_id = queue_id
|
|
self.msgid = msgid
|
|
|
|
def __repr__(self):
|
|
r = 'Message(%s, %s, %s, %s)' % (repr(self.mac), repr(self.message), repr(self.queue_id), repr(self.msgid))
|
|
if __name__ != '__main__':
|
|
return '%s.%s' % (__name__, r)
|
|
else:
|
|
return r
|
|
|
|
def writeall(f, b):
|
|
written = 0
|
|
while written < len(b):
|
|
written += f.write(b[written:])
|
|
|
|
class ReadallError(Exception): pass
|
|
|
|
def readall(f, length):
|
|
read = bytearray()
|
|
while len(read) < length:
|
|
data = f.read(length - len(read))
|
|
if data == b'':
|
|
raise ReadallError('Could not satisfy read of %i bytes' % length)
|
|
read.extend(data)
|
|
return bytes(read)
|
|
|
|
def readall_u16(f):
|
|
u16_bytes = readall(f, 2)
|
|
return (u16_bytes[0] << 8) | u16_bytes[1]
|
|
|
|
class MACParseError(Exception): pass
|
|
|
|
def parse_mac(text):
|
|
parts = text.split(':')
|
|
if len(parts) != 6:
|
|
raise MACParseError('Invalid MAC format: %s' % text)
|
|
|
|
try:
|
|
parsed = bytes(int(field, 16) for field in parts)
|
|
except ValueError:
|
|
raise MACParseError('Invalid MAC format %s' % text)
|
|
|
|
return parsed
|
|
|
|
class NoMatchesError(Exception): pass
|
|
class TooManyMatchesError(Exception): pass
|
|
|
|
def mac_from_name(text):
|
|
global peers
|
|
|
|
# Try to parse as a MAC address
|
|
try:
|
|
mac = parse_mac(text)
|
|
return mac
|
|
except MACParseError as err:
|
|
pass
|
|
|
|
# It was not, try to find a matching nick
|
|
hits = 0
|
|
for peer_mac, peer in peers.items():
|
|
if text == peer.nick or text == '~' + peer.nick:
|
|
hits += 1
|
|
mac = peer_mac
|
|
|
|
if hits == 0:
|
|
raise NoMatchesError(text)
|
|
elif hits > 1:
|
|
raise TooManyMatchesError(text)
|
|
|
|
return mac
|
|
|
|
def format_mac(mac):
|
|
return ':'.join(mac[i:i+1].hex() for i in range(len(mac)))
|
|
|
|
def format_status(status):
|
|
if status == 0:
|
|
return 'available'
|
|
elif status == 1:
|
|
return 'unavailable'
|
|
elif status == 2:
|
|
return 'offline'
|
|
else:
|
|
raise ValueError('Unknown status %i' % status)
|
|
|
|
def send_message(backend, mac, message):
|
|
encoded = message.encode('utf-8')
|
|
writeall(backend, b'm' + mac + bytes([len(encoded) >> 8, len(encoded) & 0xff]) + encoded)
|
|
|
|
def queue_message(backend, mac, message):
|
|
global send_queue
|
|
global next_queue_id
|
|
|
|
if len(send_queue) == 0:
|
|
# Nothing being processed atm, send directly
|
|
send_message(backend, mac, message)
|
|
send_queue.append(Message(mac, message))
|
|
|
|
else:
|
|
# Enqueue with an ID
|
|
if send_queue[-1].queue_id is None:
|
|
next_queue_id = 0
|
|
else:
|
|
next_queue_id = send_queue[-1].queue_id + 1
|
|
|
|
send_queue.append(Message(mac, message, next_queue_id))
|
|
|
|
print('--- Queued (%i)' % next_queue_id)
|
|
|
|
def send_status_request(backend, mac):
|
|
writeall(backend, b'r' + mac)
|
|
|
|
def set_status_nick(backend, status, nick):
|
|
encoded = nick.encode('utf-8')
|
|
writeall(backend, b's' + bytes([status.value, len(encoded)]) + encoded)
|
|
|
|
def handle_user_command(backend, line):
|
|
global own_nick, own_status
|
|
global default_target_mac
|
|
|
|
if len(line) > 0 and line[0] == '/':
|
|
command, _, rest = line.partition(' ')
|
|
|
|
try:
|
|
if command == '/':
|
|
# Send quoted message
|
|
# TODO: Validate message
|
|
if default_target_mac is None:
|
|
print('--- Default target not set, set with /target')
|
|
else:
|
|
queue_message(backend, default_target_mac, rest)
|
|
|
|
elif command == '/msg':
|
|
# Send message to target
|
|
target, _, message = rest.partition(' ')
|
|
mac = mac_from_name(target)
|
|
queue_message(backend, mac, message)
|
|
|
|
elif command == '/status':
|
|
# Request status
|
|
mac = mac_from_name(rest)
|
|
if mac in peers:
|
|
print('=== ~%s (%s) [%s]' % (peers[mac].nick, peers[mac].status.name, format_mac(mac)))
|
|
else:
|
|
send_status_request(backend, mac)
|
|
|
|
elif command == '/available' and rest == '':
|
|
# Set status to available
|
|
own_status = statuses.available
|
|
set_status_nick(backend, own_status, own_nick)
|
|
|
|
elif command == '/unavailable' and rest == '':
|
|
# Set status to unavailable
|
|
own_status = statuses.unavailable
|
|
set_status_nick(backend, own_status, own_nick)
|
|
|
|
elif command == '/nick':
|
|
# Change nick
|
|
# TODO: Validate nick
|
|
own_nick = rest
|
|
set_status_nick(backend, own_status, own_nick)
|
|
|
|
elif command == '/target':
|
|
# Set default target of messages
|
|
default_target_mac = mac_from_name(rest)
|
|
|
|
elif command == '/quit':
|
|
# Quit
|
|
return 'quit'
|
|
|
|
else:
|
|
# Display usage
|
|
print('--- / <message>; /msg <target> <message>; /status <target>; /available; /unavailable; /nick <nick>; /target <target>; /quit')
|
|
|
|
except NoMatchesError as err:
|
|
print('--- name %s matches no peers' % err.args[0])
|
|
|
|
except TooManyMatchesError as err:
|
|
print('--- name %s matches several peers' % err.args[0])
|
|
|
|
else:
|
|
# Send message
|
|
if default_target_mac is None:
|
|
print('--- Default target not set, set with /target')
|
|
else:
|
|
queue_message(backend, default_target_mac, line)
|
|
|
|
def handle_status(mac, status, nick):
|
|
global peers
|
|
|
|
if mac not in peers:
|
|
# Never seen before
|
|
peers[mac] = Peer(nick = None, status = None, lastseen = None)
|
|
|
|
|
|
if peers[mac].nick is not None and peers[mac].status != statuses.offline and nick != peers[mac].nick:
|
|
print('=== ~%s -> ~%s [%s]' % (peers[mac].nick, nick, format_mac(mac)))
|
|
|
|
peers[mac].nick = nick
|
|
|
|
if status != peers[mac].status:
|
|
if status == statuses.offline:
|
|
print('<<< ~%s [%s]' % (nick, format_mac(mac)))
|
|
elif peers[mac].status is None or peers[mac].status == statuses.offline:
|
|
if status == statuses.available:
|
|
print('>>> ~%s [%s]' % (nick, format_mac(mac)))
|
|
else:
|
|
print('>>> ~%s (%s) [%s]' % (nick, status.name, format_mac(mac)))
|
|
else:
|
|
print('=== ~%s (%s) [%s]' % (nick, status.name, format_mac(mac)))
|
|
|
|
peers[mac].status = status
|
|
|
|
def nick_from_mac(mac):
|
|
global peers
|
|
|
|
if mac not in peers:
|
|
return format_mac(mac)
|
|
|
|
else:
|
|
nick = peers[mac].nick
|
|
|
|
# Ensure nicks are unique
|
|
unique = True
|
|
for peer_mac, peer in peers.items():
|
|
if peer_mac == mac: continue
|
|
|
|
if peer.nick == nick:
|
|
# Nick not unique
|
|
unique = False
|
|
break
|
|
|
|
if unique:
|
|
# Unique nicks: ~nick
|
|
return '~' + nick
|
|
else:
|
|
# Non-unique nicks: [MAC]~nick
|
|
return '[%s]~%s' % (format_mac(mac), nick)
|
|
|
|
def handle_message(mac, message):
|
|
nick = nick_from_mac(mac)
|
|
for line in message.split('\n'):
|
|
print('<%s> %s' % (nick, line))
|
|
|
|
def eventloop(proc):
|
|
global peers
|
|
global send_queue, ack_failed
|
|
|
|
# Create unbuffered version of stdin and close the old one as we
|
|
# won't need it anymore
|
|
unbuf_stdin = open(sys.stdin.buffer.fileno(), 'rb', buffering = 0)
|
|
sys.stdin.close()
|
|
|
|
# Set up a poll for inputs (but do output blockingly)
|
|
poll = select.poll()
|
|
poll.register(proc.stdout, select.POLLIN)
|
|
poll.register(unbuf_stdin, select.POLLIN)
|
|
|
|
input_buffer = bytearray()
|
|
|
|
running = True
|
|
while running:
|
|
# Handle offline timeouts
|
|
now = time.monotonic()
|
|
for mac, peer in peers.items():
|
|
if peer.lastseen + offline_timeout < now:
|
|
peer.status = statuses.offline
|
|
print('<<< (timeout) ~%s [%s]' % (peer.nick, format_mac(mac)))
|
|
|
|
# Figure out how long to wait in poll()
|
|
wait = None
|
|
for peer in peers.values():
|
|
if peer.status != statuses.offline:
|
|
if wait is None or wait >= peer.lastseen + offline_timeout - now:
|
|
wait = peer.lastseen + offline_timeout - now
|
|
|
|
# Clamp at 0
|
|
if wait is not None and wait < 0:
|
|
wait = 0
|
|
|
|
# Convert s to ms
|
|
if wait is not None:
|
|
wait = wait * 1000
|
|
|
|
# Process events
|
|
for fd, event in poll.poll(wait):
|
|
if fd == proc.stdout.fileno() and event & select.POLLIN:
|
|
event_type = readall(proc.stdout, 1)
|
|
if event_type == b's':
|
|
# Status
|
|
source_mac = readall(proc.stdout, 6)
|
|
status, = readall(proc.stdout, 1)
|
|
nick_length, = readall(proc.stdout, 1)
|
|
nick = readall(proc.stdout, nick_length,)
|
|
|
|
handle_status(source_mac, statuses(status), nick.decode('utf-8'))
|
|
|
|
peers[source_mac].lastseen = time.monotonic()
|
|
|
|
elif event_type == b'i':
|
|
# Msgid for message
|
|
msgid = readall_u16(proc.stdout)
|
|
send_queue[0].msgid = msgid
|
|
|
|
elif event_type == b'I':
|
|
# Failed to get msgid for message
|
|
message = send_queue.pop(0)
|
|
nick = nick_from_mac(message.mac)
|
|
if message.queue_id is not None:
|
|
print('--- Failed to send to %s (%i)' % (nick, message.queue_id))
|
|
else:
|
|
print('--- Failed to send to %s' % nick)
|
|
|
|
# Send next message if there is one queued
|
|
if len(send_queue) > 0:
|
|
send_message(proc.stdin, send_queue[0].mac, send_queue[0].message)
|
|
|
|
elif event_type == b'a':
|
|
# ACK received
|
|
source_mac = readall(proc.stdout, 6)
|
|
msgid = readall_u16(proc.stdout)
|
|
|
|
peers[source_mac].lastseen = time.monotonic()
|
|
|
|
# Was it for a message currently waiting?
|
|
if len(send_queue) > 0 and send_queue[0].msgid == msgid:
|
|
# Yes, drop is from the queue
|
|
send_queue.pop(0)
|
|
|
|
# Send next message if there is one queued
|
|
if len(send_queue) > 0:
|
|
send_message(proc.stdin, send_queue[0].mac, send_queue[0].message)
|
|
|
|
elif msgid in ack_failed:
|
|
# No, but it was one we thought to have failed
|
|
message = ack_failed[msgid]
|
|
del ack_failed[msgid]
|
|
nick = nick_from_mac(message.mac)
|
|
for line in message.message.split('\n'):
|
|
print('--- %s acknowledged receive: %s' % (nick, line))
|
|
|
|
elif event_type == b'A':
|
|
# ACK not received (and message send failed)
|
|
# Add it to the messages where ack failed
|
|
message = send_queue.pop(0)
|
|
ack_failed[message.msgid] = message
|
|
nick = nick_from_mac(message.mac)
|
|
if message.queue_id is not None:
|
|
print('--- Failed to send to %s (%i)' % (nick, message.queue_id))
|
|
else:
|
|
print('--- Failed to send to %s' % nick)
|
|
|
|
# Send next message if there is one queued
|
|
if len(send_queue) > 0:
|
|
send_message(proc.stdin, send_queue[0].mac, send_queue[0].message)
|
|
|
|
elif event_type == b'm':
|
|
# Message received
|
|
source_mac = readall(proc.stdout, 6)
|
|
message_length = readall_u16(proc.stdout)
|
|
message = readall(proc.stdout, message_length)
|
|
|
|
handle_message(source_mac, message.decode('utf-8'))
|
|
|
|
peers[source_mac].lastseen = time.monotonic()
|
|
|
|
else:
|
|
raise ValueError('Unknown event type from backend: %s' % repr(event_type))
|
|
|
|
elif fd == proc.stdout.fileno() and event & select.POLLHUP:
|
|
print('Backend exited')
|
|
running = False
|
|
|
|
elif fd == unbuf_stdin.fileno() and event & select.POLLIN:
|
|
data = unbuf_stdin.read(1024)
|
|
input_buffer.extend(data)
|
|
|
|
while True:
|
|
newline_location = input_buffer.find(b'\n')
|
|
if newline_location == -1:
|
|
break
|
|
|
|
line, _, input_buffer = input_buffer.partition(b'\n')
|
|
if handle_user_command(proc.stdin, line.decode('utf-8')) == 'quit':
|
|
writeall(proc.stdin, b'q')
|
|
running = False
|
|
|
|
if data == b'':
|
|
# ^D
|
|
writeall(proc.stdin, b'q')
|
|
running = False
|
|
|
|
else:
|
|
raise Exception('Unreachable')
|
|
|
|
def main():
|
|
global own_nick, own_status
|
|
|
|
_, interface, own_nick = sys.argv
|
|
|
|
proc = subprocess.Popen(['sudo', libexec_dir + '/ethermess-backend', interface], stdin = subprocess.PIPE, stdout = subprocess.PIPE, stderr = sys.stderr, bufsize = 0)
|
|
|
|
# Tell the backend the status and nick
|
|
own_status = statuses.available
|
|
encoded = own_nick.encode('utf-8')
|
|
writeall(proc.stdin, bytes([own_status.value, len(encoded)]) + encoded)
|
|
|
|
# Read our MAC
|
|
mac = readall(proc.stdout, 6)
|
|
|
|
print('--- MAC: %s' % format_mac(mac))
|
|
|
|
eventloop(proc)
|
|
|
|
proc.wait()
|
|
|
|
if __name__ == '__main__':
|
|
main()
|