Skip to content

Commit

Permalink
Config hot reload (#20)
Browse files Browse the repository at this point in the history
  • Loading branch information
axltxl authored Dec 5, 2022
1 parent 7904a43 commit 2fdc829
Show file tree
Hide file tree
Showing 16 changed files with 382 additions and 149 deletions.
3 changes: 3 additions & 0 deletions TODO
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
* config.reload
* log: colorama
* lint everything
* README
* Examples README

Expand Down
9 changes: 9 additions & 0 deletions examples/arturia/minilab_mk2/wt_cj4_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,12 @@ def on_ap_master_change(v):
ap_on = bool(math.floor(v.value))


def config_reload(m):
"""Hot configuration reload"""

config.reload()


# Proceed to start up the engines
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
config.setup(
Expand Down Expand Up @@ -397,5 +403,8 @@ def on_ap_master_change(v):
(AMK2_PAD_09, ap_lnav_toggle), # LNAV
(AMK2_PAD_10, ap_vnav_toggle), # VNAV
(AMK2_PAD_11, ap_appr_toggle), # Approach mode
# Reload config
# ----------------------
(midi.NOTE_048, config_reload),
],
)
34 changes: 3 additions & 31 deletions m2fs/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import os
import sys
import traceback
import importlib.util
import signal
import time

Expand All @@ -13,7 +12,6 @@
PKG_NAME,
PKG_VERSION,
)
from .logger import Logger
from .logger import (
LOG_LVL_VERBOSE,
LOG_LVL_DEBUG,
Expand All @@ -30,7 +28,6 @@
connect as simc_connect,
disconnect as simc_disconnect,
get_variable as simc_get_variable,
poll_start as simc_poll_start,
poll_stop as simc_poll_stop,
)
from .midi import (
Expand All @@ -39,8 +36,9 @@
message_pump_start as midi_message_pump_start,
bootstrap as midi_bootstrap,
)
from .config import load as config_load
from .log import log

log = Logger(prefix=">> ")

CLI_DEFAULT_CONFIG_DIR = os.getcwd()
CLI_DEFAULT_CONFIG_FILE = os.path.join(CLI_DEFAULT_CONFIG_DIR, "config.py")
Expand Down Expand Up @@ -224,30 +222,6 @@ def __cmd_ls() -> None:
__log_ports(ports["output"])


def __load_mod_from_file(config_file: str):
"""
Append configuration file directory to sys.path
This will make it possible to import a configuration file
set by the user
"""

config_file_abs_path = os.path.expanduser(os.path.realpath(config_file))
module_name = "usr_config"

log.debug(f"Attempting to load config module at: {config_file_abs_path}")

