Sensor readouts are now displayed as soon as new data is avaliable (no useless fetcher thread anymore). Invalid sensor ports are now marked. Keyboard shortcuts now validate state before running the action. Added identification action.
553 lines
16 KiB
Python
553 lines
16 KiB
Python
from sys import path
|
|
import os
|
|
from time import strftime
|
|
from functools import partial
|
|
import re
|
|
|
|
fl0w_path = os.path.dirname(os.path.realpath(__file__))
|
|
shared_path = os.path.dirname(os.path.realpath(__file__)) + "/Shared/"
|
|
if fl0w_path not in path:
|
|
path.append(fl0w_path)
|
|
if shared_path not in path:
|
|
path.append(shared_path)
|
|
|
|
|
|
import sublime
|
|
import sublime_plugin
|
|
|
|
from Highway import Client, Route, Pipe, DummyPipe
|
|
from Utils import get_hostname
|
|
|
|
from SublimeMenu import *
|
|
import Logging
|
|
|
|
import webbrowser
|
|
from time import sleep
|
|
import os
|
|
|
|
CHANNEL = 1
|
|
FL0W_STATUS = "fl0w"
|
|
|
|
def plugin_unloaded():
|
|
for window in windows:
|
|
if hasattr(window, "fl0w") and window.fl0w.connected:
|
|
window.fl0w.invoke_disconnect()
|
|
for sensor_type in ("analog", "digital"):
|
|
window.active_view().erase_phantoms(sensor_type)
|
|
|
|
|
|
PARENTHESES_REGEX = re.compile("\((.*?)\)")
|
|
STYLE_OPEN = "<body><style>code { color: var(--orangish); }</style><code>"
|
|
STYLE_CLOSE = "</code></body>"
|
|
|
|
ERROR_OPEN = "<body><style>code { color: var(--redish); }</style><code>"
|
|
ERROR_CLOSE = "</code></body>"
|
|
|
|
windows = []
|
|
sensor_phantoms = []
|
|
|
|
def set_status(status, window):
|
|
window.active_view().set_status(FL0W_STATUS,
|
|
"fl0w: %s" % status)
|
|
|
|
class Fl0wClient(Client):
|
|
def setup(self, routes, fl0w, debug=False):
|
|
super().setup(routes, debug=debug)
|
|
self.fl0w = fl0w
|
|
|
|
|
|
def ready(self):
|
|
self.fl0w.connected = True
|
|
if self.fl0w.debug:
|
|
Logging.info("Connection ready!")
|
|
# Enlist on editor channel
|
|
self.send({"channel" : 1, "name" : get_hostname()}, "subscribe")
|
|
# Subscribe to controller channel
|
|
self.send({"subscribe" : [2]}, "peers")
|
|
|
|
|
|
def closed(self, code, reason):
|
|
self.fl0w.connected = False
|
|
if self.fl0w.debug:
|
|
Logging.info("Connection closed: %s (%s)" % (reason, code))
|
|
|
|
|
|
def peer_unavaliable(self, peer):
|
|
sublime.error_message("The specifed controller is not connected anymore.")
|
|
|
|
|
|
class Info(Route):
|
|
def run(self, data, handler):
|
|
info = ""
|
|
for key in data:
|
|
info += "%s: %s\n" % (key.capitalize(), ", ".join(data[key]))
|
|
sublime.message_dialog(info)
|
|
handler.fl0w.meta.invoke(handler.fl0w.window, back=handler.fl0w.main_menu)
|
|
|
|
|
|
class Sensor(Pipe):
|
|
def run(self, data, peer, handler):
|
|
for sensor_phantom in handler.fl0w.subscriptions:
|
|
sensor_phantom.update_sensor_values(data)
|
|
|
|
|
|
class Peers(Route):
|
|
def start(self, handler):
|
|
self.selected_action_menu = None
|
|
|
|
def run(self, data, handler):
|
|
handler.fl0w.controller_menu.clear()
|
|
for id_ in data:
|
|
action_menu = Menu()
|
|
action_menu.id_ = id_
|
|
action_menu += Entry("Set Target",
|
|
"Set controller as target for program execution and sensor readouts.",
|
|
action=partial(lambda handler, id_: self.set_target(handler, id_),
|
|
handler, id_))
|
|
action_menu += Entry("Programs",
|
|
"Lists all executable programs on the controller.",
|
|
action=partial(lambda handler, id_: handler.pipe(None, "list_programs", id_),
|
|
handler, id_))
|
|
action_menu += Entry("Set Name",
|
|
"Sets the hostname of the selected controller",
|
|
action=partial(lambda handler, id_: Input("New Hostname:", initial_text=data[id_]["name"],
|
|
on_done=lambda hostname: handler.pipe(
|
|
{"set" : hostname}, "hostname", id_)).invoke(handler.fl0w.window), handler, id_))
|
|
action_menu += Entry("Processes",
|
|
"Lists processes currently running on controller.",
|
|
action=partial(lambda handler, id_: handler.pipe(None, "processes", id_),
|
|
handler, id_))
|
|
action_menu += Entry("Identify",
|
|
"Plays an identification sound on the controller.",
|
|
action=partial(lambda handler, id_: handler.pipe(None, "identify", id_),
|
|
handler, id_))
|
|
action_menu.back = handler.fl0w.controller_menu
|
|
handler.fl0w.controller_menu += Entry(data[id_]["name"], id_, sub_menu=action_menu,
|
|
action=self.set_selected_action_menu,
|
|
kwargs={"selected_action_menu" : action_menu})
|
|
|
|
|
|
def set_target(self, handler, peer):
|
|
handler.fl0w.target = peer
|
|
if handler.fl0w.debug:
|
|
set_status("Target: %s" % peer, handler.fl0w.window)
|
|
|
|
|
|
def set_selected_action_menu(self, selected_action_menu):
|
|
self.selected_action_menu = selected_action_menu
|
|
|
|
|
|
class Processes(Pipe):
|
|
def run(self, data, peer, handler):
|
|
view = handler.fl0w.window.new_file()
|
|
view.set_name("Processes")
|
|
view.settings().set("draw_indent_guides", False)
|
|
for line in data:
|
|
view.run_command("append", {"characters": line + "\n"})
|
|
view.set_read_only(True)
|
|
|
|
|
|
class ListPrograms(Pipe):
|
|
def run(self, data, peer, handler):
|
|
program_menu = Menu(subtitles=False)
|
|
for program in data:
|
|
program_menu += Entry(program,
|
|
action=lambda handler: handler.pipe(program,
|
|
"run_program",
|
|
handler.routes["peers"].selected_action_menu.id_),
|
|
kwargs={"handler" : handler})
|
|
program_menu.invoke(handler.fl0w.window,
|
|
back=handler.routes["peers"].selected_action_menu)
|
|
|
|
class StdStream(Pipe):
|
|
def run(data, peer, handler):
|
|
pass
|
|
|
|
|
|
|
|
class Fl0w:
|
|
def __init__(self, window, debug=False):
|
|
self.window = window
|
|
self.folder = window.folders()[0]
|
|
if self.folder != "/":
|
|
self.folder = self.folder + "/"
|
|
|
|
self.connected = False
|
|
|
|
self.subscriptions = {}
|
|
self._combined_subscriptions = {"analog" : [], "digital" : []}
|
|
|
|
self._target = None
|
|
self._debug = debug
|
|
|
|
|
|
self.start_menu = Menu()
|
|
self.start_menu += Entry("Connect", "Connect to a fl0w server",
|
|
action=self.invoke_connect)
|
|
self.start_menu += Entry("About", "Information about fl0w",
|
|
action=self.invoke_about)
|
|
|
|
self.debug_menu = Menu(subtitles=False)
|
|
self.debug_menu += Entry("On",
|
|
action=lambda: self.set_debug(True))
|
|
self.debug_menu += Entry("Off",
|
|
action=lambda: self.set_debug(False))
|
|
|
|
|
|
self.settings = Menu()
|
|
self.settings += Entry("Debug", "Toggle debug mode",
|
|
sub_menu=self.debug_menu)
|
|
|
|
|
|
self.meta = Menu()
|
|
self.meta += Entry("Info", "Server info",
|
|
action=lambda: self.ws.send(None, "info"))
|
|
self.meta_entry = Entry("Meta", "Debug information about fl0w",
|
|
sub_menu=self.meta)
|
|
if self.debug:
|
|
self.main_menu += self.meta_entry
|
|
|
|
|
|
self.main_menu = Menu()
|
|
self.controller_menu = Menu()
|
|
self.main_menu += Entry("Controllers", "All connected controllers",
|
|
sub_menu=self.controller_menu)
|
|
self.main_menu += Entry("Settings", "General purpose settings",
|
|
sub_menu=self.settings)
|
|
self.main_menu += Entry("Disconnect", "Disconnect from server",
|
|
action=self.invoke_disconnect)
|
|
|
|
# Patch all sensor phantom that have been created before a fl0w instance
|
|
# was attached to the window
|
|
for sensor_phantom in sensor_phantoms:
|
|
if sensor_phantom.window.id() == self.window.id():
|
|
sensor_phantom.fl0w = self
|
|
if self.debug:
|
|
Logging.info("Patched sensor phantom '%s'" % str(sensor_phatom))
|
|
|
|
|
|
@property
|
|
def target(self):
|
|
return self._target
|
|
|
|
|
|
@target.setter
|
|
def target(self, target):
|
|
if self.target != None:
|
|
self.ws.pipe("unsubscribe", "sensor", self.target)
|
|
self._target = target
|
|
set_status("Set target: %s" % target, self.window)
|
|
if self.combined_subscriptions != {"analog" : [], "digital" : []}:
|
|
self.ws.pipe({"subscribe" : self.combined_subscriptions_}, "sensor", target)
|
|
|
|
|
|
@property
|
|
def debug(self):
|
|
return self._debug
|
|
|
|
|
|
def set_debug(self, debug):
|
|
self.debug = debug
|
|
|
|
|
|
@debug.setter
|
|
def debug(self, debug):
|
|
if debug:
|
|
self._debug = True
|
|
if not self.meta_entry in self.main_menu.entries.values():
|
|
self.main_menu += self.meta_entry
|
|
else:
|
|
self._debug = False
|
|
self.main_menu -= self.meta_entry
|
|
set_status("Debug set to %s" % self._debug, self.window)
|
|
|
|
|
|
@property
|
|
def combined_subscriptions(self):
|
|
return self._combined_subscriptions
|
|
|
|
|
|
@combined_subscriptions.setter
|
|
def combined_subscriptions(self, combined_subscriptions_):
|
|
if self.combined_subscriptions != combined_subscriptions_:
|
|
self._combined_subscriptions = combined_subscriptions_
|
|
if self.connected and self.target != None:
|
|
self.ws.pipe("unsubscribe", "sensor", self.target)
|
|
self.ws.pipe({"subscribe" : combined_subscriptions_}, "sensor",
|
|
self.target)
|
|
|
|
|
|
def subscribe(self, sensor_phatom, subscriptions):
|
|
self.subscriptions[sensor_phatom] = subscriptions
|
|
self.make_subscriptions()
|
|
|
|
|
|
|
|
def unsubscribe(self, sensor_phantom):
|
|
if sensor_phantom in self.subscriptions:
|
|
del self.subscriptions[sensor_phantom]
|
|
self.make_subscriptions()
|
|
|
|
|
|
def make_subscriptions(self):
|
|
combined_subscriptions = {"analog" : [], "digital" : []}
|
|
for sensor_phantom in self.subscriptions:
|
|
for sensor_type in ("analog", "digital"):
|
|
combined_subscriptions[sensor_type] = list(
|
|
set(combined_subscriptions[sensor_type]) |
|
|
set(self.subscriptions[sensor_phantom][sensor_type])
|
|
)
|
|
self.combined_subscriptions = combined_subscriptions
|
|
|
|
|
|
def run_program(self, path):
|
|
if self.connected and self.target != None:
|
|
relpath = os.path.relpath(path, self.folder)
|
|
if os.path.isfile(self.folder + relpath):
|
|
if self.debug:
|
|
Logging.info("Running program '%s'" % relpath)
|
|
self.ws.pipe(relpath, "run_program", self.target)
|
|
|
|
|
|
def invoke_start_menu(self):
|
|
self.start_menu.invoke(self.window)
|
|
|
|
|
|
def invoke_main_menu(self):
|
|
self.main_menu.invoke(self.window)
|
|
|
|
|
|
def invoke_about(self):
|
|
if sublime.ok_cancel_dialog("fl0w by @robot0nfire", "robot0nfire.com"):
|
|
webbrowser.open("http://robot0nfire.com")
|
|
|
|
|
|
|
|
def connect(self, connect_details):
|
|
try:
|
|
self.ws = Fl0wClient('ws://%s' % connect_details)
|
|
self.ws.setup({"info" : Fl0wClient.Info(), "peers" : Fl0wClient.Peers(),
|
|
"processes" : Fl0wClient.Processes(),
|
|
"list_programs" : Fl0wClient.ListPrograms(), "sensor" : Fl0wClient.Sensor()},
|
|
self, debug=True)
|
|
self.ws.connect()
|
|
sublime.set_timeout_async(self.ws.run_forever, 0)
|
|
set_status("Connection opened '%s'" % self.folder, self.window)
|
|
self.connected = True
|
|
except OSError as e:
|
|
sublime.error_message("Error during connection creation:\n %s" % str(e))
|
|
|
|
|
|
|
|
def invoke_connect(self):
|
|
# Will be removed once autoconnect works
|
|
self.connect("127.0.0.1:3077")
|
|
|
|
|
|
def invoke_disconnect(self):
|
|
if self.connected:
|
|
for sensor_phantom in sensor_phantoms:
|
|
if sensor_phantom.window.id() == self.window.id():
|
|
sensor_phantom.enabled = False
|
|
self.ws.close()
|
|
set_status("Connection closed '%s'" % self.folder, self.window)
|
|
self.connected = False
|
|
|
|
|
|
class Fl0wCommand(sublime_plugin.WindowCommand):
|
|
def run(self):
|
|
valid_window_setup = True
|
|
folder_count = len(self.window.folders())
|
|
if folder_count > 1:
|
|
sublime.error_message("Only one open folder per window is allowed.")
|
|
valid_window_setup = False
|
|
elif folder_count == 0:
|
|
sublime.error_message("No folder open in window.")
|
|
valid_window_setup = False
|
|
if valid_window_setup:
|
|
if not hasattr(self.window, "fl0w"):
|
|
folder = self.window.folders()[0]
|
|
files = os.listdir(folder)
|
|
if not ".no-fl0w" in files:
|
|
if not ".fl0w" in files:
|
|
open(folder + "/.fl0w", 'a').close()
|
|
self.window.fl0w = Fl0w(self.window)
|
|
windows.append(self.window)
|
|
self.window.fl0w.start_menu.invoke(self.window)
|
|
else:
|
|
self.window.fl0w = Fl0w(self.window)
|
|
windows.append(self.window)
|
|
self.window.fl0w.start_menu.invoke(self.window)
|
|
else:
|
|
sublime.error_message("fl0w can't be opened in your current directory (.no-fl0w file exists)")
|
|
else:
|
|
if not self.window.fl0w.connected:
|
|
self.window.fl0w.invoke_start_menu()
|
|
else:
|
|
self.window.fl0w.invoke_main_menu()
|
|
else:
|
|
if hasattr(self.window, "fl0w"):
|
|
sublime.error_message("Window setup was invalidated (Don't close or open any additional folders in a fl0w window)")
|
|
self.window.fl0w.invoke_disconnect()
|
|
|
|
|
|
class RunCommand(sublime_plugin.WindowCommand):
|
|
def run(self):
|
|
if hasattr(self.window, "fl0w"):
|
|
if self.window.fl0w.connected:
|
|
if self.window.fl0w.target == None:
|
|
sublime.error_message("A target controller has to be set to "
|
|
"run programs.")
|
|
else:
|
|
file_name = self.window.active_view().file_name()
|
|
if file_name != None and file_name.endswith(".c"):
|
|
self.window.fl0w.run_program(file_name)
|
|
else:
|
|
sublime.error_message("fl0w is not connected.")
|
|
else:
|
|
sublime.error_message("fl0w is not running in your current window.")
|
|
|
|
|
|
class SensorCommand(sublime_plugin.WindowCommand):
|
|
def __init__(self, window):
|
|
super().__init__(window)
|
|
self.enabled = False
|
|
|
|
|
|
def run(self):
|
|
if not self.enabled:
|
|
if hasattr(self.window, "fl0w"):
|
|
if self.window.fl0w.connected:
|
|
if self.window.fl0w.target == None:
|
|
sublime.error_message("A target controller has to be set to "
|
|
"enable inline sensor readouts.")
|
|
else:
|
|
self.enabled = not self.enabled
|
|
for sensor_phantom in sensor_phantoms:
|
|
if sensor_phantom.window.id() == self.window.id():
|
|
sensor_phantom.enabled = self.enabled
|
|
set_status("Enabled sensor phantoms.", self.window)
|
|
else:
|
|
sublime.error_message("fl0w is not connected.")
|
|
else:
|
|
sublime.error_message("fl0w is not running in your current window.")
|
|
else:
|
|
self.enabled = not self.enabled
|
|
for sensor_phantom in sensor_phantoms:
|
|
if sensor_phantom.window.id() == self.window.id():
|
|
sensor_phantom.enabled = self.enabled
|
|
set_status("Disabled sensor phantoms.", self.window)
|
|
|
|
|
|
class SensorPhantom(sublime_plugin.ViewEventListener):
|
|
def __init__(self, view):
|
|
self.view = view
|
|
self.window = view.window()
|
|
|
|
# Is patched by the fl0w instance that is in control of the same window
|
|
self.fl0w = None
|
|
self._enabled = False
|
|
|
|
self._matches = {"analog" : [], "digital" : []}
|
|
|
|
self.timeout_scheduled = False
|
|
self.needs_update = False
|
|
|
|
for window in windows:
|
|
if hasattr(window, "fl0w"):
|
|
self.fl0w = window.fl0w
|
|
if not self in sensor_phantoms:
|
|
sensor_phantoms.append(self)
|
|
|
|
|
|
@property
|
|
def enabled(self):
|
|
return self._enabled
|
|
|
|
|
|
@enabled.setter
|
|
def enabled(self, enabled_):
|
|
self._enabled = enabled_
|
|
if enabled_:
|
|
if self.fl0w != None:
|
|
self.find_matches()
|
|
self.fl0w.subscribe(self, self.subscriptions)
|
|
else:
|
|
if self.fl0w != None:
|
|
self.fl0w.unsubscribe(self)
|
|
for sensor_type in ("analog", "digital"):
|
|
self.window.active_view().erase_phantoms(sensor_type)
|
|
|
|
|
|
@property
|
|
def matches(self):
|
|
return self._matches
|
|
|
|
@matches.setter
|
|
def matches(self, matches_):
|
|
if not matches_ == self.matches:
|
|
self._matches = matches_
|
|
self.fl0w.subscribe(self, self.subscriptions)
|
|
|
|
|
|
@property
|
|
def subscriptions(self):
|
|
subscriptions_ = {"analog" : [], "digital" : []}
|
|
for sensor_type in ("analog", "digital"):
|
|
subscriptions_[sensor_type] = [sensor[0] for sensor in self.matches[sensor_type]]
|
|
return subscriptions_
|
|
|
|
|
|
def find_matches(self):
|
|
matches = {"analog" : [], "digital" : []}
|
|
# Don't do any calculations on 1MB or larger files
|
|
if self.view.size() < 2**20:
|
|
for method_name in ("analog", "digital"):
|
|
candidates = self.view.find_all("%s\(\d*\)" % method_name)
|
|
for candidate in candidates:
|
|
line = self.view.substr(candidate)
|
|
port_candidates = re.findall(PARENTHESES_REGEX, line)
|
|
if len(port_candidates) == 1:
|
|
if port_candidates[0].isnumeric():
|
|
matches[method_name].append(
|
|
(
|
|
int(port_candidates[0]),
|
|
sublime.Region(self.view.line(candidate.a).b)
|
|
))
|
|
self.matches = matches
|
|
|
|
# Called by fl0w instance
|
|
def update_sensor_values(self, readouts):
|
|
for sensor_type in ("analog", "digital"):
|
|
self.view.erase_phantoms(sensor_type)
|
|
for match in self.matches[sensor_type]:
|
|
try:
|
|
self.view.add_phantom(sensor_type, match[1],
|
|
STYLE_OPEN + str(readouts[sensor_type][str(match[0])]) + STYLE_CLOSE,
|
|
sublime.LAYOUT_INLINE)
|
|
except KeyError:
|
|
self.view.add_phantom(sensor_type, match[1],
|
|
ERROR_OPEN + "!" + ERROR_CLOSE,
|
|
sublime.LAYOUT_INLINE)
|
|
|
|
|
|
def handle_timeout(self):
|
|
self.timeout_scheduled = False
|
|
if self.needs_update:
|
|
self.needs_update = False
|
|
self.find_matches()
|
|
|
|
|
|
def on_modified(self):
|
|
if self.enabled:
|
|
if self.timeout_scheduled:
|
|
self.needs_update = True
|
|
else:
|
|
sublime.set_timeout(lambda: self.handle_timeout(), 500)
|
|
self.find_matches()
|
|
|
|
|
|
def __del__(self):
|
|
self.enabled = False
|
|
if self in sensor_phantoms:
|
|
del sensor_phantoms[sensor_phantoms.index(self)]
|