From 0e7b2e49585e3725736ae380cbd2dfb28fea5093 Mon Sep 17 00:00:00 2001 From: Paul Oliver Date: Thu, 29 Feb 2024 02:29:14 +0100 Subject: Added 'Common.py' module. [#27] Not exactly a 'tappable pipe', this simple communications module allows for IPC between individual Salis organisms and different simulations via UDP sockets. --- .gitignore | 2 + bin/README.md | 9 ++- bin/modules/common.py | 174 +++++++++++++++++++++++++++++++++++++++++++++++++ bin/modules/handler.py | 97 +++++++++++++++++++++++++-- bin/modules/printer.py | 138 +++++++++++++++++++++++++++++++++------ bin/network/.keep | 0 bin/salis.py | 31 +++++++-- 7 files changed, 417 insertions(+), 34 deletions(-) create mode 100644 bin/modules/common.py create mode 100644 bin/network/.keep diff --git a/.gitignore b/.gitignore index 015126d..077c454 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ +TODO.md bin/__pycache__/* bin/modules/__pycache__/* +bin/network/*.json bin/error.log bin/lib/libsalis-*.so bin/sims/*.sim diff --git a/bin/README.md b/bin/README.md index 635f233..c3ff7d9 100644 --- a/bin/README.md +++ b/bin/README.md @@ -53,8 +53,15 @@ control some aspects of the simulation. |`s \| scroll` |value |--- |Scroll to Nth process or memory address | |`p \| process` |id |--- |Select process by ID | |`r \| rename` |name |--- |Give simulation a new name | -|`s \| save` |--- |--- |Save simulation | +|`save` |--- |--- |Save simulation | |`a \| auto` |interval |--- |Set simulation's auto-save interval | +|`l \| link` |port |--- |Add localhost as common source and target | +|`source` |address |port |Add a common source | +|`target` |address |port |Add a common target | +|`rem_source` |address |port |Remove an existing common source | +|`rem_target` |address |port |Remove an existing common target | +|`net_load` |file |--- |Load network settings from file | +|`net_save` |file |--- |Save network settings to a file | ### Color Legend In WORLD view, as well as in PROCESS view (when gene mode is selected), each diff --git a/bin/modules/common.py b/bin/modules/common.py new file mode 100644 index 0000000..25163d1 --- /dev/null +++ b/bin/modules/common.py @@ -0,0 +1,174 @@ +""" SALIS: Viewer/controller for the SALIS simulator. + +File: common.py +Author: Paul Oliver +Email: paul.t.oliver.design@gmail.com + +Network communications module for Salis simulator. This module allows for IPC +between individual Salis organisms and different simulations via UDP sockets. +""" + +import json +import os +import socket + +from ctypes import c_int, c_uint8, CFUNCTYPE + + +class Common: + SENDER_TYPE = CFUNCTYPE(c_int, c_uint8) + RECEIVER_TYPE = CFUNCTYPE(c_uint8) + + def __init__(self, sim, max_buffer_size=4096): + """ Initialize module with a default buffer size of 4 KiB. + """ + self.__sim = sim + self.__settings_path = self.__get_settings_path() + self.max_buffer_size = max_buffer_size + self.in_buffer = bytearray() + self.out_buffer = bytearray() + self.sources = [] + self.targets = [] + + # Use a global client socket for all output operations. + self.__client = self.__get_socket() + + def define_functors(self): + """ Define the C callbacks which we'll pass to the Salis simulator. + These simply push and pop instructions from the input and output + buffers whenever organisms call the SEND and RCVE instructions. + """ + def sender(inst): + if len(self.out_buffer) < self.max_buffer_size: + self.out_buffer.append(inst) + + def receiver(): + if len(self.in_buffer): + res = self.in_buffer[0] + self.in_buffer = self.in_buffer[1:] + return c_uint8(res) + else: + return c_uint8(0) + + self.__sender = self.SENDER_TYPE(sender) + self.__receiver = self.RECEIVER_TYPE(receiver) + self.__sim.lib.sal_comm_set_sender(self.__sender) + self.__sim.lib.sal_comm_set_receiver(self.__receiver) + + def add_source(self, address, port): + """ Create new input socket. + """ + sock = self.__get_server(address, port) + self.sources.append(sock) + + def add_target(self, address, port): + """ Create new output address/port tuple. We use global output socket + ('self.__client') for output operations. + """ + self.targets.append((address, port)) + + def remove_source(self, address, port): + """ Remove an input socket. + """ + source = (address, port) + self.sources = [s for s in self.sources if s.getsockname() != source] + + def remove_target(self, address, port): + """ Remove an output address/port pair. + """ + target = (address, port) + self.targets = [t for t in self.targets if t != target] + + def link_to_self(self, port): + """ Create input and output links to 'localhost'. + """ + self.add_source(socket.gethostbyname(socket.gethostname()), port) + self.add_target(socket.gethostbyname(socket.gethostname()), port) + + def cycle(self): + """ We push all data on the output buffer to all targets and clear it. + We withdraw incoming data from all source sockets and append it to the + input buffer. + """ + if len(self.out_buffer) and self.targets: + for target in self.targets: + self.__client.sendto(self.out_buffer, target) + + # Clear output buffer. + self.out_buffer = bytearray() + + # Receive data and store on input buffer. + if len(self.in_buffer) < self.max_buffer_size: + for source in self.sources: + try: + self.in_buffer += source.recv( + self.max_buffer_size - len(self.in_buffer) + ) + except socket.error: + pass + + def load_network_config(self, filename): + """ Load network configuration from a JSON file. + """ + with open(os.path.join(self.__settings_path, filename), "r") as f: + in_dict = json.load(f) + + self.max_buffer_size = in_dict["max_buffer_size"] + + for source in in_dict["sources"]: + self.add_source(*source) + + for target in in_dict["targets"]: + self.add_target(*target) + + for inst in in_dict["in_buffer"]: + self.in_buffer.append(self.__sim.handler.inst_dict[inst]) + + for inst in in_dict["out_buffer"]: + self.out_buffer.append(self.__sim.handler.inst_dict[inst]) + + def save_network_config(self, filename): + """ Save network configuration to a JSON file. + """ + out_dict = { + "max_buffer_size": self.max_buffer_size, + "in_buffer": "", + "out_buffer": "", + "sources": [s.getsockname() for s in self.sources], + "targets": self.targets, + } + + for byte in self.in_buffer: + out_dict["in_buffer"] += self.__sim.printer.inst_list[byte][1] + + for byte in self.out_buffer: + out_dict["out_buffer"] += self.__sim.printer.inst_list[byte][1] + + with open(os.path.join(self.__settings_path, filename), "w") as f: + json.dump(out_dict, f, indent="\t") + + + ############################### + # Private methods + ############################### + + def __get_settings_path(self): + """ Get path to network settings directory. + """ + self_path = os.path.dirname(__file__) + return os.path.join(self_path, "../network") + + def __get_socket(self): + """ Generate a non-blocking UDP socket. + """ + sock = socket.socket( + socket.AF_INET, socket.SOCK_DGRAM | socket.SOCK_NONBLOCK + ) + return sock + + def __get_server(self, address, port): + """ Generate a socket and bind to an address/port pair. + """ + serv_socket = self.__get_socket() + serv_socket.bind((address, port)) + return serv_socket diff --git a/bin/modules/handler.py b/bin/modules/handler.py index 3325391..ba2b249 100644 --- a/bin/modules/handler.py +++ b/bin/modules/handler.py @@ -35,13 +35,13 @@ class Handler: """ self.__sim = sim self.__printer = sim.printer - self.__inst_dict = self.__get_inst_dict() self.__min_commands = [ ord("M"), ord(" "), curses.KEY_RESIZE, self.KEY_ESCAPE, ] + self.inst_dict = self.__get_inst_dict() self.console_history = [] # Set short delay for ESCAPE key (which is used to exit the simulator). @@ -82,9 +82,11 @@ class Handler: elif cmd == ord("a"): self.__printer.world.pan_left() self.__printer.proc_scroll_left() + self.__printer.comm_scroll_left() elif cmd == ord("d"): self.__printer.world.pan_right() self.__printer.proc_scroll_right() + self.__printer.comm_scroll_right() elif cmd == ord("s"): self.__printer.world.pan_down() self.__printer.proc_scroll_down() @@ -103,6 +105,7 @@ class Handler: elif cmd == ord("A"): self.__printer.world.pan_reset() self.__printer.proc_scroll_horizontal_reset() + self.__printer.comm_scroll_horizontal_reset() elif cmd == ord("o"): self.__printer.proc_select_prev() elif cmd == ord("p"): @@ -166,6 +169,20 @@ class Handler: self.__on_save(command) elif command[0] in ["a", "auto"]: self.__on_set_autosave(command) + elif command[0] in ["l", "link"]: + self.__on_link_to_self(command) + elif command[0] in ["source"]: + self.__on_add_source(command) + elif command[0] in ["target"]: + self.__on_add_target(command) + elif command[0] in ["rem_source"]: + self.__on_remove_source(command) + elif command[0] in ["rem_target"]: + self.__on_remove_target(command) + elif command[0] in ["net_load"]: + self.__on_network_load(command) + elif command[0] in ["net_save"]: + self.__on_network_save(command) else: # Raise if a non-existing command has been given. self.__raise("Invalid command: '{}'".format(command[0])) @@ -203,8 +220,7 @@ class Handler: time_max = time.time() + self.CYCLE_TIMEOUT for _ in range(factor): - self.__sim.lib.sal_main_cycle() - self.__sim.check_autosave() + self.__sim.cycle() if time.time() > time_max: break @@ -255,7 +271,7 @@ class Handler: for symbol in genome: self.__sim.lib.sal_mem_set_inst( - address, self.__inst_dict[symbol] + address, self.inst_dict[symbol] ) address += 1 @@ -269,7 +285,7 @@ class Handler: # All characters in file must be actual instruction symbols. for character in command[1]: - if character not in self.__inst_dict: + if character not in self.inst_dict: self.__raise("Invalid symbol '{}' found on stream".format( character )) @@ -298,7 +314,7 @@ class Handler: # All characters in file must be actual instruction symbols. for character in genome: - if character not in self.__inst_dict: + if character not in self.inst_dict: self.__raise("Invalid symbol '{}' found on '{}'".format( character, gen_file )) @@ -364,6 +380,8 @@ class Handler: # output = {} exec(" ".join(command[1:]), locals(), output) + self.__sim.printer.screen.clear() + self.__sim.printer.print_page() if output: self.__respond("EXEC RESPONDS: {}".format(str(output))) @@ -433,3 +451,70 @@ class Handler: self.__raise("Invalid parameters for '{}'".format(command[0])) self.__sim.set_autosave(int(command[1], 0)) + + def __on_link_to_self(self, command): + """ Add self as network target and source. + """ + if len(command) != 2: + self.__raise("Invalid parameters for '{}'".format(command[0])) + + port = int(command[1]) + self.__sim.common.link_to_self(int(command[1])) + + def __on_add_source(self, command): + """ Add new network source. + """ + if len(command) != 3: + self.__raise("Invalid parameters for '{}'".format(command[0])) + + address = command[1] + port = int(command[2]) + self.__sim.common.add_source(address, port) + + def __on_add_target(self, command): + """ Add new network target. + """ + if len(command) != 3: + self.__raise("Invalid parameters for '{}'".format(command[0])) + + address = command[1] + port = int(command[2]) + self.__sim.common.add_target(address, port) + + def __on_remove_source(self, command): + """ Remove existing network source. + """ + if len(command) != 3: + self.__raise("Invalid parameters for '{}'".format(command[0])) + + address = command[1] + port = int(command[2]) + self.__sim.common.remove_source(address, port) + + def __on_remove_target(self, command): + """ Remove existing network target. + """ + if len(command) != 3: + self.__raise("Invalid parameters for '{}'".format(command[0])) + + address = command[1] + port = int(command[2]) + self.__sim.common.remove_target(address, port) + + def __on_network_load(self, command): + """ Load network settings from JSON file (located on network settings + directory. + """ + if len(command) != 2: + self.__raise("Invalid parameters for '{}'".format(command[0])) + + self.__sim.common.load_network_config(command[1]) + + def __on_network_save(self, command): + """ Save network settings to a JSON file (which will be placed on the + network settings directory). + """ + if len(command) != 2: + self.__raise("Invalid parameters for '{}'".format(command[0])) + + self.__sim.common.save_network_config(command[1]) diff --git a/bin/modules/printer.py b/bin/modules/printer.py index 51a5b33..cfbca84 100644 --- a/bin/modules/printer.py +++ b/bin/modules/printer.py @@ -12,11 +12,9 @@ format. It makes use of the curses library for terminal handling. import curses import curses.textpad import os -import time from collections import OrderedDict -from ctypes import c_uint8, c_uint32, cast, POINTER -from modules.handler import Handler +from ctypes import c_uint32, cast, POINTER from modules.world import World @@ -44,6 +42,7 @@ class Printer: self.__proc_element_scroll = 0 self.__proc_gene_scroll = 0 self.__proc_gene_view = False + self.__common_buffer_scroll = 0 self.__curs_y = 0 self.__curs_x = 0 self.__print_hex = False @@ -143,6 +142,19 @@ class Printer: max_scroll, self.__proc_element_scroll ) + def comm_scroll_left(self): + """ Scroll buffers (on COMMON view) to the left. + """ + if self.current_page == "COMMON": + self.__common_buffer_scroll -= 1 + self.__common_buffer_scroll = max(0, self.__common_buffer_scroll) + + def comm_scroll_right(self): + """ Scroll buffers (on COMMON view) to the right. + """ + if self.current_page == "COMMON": + self.__common_buffer_scroll += 1 + def proc_scroll_down(self, fast=False): """ Scroll process data table (on PROCESS view) up. """ @@ -195,6 +207,12 @@ class Printer: else: self.__proc_element_scroll = 0 + def comm_scroll_horizontal_reset(self): + """ Scroll common in and out buffers back to the left. + """ + if self.current_page == "COMMON": + self.__common_buffer_scroll = 0 + def proc_select_prev(self): """ Select previous process. """ @@ -411,6 +429,8 @@ class Printer: self.world.render() elif self.current_page == "PROCESS": self.__print_proc_list() + elif self.current_page == "COMMON": + self.__print_common_data() ############################### @@ -552,6 +572,11 @@ class Printer: ("e", "last", self.__sim.lib.sal_proc_get_last), ("e", "selected", lambda: self.selected_proc), ]), + ("COMMON", [ + ("e", "in", lambda: len(self.__sim.common.in_buffer)), + ("e", "out", lambda: len(self.__sim.common.out_buffer)), + ("e", "max", lambda: self.__sim.common.max_buffer_size), + ]), ("WORLD", [ ("e", "position", lambda: self.world.pos), ("e", "zoom", lambda: self.world.zoom), @@ -627,6 +652,15 @@ class Printer: self.screen.move(ypos, 0) self.screen.clrtoeol() + def __data_format(self, x): + """ Print all proc IDs and elements in decimal or hexadecimal format, + depending on hex-flag being set. + """ + if self.__print_hex: + return hex(x) + else: + return x + def __print_proc_data_list(self): """ Print list of process data elements in PROCESS page. We can toggle between printing the data elements or the genomes by pressing the 'g' @@ -644,13 +678,6 @@ class Printer: ypos += 1 proc_id = self.proc_list_scroll - # Print all proc IDs and elements in decimal or hexadecimal format, - # depending on hex-flag being set. - if self.__print_hex: - data_format = lambda x: hex(x) - else: - data_format = lambda x: x - # Lastly, iterate all lines and print as much process data as it fits. # We can scroll the process data table using the 'wasd' keys. while ypos < self.size[0]: @@ -671,8 +698,8 @@ class Printer: ) # Lastly, assemble and print the next table row. - row = " | ".join(["{:<10}".format(data_format(proc_id))] + [ - "{:>10}".format(data_format(element)) + row = " | ".join(["{:<10}".format(self.__data_format(proc_id))] + [ + "{:>10}".format(self.__data_format(element)) for element in proc_data[self.__proc_element_scroll:] ]) self.__print_line(ypos, row, attr) @@ -759,19 +786,12 @@ class Printer: between printing the genomes or the data elements by pressing the 'g' key. """ - # Print all proc IDs and gene scroll in decimal or hexadecimal format, - # depending on hex-flag being set. - if self.__print_hex: - data_format = lambda x: hex(x) - else: - data_format = lambda x: x - # First, print the table header. We print the current gene-scroll # position for easy reference. Return back to zero scroll with the 'A' # key. ypos = len(self.__main) + len(self.__pages["PROCESS"]) + 5 header = "{:<10} | genes {} -->".format( - "pidx", data_format(self.__proc_gene_scroll) + "pidx", self.__data_format(self.__proc_gene_scroll) ) self.__clear_line(ypos) self.__print_header(ypos, header) @@ -791,13 +811,89 @@ class Printer: attr = curses.A_NORMAL # Assemble and print the next table row. - row = "{:<10} |".format(data_format(proc_id)) + row = "{:<10} |".format(self.__data_format(proc_id)) self.__print_line(ypos, row, attr) self.__print_proc_gene(ypos, proc_id) proc_id += 1 ypos += 1 + def __print_buffer(self, ypos, buff): + """ Print contents of a network buffer as a list of instruction + symbols. + """ + if not ypos < self.size[0]: + return + + if not len(buff): + self.__print_line(ypos, "---") + return + + xpos = 1 + bpos = self.__common_buffer_scroll + self.__clear_line(ypos) + + while xpos < self.size[1] - 1 and bpos < len(buff): + symbol = self.inst_list[int(buff[bpos])][1] + self.screen.addstr(ypos, xpos, symbol) + xpos += 1 + bpos += 1 + + def __print_common_widget( + self, ypos_s, ypos_b, head_s, head_b, sockets, buff, sources=False + ): + """ Print data pertaining input or output network buffers, sources and + targets. + """ + self.__print_header(ypos_s, head_s) + + # Socket info is stored differently for input and output. + if sources: + fmt_sock = lambda s: "{} {}".format(*s.getsockname()) + else: + fmt_sock = lambda s: "{} {}".format(*s) + + # Print active socket list. + if sockets: + for socket in sockets: + ypos_s += 1 + self.__print_line(ypos_s, fmt_sock(socket)) + else: + self.__print_line(ypos_s + 1, "---") + + # Print current contents of the network buffer. + self.__clear_line(ypos_b) + self.__print_header(ypos_b, "{:<10} | {} -->".format( + head_b, self.__data_format(self.__common_buffer_scroll) + )) + self.__clear_line(ypos_b + 1) + self.__print_buffer(ypos_b + 1, buff) + + def __print_common_data(self): + """ Print active socket list and network buffer data. + """ + ypos_src = len(self.__main) + len(self.__pages["COMMON"]) + 5 + ypos_tgt = ypos_src + max(3, len(self.__sim.common.sources) + 2) + ypos_ibf = ypos_tgt + max(3, len(self.__sim.common.targets) + 2) + ypos_obf = ypos_ibf + 3 + self.__print_common_widget( + ypos_src, + ypos_ibf, + "SOURCES", + "IN BUFFER", + self.__sim.common.sources, + self.__sim.common.in_buffer, + sources=True, + ) + self.__print_common_widget( + ypos_tgt, + ypos_obf, + "TARGETS", + "OUT BUFFER", + self.__sim.common.targets, + self.__sim.common.out_buffer, + ) + def __print_proc_list(self): """ Print list of process genomes or process data elements in PROCESS page. We can toggle between printing the genomes or the data elements diff --git a/bin/network/.keep b/bin/network/.keep new file mode 100644 index 0000000..e69de29 diff --git a/bin/salis.py b/bin/salis.py index 5e69c40..f51484c 100755 --- a/bin/salis.py +++ b/bin/salis.py @@ -20,10 +20,10 @@ import os import re import sys import time -import traceback from argparse import ArgumentParser, HelpFormatter from ctypes import CDLL, c_bool, c_uint8, c_uint32, c_char_p, POINTER +from modules.common import Common from modules.handler import Handler from modules.printer import Printer from subprocess import check_call @@ -49,6 +49,7 @@ class Salis: self.__exit = False self.save_file_path = self.__get_save_file_path() self.lib = self.__parse_lib() + self.common = Common(self) self.printer = Printer(self) self.handler = Handler(self) self.minimal = self.args.minimal @@ -66,6 +67,15 @@ class Salis: elif self.args.action == "load": self.lib.sal_main_load(self.save_file_path.encode("utf-8")) + # Configure Common module. Pass C callbacks to Salis and load settings + # for this simulator (if they exist). + self.common.define_functors() + + try: + self.common.load_network_config(self.args.file + ".json") + except FileNotFoundError: + pass + def __del__(self): """ Salis destructor. """ @@ -85,6 +95,15 @@ class Salis: ): os.remove(self.__log) + def cycle(self): + """ Perform all cycle operations. These include cycling the actual + Salis simulator, checking for autosave intervals and cycling the Common + module. + """ + self.common.cycle() + self.lib.sal_main_cycle() + self.check_autosave() + def run(self): """ Runs main simulation loop. Curses may be placed on non-blocking mode, which allows simulation to run freely while still listening to @@ -100,8 +119,7 @@ class Salis: end = time.time() + 0.015 while time.time() < end: - self.lib.sal_main_cycle() - self.check_autosave() + self.cycle() def toggle_state(self): """ Toggle between 'paused' and 'running' states. On 'running' curses @@ -148,8 +166,9 @@ class Salis: check_call(["gzip", auto_path]) def exit(self): - """ Signal we want to exit the simulator. + """ Save network settings and signal we want to exit the simulator. """ + self.common.save_network_config(self.args.file + ".json") self.__exit = True @@ -334,8 +353,8 @@ class Salis: "uint32_p": POINTER(c_uint32), "string": c_char_p, "Process": None, - "Sender": None, - "Receiver": None, + "Sender": Common.SENDER_TYPE, + "Receiver": Common.RECEIVER_TYPE, } # Finally, set correct arguments and return types of all Salis -- cgit v1.2.1