spec = importlib.util.spec_from_file_location(module_name, config_file_abs_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
log.info(
f"{module_name}@{config_file_abs_path}: config module successfully loaded!"
)

return module


def event_loop(*, config_file: str) -> None:
"""
Main event loop
Expand All @@ -259,9 +233,7 @@ def event_loop(*, config_file: str) -> None:

try:
midi_bootstrap() # Start the MIDI engine
config = __load_mod_from_file(config_file) # Get config as a module
simc_poll_start() # Start polling for simc changes ... (does not block)
midi_message_pump_start() # Start rolling those MIDI messages
config_load(config_file) # Load the config file

# block until exception
while True:
Expand Down
83 changes: 83 additions & 0 deletions m2fs/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,27 @@
Configuration file entrypoint: setup()
"""

import os
import sys
import importlib
import importlib.util

from .midi import (
subscribe_to_cc as midi_subscribe_to_cc,
subscribe_to_note as midi_subscribe_to_note,
subscribe_to_pitchwheel as midi_subscribe_to_pitchwheel,
connect_input_port as midi_connect_input_port,
reset as midi_reset,
message_pump_start as midi_message_pump_start,
)
from .simc import (
connect as simc_connect,
subscribe_to_simvar as simc_subscribe_to_simvar,
poll_start as simc_poll_start,
reset as simc_reset,
)
from .simc import SIMCONNECT_BACKEND_DEFAULT
from .log import log


def ___batch_assign_cc_handlers(h_map: list[tuple[int, callable]]):
Expand All @@ -34,6 +44,76 @@ def ___batch_subscribe_to_simvars(h_map: list[tuple[str, callable]]):
simc_subscribe_to_simvar(simvar, handler=handler)


__config_module = None # configuration as a python module
__config_module_abs_path = "" # absolute path to configuration file

CONFIG_MODULE_SPEC_NAME = "m2fs.usr_config"


def load(config_file: str) -> None:
"""
Append configuration file directory to sys.path
This will make it possible to import a configuration file
set by the user
"""

global __config_module, __config_module_abs_path

log.debug(f"Attempting to load config module at: {config_file}")

# Make sure the config module is not a sys.modules already
try:
del sys.modules[CONFIG_MODULE_SPEC_NAME]
except KeyError:
pass

__config_module_abs_path = os.path.expanduser(os.path.realpath(config_file))

# specify the module that needs to be
# imported relative to the path of the
# module
spec = importlib.util.spec_from_file_location(
CONFIG_MODULE_SPEC_NAME, __config_module_abs_path
)

# creates a new module based on spec
__config_module = importlib.util.module_from_spec(spec)

# Add module to sys.modules
sys.modules[CONFIG_MODULE_SPEC_NAME] = __config_module

# executes the module in its own namespace
# when a module is imported or reloaded.
spec.loader.exec_module(__config_module)

log.info(f"{config_file}: config module successfully loaded!")


def reload() -> None:
"""
Reload configuration module
This will make sure a hot configuration can be done.
It'll make sure all internal state has been set to defaults
(any housekeeping, etc.), and then it proceeds to reload
the config module.
"""

global __config_module_abs_path

log.warn("!!! CONFIG RELOAD !!!")

# Reset MIDI engine
midi_reset()

# Reset SimConnect client and data
simc_reset()

# Load the configuration file (again)
load(config_file=__config_module_abs_path)


def setup(
*,
simconnect_backend: int = SIMCONNECT_BACKEND_DEFAULT,
Expand Down Expand Up @@ -82,3 +162,6 @@ def setup(
# SimVar subscriptions
if simconnect_var_subs is not None:
___batch_subscribe_to_simvars(simconnect_var_subs)

simc_poll_start() # Start polling for simc changes ... (does not block)
midi_message_pump_start() # Start rolling those MIDI messages
8 changes: 8 additions & 0 deletions m2fs/log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
"""
log object
"""

from .logger import Logger

log = Logger(prefix=">> ")
2 changes: 1 addition & 1 deletion m2fs/midi/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-

from .midi import message_pump_start, cleanup, bootstrap
from .midi import message_pump_start, cleanup, bootstrap, reset

from .port import (
list_available_ports,
Expand Down
14 changes: 12 additions & 2 deletions m2fs/midi/cc.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
# -*- coding: utf-8 -*-

import threading

from .log import log
from .message import IdMessage
from .message import TYPE_CC

# List of CC handlers (functions)
__cc_message_handlers = {}

# CC handlers mutex for parallel access
__cc_message_handlers_mutex = threading.Lock()

# List of all MIDI CCs
CC_000 = 0x00
Expand Down Expand Up @@ -159,7 +163,10 @@ def __str__(self) -> str:
def get_handler(*, cc):
"""Get a handler for a particular CC"""

return __cc_message_handlers[cc]
global __cc_message_handlers, __cc_message_handlers_mutex

with __cc_message_handlers_mutex:
return __cc_message_handlers[cc]


def bootstrap() -> None:
Expand All @@ -173,6 +180,8 @@ def bootstrap() -> None:
def subscribe(*, cc: int, handler):
"""Map a handler to changes done on a CC"""

global __cc_message_handlers, __cc_message_handlers_mutex

log.debug(f"CC: subscribing handler [CC#{cc}] -> {handler.__name__}")

# Decorator pattern is used mostly
Expand All @@ -181,7 +190,8 @@ def wrapper(msg):
log.debug(msg)
handler(msg)

__cc_message_handlers[cc] = wrapper
with __cc_message_handlers_mutex:
__cc_message_handlers[cc] = wrapper


def __null_cc_message_handler(msg: ControlChangeMessage):
Expand Down
52 changes: 46 additions & 6 deletions m2fs/midi/midi.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@
import os
import traceback
import threading
import time

import mido

# Make sure we're using rtmidi backend
import mido.backends.rtmidi

# There are message pump threads created per each MIDI input
# port specified on config. Each one of those
# polling for input indefinitely at the frequency
# set here
MIDI_IN_THREAD_SLEEP_TIME = 1.0 / 66 # 66 Hz

from .log import log
from .port import cleanup as port_cleanup
Expand Down Expand Up @@ -48,6 +54,13 @@ def bootstrap():
pw_bootstrap()


def reset() -> None:
"""Reset MIDI engine to defaults"""

cleanup() # do housekeeping
bootstrap() # bootstrap the MIDI engine again


def __get_midi_message(msg) -> BaseMessage:
"""
Create a Message-based object from a mido MIDI message.
Expand Down Expand Up @@ -119,19 +132,47 @@ def cleanup() -> None:
"""Take care of business"""

port_cleanup()
message_pump_stop()


def __in_port_message_pump(in_port: mido.ports.BaseInput):
"""Input port message pump"""

for msg in in_port:
__handle_msg(msg)
# Source: https://mido.readthedocs.io/en/latest/ports.html
# > This will iterate over messages as they arrive on the port until the port closes.
# > (So far only socket ports actually close by themselves. This happens if the other end disconnects.)
# for msg in in_port:
# __handle_msg(msg)

# > This will iterate over all messages that have already arrived.
# > It is typically used in main loops where you want to do something
# > else while you wait for messages:
while not in_port.closed:
for msg in in_port.iter_pending():
__handle_msg(msg)
time.sleep(
MIDI_IN_THREAD_SLEEP_TIME
) # so, it does not eat CPU time unnecessarily


def message_pump_stop() -> None:
"""Stop all MIDI input port message pump threads"""

global __in_port_msg_pump_threads
for t in __in_port_msg_pump_threads:
log.debug(f"stopping message pump thread: {t.getName()}")
try:
t.join()
except RuntimeError:
pass
__in_port_msg_pump_threads = []
log.debug("stopped all message pump threads")


def message_pump_start() -> None:
"""Main MIDI event message pump"""

global __in_port_msg_pump_threads, __message_pump_quit
global __in_port_msg_pump_threads

for port in get_all_input_ports():

Expand All @@ -141,9 +182,8 @@ def message_pump_start() -> None:
# (set to daemon, so it doesn't need to be explicitely dealt with
# when exiting)
# NOTE: consider a ThreadPoolExecutor in the future
msg_pump_thread = threading.Thread(
target=__in_port_message_pump, args=(port,), daemon=True
)
msg_pump_thread = threading.Thread(target=__in_port_message_pump, args=(port,))
msg_pump_thread.setName(name=f"midi:in_port:{port.name}")
msg_pump_thread.start()

__in_port_msg_pump_threads.append(msg_pump_thread)
Loading

0 comments on commit 2fdc829

Please sign in to comment.