Panos Parisis
Published © GPL3+

Medication Reminder

Is medication too complex a task for your loved ones? Lighten their burden with this talking assistant that also sends slack notifications!

IntermediateFull instructions provided3 hours412
Medication Reminder

Things used in this project

Hardware components

Raspberry Pi 3 Model B
Raspberry Pi 3 Model B
A Raspberry PI 3A+ could do the job without a problem, but couldn't find a suitable case for it. If having a case is not important to you, feel free to downgrade.
×1
JAM HAT (LED & Buzzer Board)
This HAT provided just the basics: 2 buttons, a buzzer and a 2 rows of "traffic-light" LEDs (2 x Red, 2 x Yellow, 2 x Green)
×1
Raspberry Pi JAM HAT Case
Optional - even a small cardboard box would do
×1
Mini External Speaker (Generic)
×1
MicroSD Card, Class 10, 16 GB
×1
Raspberry Pi 4 Model B PSU, USB-C, 5.1V, 3A
×1

Software apps and online services

Slack
Slack
Raspbian
Raspberry Pi Raspbian

Hand tools and fabrication machines

Phillips screwdriver (generic)
Used to attach the Jam Hat on your Raspberry Pi

Story

Read more

Schematics

Example of medication file

Code

Main App

Python
Single all-in-one source code file
'''
[]===========================[]
 | ** Medication Reminder ** |
 | for Raspberry PI & JamHat |
 | (c) Panos Parisis 2020-1  |
 |---------------------------|
 | For my loving wife        |
[]===========================[]
'''
import os
import csv
import pyttsx3
import getopt
import sys
import tty
import select
import termios
import csv
import signal
import _thread
import threading
import requests as req
import logging
import logging.config
import base64
from flask import Flask, json, request, jsonify, abort
from datetime import date, datetime, timedelta
from apscheduler.schedulers.background import BackgroundScheduler
from time import sleep


'''
=====================
constants
=====================
'''
LED_RED = 0
LED_ORANGE = 1
LED_GREEN = 2
MESSAGE_PREAMBLE = "Medication time..."
REMINDER_PREAMBLE = "Your attention please..."
MESSAGE_CONFIRM = "Press the blue button to dismiss, or the red button to postpone"
MESSAGE_NO_ANSWER = "Got no answer... Will try again in a few minutes..."
MESSAGE_POSTPONED = "{} postponed"
MESSAGE_DISMISSED = "{} dismissed"
SLACK_URL = "???"


'''
=====================
classes
=====================
'''


class CharBuffer():
    def __init__(self):
        self.ch = None

    @staticmethod
    def flush():
        termios.tcflush(sys.stdin, termios.TCIOFLUSH)

    def next_char(self, acceptable):
        if not self.ch is None:
            ch = self.ch
            self.ch = None
            return ch
        if select.select([sys.stdin], [], [], 0) == ([sys.stdin], [], []):
            ch = sys.stdin.read(1)
            termios.tcflush(sys.stdin, termios.TCIOFLUSH)
            if ch in acceptable:
                return ch
            self.ch = ch
        return None


class BuzzerFake():
    def play(self, note):
        pass


class LEDFake():
    def on(self):
        pass

    def off(self):
        pass


class ButtonFake():
    def __init__(self, chars, cb, name):
        self.chars = chars
        self.cb = cb
        self.name = name

    @property
    def is_pressed(self):
        ch = self.cb.next_char(self.chars)
        if ch is None:
            return False
        print("<<{} button pressed>>".format(self.name))
        return True

    def wait_for_release(self):
        pass


class JamHatFake():
    def __init__(self):
        self.buzzer = BuzzerFake()
        cb = CharBuffer()
        self.button_1 = ButtonFake(['r', 'R', '-'], cb, "RED")
        self.button_2 = ButtonFake(['b', 'B', '+'], cb, "BLUE")
        self.lights_1 = [LEDFake(), LEDFake(), LEDFake()]
        self.lights_2 = [LEDFake(), LEDFake(), LEDFake()]

    def close(self):
        pass

    def off(self):
        pass


class Medication():
    def __init__(self, message, start_at, stop_after, is_reminder=False):
        self.__is_reminder = is_reminder
        self.__start_at = self.__created_at = start_at
        self.__dismissed = False
        self.__stop_at = start_at + timedelta(minutes=stop_after)
        self.__notification_sent = False
        self.__message = message
        enc = str(self.__hash__()).encode('ascii')
        self.__identity = base64.b64encode(enc).decode('ascii')

    @staticmethod
    def convert(target, start_at, message, stop_after, reminder):
        try:
            dt = datetime.strptime(start_at, '%Y-%m-%d %H:%M')
        except (ValueError):
            tmp = datetime.strptime(start_at, '%H:%M')
            dt = datetime(target.year, target.month,
                          target.day, tmp.hour, tmp.minute)

        return Medication(message, dt, int(stop_after),
                          reminder.lower() in ['true', '1', 'yes'])

    @property
    def id(self):
        return self.__identity

    @property
    def message(self):
        return self.__message

    @property
    def is_pending(self):
        return self.is_active and datetime.now() >= self.__start_at

    @property
    def is_reminder(self):
        return self.__is_reminder

    def notify(self):
        if self.__notification_sent or SLACK_URL == "???":
            return

        log.debug(f'Notifying slack about [{self.message}]')
        try:
            r = req.post(SLACK_URL, json={
                         "text": "Medication time for " + self.message})
            if r.status_code == 200:
                self.__notification_sent = True
            else:
                log.error(f"POST to slack failed with status {r.status_code}")
        except Exception as ex:
            log.error(ex)

    def postpone(self, mins):
        delta = timedelta(minutes=mins)
        new_time = datetime.now().replace(
            second=0, microsecond=0)
        if new_time < self.__start_at:
            new_time = self.__start_at
        self.__start_at = new_time + delta
        self.__stop_at = self.__stop_at.replace(
            second=0, microsecond=0) + delta
        log.debug(f'[{self.__message}] rescheduled @ {self.__start_at}')

    def dismiss(self):
        self.__dismissed = True
        log.debug(f'[{self.__message}] dismissed')

    def toggle(self):
        self.__dismissed = not self.__dismissed
        log.debug(f'[{self.__message}] dismissed flag toggled')

    @property
    def for_today(self):
        return self.__start_at.date() == date.today()

    @property
    def is_active(self):
        return not self.__dismissed and datetime.now() < self.__stop_at

    @property
    def created_at(self):
        return self.__created_at

    def __str__(self):
        return "[{0}] @ {1:%d/%m/%Y %H:%M} flags={2}{3}{4}{5}{6}".format(
               self.message, self.__start_at,
               'R' if self.is_reminder else 'M',
               'A' if self.is_active else 'I',
               'P' if self.__start_at != self.__created_at else '_',
               'D' if self.__dismissed else '_',
               'S' if self.__notification_sent else '_')

    def __eq__(self, other):
        return (self.message == other.message) and (self.created_at == other.created_at)

    def __hash__(self):
        return hash((self.__message, self.__created_at))


class MedicationList():
    def __init__(self, input_file="/meds.csv"):
        self.__padlock = threading.Lock()
        self.__list = []
        self.__input_file = input_file

    def add_medication(self, new_med):
        with self.__padlock:
            if (new_med not in self.__list):
                self.__list.append(new_med)

    def item(self, id):
        with self.__padlock:
            for med in self.__list:
                if med.id == id:
                    return med
        return None

    @property
    def contents(self):
        tmp = {}
        with self.__padlock:
            for med in self.__list:
                tmp[med.id] = med.__str__()
        return tmp

    def remove_medication(self, instance):
        with self.__padlock:
            self.__list.remove(instance)
        log.debug(f"Medication deleted:\n{instance}")

    def get_filtered_contents(self, expr):
        with self.__padlock:
            return list(filter(expr, self.__list))

    def refresh(self, target=None):
        if target is None:
            target = date.today()

        with self.__padlock:
            # first generate the new entries, if missing
            # it's essential to check against the original list before adding a med
            with open(self.__input_file, mode='r') as file:
                rd = csv.reader(file)
                for start_at, message, stop_after, reminder in rd:
                    new_med = Medication.convert(
                        target, start_at, message, stop_after, reminder)
                    if (new_med not in self.__list):
                        self.__list.append(new_med)
            # remove expired entries
            expired = []
            for med in self.__list:
                if not med.is_active:
                    expired.append(med)
            for exp in expired:
                self.__list.remove(exp)

        log.debug(f"Current state:\n{self.contents}")


'''
=====================
globals
=====================
'''
log = None
previous_date = date.today()
test_mode = False
kbd_timeout = False
med_list = None
silent_mode = False
api = Flask(__name__)
try:
    from gpiozero import JamHat
    jh = JamHat()
except ImportError:
    jh = JamHatFake()


'''
=====================
functions
=====================
'''


def stop_waiting(signum, frame):
    global kbd_timeout

    kbd_timeout = True
    if test_mode:
        print("<<alarm>>")


def buzz(notes=None):
    global jh, test_mode

    if test_mode:
        print("<<Buzz>>")
    if silent_mode:
        return
    if (notes is None):
        notes = [440.000, 391.995, 349.228, 329.628, 293.665, 261.626]
    for note in notes:
        jh.buzzer.play(note)
        sleep(0.1)
    for note in reversed(notes):
        jh.buzzer.play(note)
        sleep(0.1)


def led_on(colour):
    global jh

    jh.lights_2[colour].on()
    jh.lights_1[colour].on()


def led_blink(colour, iteration):
    global jh

    if iteration % 2 == 0:
        jh.lights_2[colour].on()
        jh.lights_1[colour].off()
    else:
        jh.lights_2[colour].off()
        jh.lights_1[colour].on()


def leds_off():
    global jh

    jh.off()


def say(engine, text):
    global test_mode

    if test_mode:
        print("VOICE: ", text)
    if not silent_mode:
        engine.say(text)
        engine.runAndWait()
    sleep(0.3)


def detect_date_change():
    global med_list, previous_date

    target = date.today()
    if target == previous_date:
        return

    med_list.refresh(target)
    previous_date = target


def check(engine):
    global kbd_timeout, jh, test_mode, med_list

    found = med_list.get_filtered_contents(lambda x: x.is_pending)

    if (found):
        buzz()
        leds_off()
        led_on(LED_ORANGE)

        reminders = [x for x in found if x.is_reminder]
        medications = [x for x in found if not x.is_reminder]
        postpone_all_pending = False

        if reminders:
            say(engine, REMINDER_PREAMBLE)
            for rem in reminders:
                log.info(f"found reminder --> {rem.message}")
                say(engine, rem.message)
                rem.dismiss()

        if medications:
            say(engine, MESSAGE_PREAMBLE)
            for med in medications:
                log.info(f"found medication --> {med.message}")
                if postpone_all_pending:
                    med.postpone(5)
                    continue
                if test_mode:
                    CharBuffer.flush()
                say(engine, med.message)
                if not silent_mode:
                    med.notify()
                was_blue_pressed = jh.button_2.is_pressed
                was_red_pressed = jh.button_1.is_pressed
                say(engine, MESSAGE_CONFIRM)
                signal.alarm(60)
                i = 0
                while not was_blue_pressed and not was_red_pressed and not kbd_timeout:
                    was_blue_pressed = jh.button_2.is_pressed
                    was_red_pressed = jh.button_1.is_pressed
                    led_blink(LED_ORANGE, i)
                    i += 1
                    sleep(0.08)
                signal.alarm(0)
                if was_blue_pressed:
                    jh.lights_1[LED_GREEN].on()
                    med.dismiss()
                    say(engine, MESSAGE_DISMISSED.format(med.message))
                    jh.lights_1[LED_GREEN].off()
                elif was_red_pressed:
                    jh.lights_1[LED_RED].on()
                    med.postpone(30)
                    say(engine, MESSAGE_POSTPONED.format(med.message))
                    jh.lights_1[LED_RED].off()
                else:  # timeout
                    kbd_timeout = False
                    led_on(LED_ORANGE)
                    log.debug(f"No answer for [{med.message}]")
                    say(engine, MESSAGE_NO_ANSWER)
                    med.postpone(5)
                    postpone_all_pending = True

        if not postpone_all_pending:
            leds_off()
            led_on(LED_GREEN)


def banner():
    msg = r"""
     ___ __ . __     ___. __        __  ___    .     __  ___ __
|\/||__ |  \|/  ` /\  | |/  \|\ |  |__)|__ |\/|||\ ||  \|__ |__)
|  ||___|__/|\__,/~~\ | |\__/| \|  |  \|___|  ||| \||__/|___|  \
"""
    print(msg)


def usage():
    print('Syntax:')
    print(
        '   python3 meds.py [-s|--silent][-h|--help][-t|--test-mode][-d|--debug]')
    print('                   [-i|--input-file <input_file>]')
    print('                   [-l|--log-config <config_file>]')


def exit_to_OS():
    log.info("Exit to OS requested")
    _thread.interrupt_main()


'''
=====================
API
=====================
'''


@api.route('/meds/version', methods=['GET'])
def version():
    info = {"version": "1.5",
            "(c)": "Panos Parisis 2020-1",
            "License": "GPL3+"}
    return jsonify(info), 200


@api.route('/meds/refresh', methods=['POST'])
def refresh_medication_list():
    global med_list

    target_str = request.form.get('target')
    if target_str is None:
        target = date.today()
    else:
        target = datetime.strptime(target_str, '%Y-%m-%d')

    med_list.refresh(target)
    return jsonify(med_list.contents), 200


@api.route('/meds', methods=['GET', 'POST'])
def get_medication_list():
    global med_list

    if request.method == 'GET':
        return jsonify(med_list.contents), 200

    if request.method == 'POST':
        message = request.form.get('message')
        start_at = request.form.get('start_at')
        stop_after = request.form.get('stop_after')
        is_reminder = request.form.get('is_reminder')
        if (start_at is None or message is None):
            abort(400)

        new_med = Medication.convert(date.today(), start_at, message,
                                     stop_after or "30", is_reminder or "True")
        log.debug(f"Medication added: {new_med}")
        med_list.add_medication(new_med)
        return jsonify(new_med.__str__()), 200


@api.route('/meds/pending', methods=['GET'])
def get_pending_medication_list():
    global med_list

    tmp = [x.__str__()
           for x in med_list.get_filtered_contents(lambda x: x.is_pending)]
    return jsonify(tmp), 200


@api.route('/meds/<id>', methods=['GET', 'DELETE', 'PATCH'])
def get_one_med(id):
    global med_list

    med = med_list.item(id)
    if med is None:
        abort(404)

    if request.method == 'DELETE':
        med_list.remove_medication(med)
    elif request.method == 'PATCH':
        med.toggle()
    return jsonify(med.__str__()), 200


@api.route('/meds/postpone/<id>', methods=['POST'])
def postpone_meds(id):
    global med_list

    todo = []
    if id == 'all':
        todo = med_list.get_filtered_contents(
            lambda x: x.is_active and x.for_today)
    else:
        med = med_list.item(id)
        if med is None:
            abort(404)
        todo.append(med)

    minutes = int(request.form.get('minutes') or '15')
    tmp = []
    for med in todo:
        med.postpone(minutes)
        tmp.append(med.__str__())
    return jsonify(tmp), 200


@api.route('/meds/stop', methods=['GET', 'POST'])
def stop_app():
    if request.method == 'GET':
        prm = request.args.get('kind') or 'werkzeug'
    elif request.method == 'POST':
        prm = request.form.get('kind') or 'werkzeug'

    func = None
    if prm == "werkzeug":
        func = request.environ.get('werkzeug.server.shutdown')
        log.info("Exit to OS requested")
    if func is None:
        func = exit_to_OS
    func()
    return "Exiting to OS...\n\n"


@api.route('/shutdown', methods=['GET', 'POST'])
def shutdown():
    global jh

    if request.method == 'GET':
        prm = request.args.get('kind') or 'hard'
    elif request.method == 'POST':
        prm = request.form.get('kind') or 'hard'

    message = ""
    if prm.lower() in ['soft', 'reboot']:
        message = "Reboot requested"
        os.system('sudo reboot now')
    else:
        jh.close()
        message = "Shutdown requested"
        os.system('sudo shutdown now')
    log.info(message)
    return message + "\n\n"


'''
=====================
main program
=====================
'''
if __name__ == '__main__':
    banner()
    # parse command line parameters
    try:
        opts, args = getopt.getopt(sys.argv[1:], "hi:l:tds", [
            "help", "input-file=", "log-config=", "test-mode", "debug", "silent"])
    except getopt.GetoptError:
        usage()
        sys.exit(1)

    input_file = "/meds.csv"
    logging_config_file = None
    debug = False
    for opt, arg in opts:
        if opt in ("-t", "--test-mode"):
            test_mode = True
        elif opt in ("-d", "--debug"):
            debug = True
        elif opt in ("-s", "--silent"):
            silent_mode = True
        elif opt in ("-h", "--help"):
            usage()
            sys.exit(0)
        elif opt in ("-i", "--input-file"):
            input_file = arg
        elif opt in ("-l", "--log-config"):
            logging_config_file = arg
        else:
            assert False, "unhandled option: " + opt

    if logging_config_file is None:
        logging.basicConfig(format='%(asctime)s|%(name)s|%(levelname)s|%(message)s',
                            level=logging.DEBUG if test_mode or debug else logging.INFO)
    else:
        logging.config.fileConfig(logging_config_file)

    log = logging.getLogger('MedicationReminder')
    log.propagate = False
    log.info('Starting...')

    if isinstance(jh, JamHatFake) and not test_mode:
        log.critical('No JamHat found. Run app in test mode (-t)')
        raise SystemExit

    # initialise flask
    api.debug = False
    api.use_reloader = False

    # set up keyboard/jam input timeout
    signal.signal(signal.SIGALRM, stop_waiting)

    # build list of medications
    med_list = MedicationList(input_file)
    med_list.refresh()
    if not med_list.contents:    # empty list evaluates to false
        log.critical('Nothing to do')
        led_on(LED_RED)
        raise SystemExit
    else:
        led_on(LED_GREEN)

    # initialise text-to-Speech engine
    engine = pyttsx3.init()
    engine.setProperty('rate', 125)
    engine.setProperty('volume', 1)

    # initialise job scheduler
    scheduler = BackgroundScheduler()
    scheduler.add_job(func=check, trigger='interval', jitter=2,
                      seconds=60, args=[engine], id="main")
    scheduler.add_job(func=detect_date_change, trigger='interval',
                      jitter=3, seconds=90, id="new_day")

    # make stdin non-blocking
    if test_mode:
        old_settings = termios.tcgetattr(sys.stdin)
        new_settings = termios.tcgetattr(sys.stdin)
        new_settings[3] = new_settings[3] & ~(
            termios.ECHO | termios.ICANON)  # lflags
        new_settings[6][termios.VMIN] = 0  # cc
        new_settings[6][termios.VTIME] = 0  # cc
        termios.tcsetattr(sys.stdin, termios.TCSADRAIN, new_settings)

    # main program loop
    print('Press Ctrl+C to exit')
    try:
        scheduler.start()  # non-blocking
        api.run(host="0.0.0.0", port=6666)  # blocking
    except (SystemExit, KeyboardInterrupt):
        pass
    except Exception as ex:
        log.error(ex)
    finally:
        if test_mode:
            termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
        signal.alarm(0)
        scheduler.shutdown(wait=False)
        jh.close()
        log.info('End of Program.-')

Postman collection

JSON
It demonstrates how to use the administrative HTTP endpoint. For information on importing Postman collections, please see https://learning.postman.com/docs/getting-started/importing-and-exporting-data/
{
	"info": {
		"_postman_id": "cd579232-d1e7-4ba6-8143-4295e8a08b4e",
		"name": "Medication Reminder",
		"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
	},
	"item": [
		{
			"name": "get all medications",
			"request": {
				"method": "GET",
				"header": [],
				"url": {
					"raw": "http://{{addr}}:{{port}}/meds",
					"protocol": "http",
					"host": [
						"{{addr}}"
					],
					"port": "{{port}}",
					"path": [
						"meds"
					]
				}
			},
			"response": []
		},
		{
			"name": "change ack flag",
			"request": {
				"method": "PATCH",
				"header": [],
				"url": {
					"raw": "http://{{addr}}:{{port}}/meds/LTExMDYxOTA5NA==",
					"protocol": "http",
					"host": [
						"{{addr}}"
					],
					"port": "{{port}}",
					"path": [
						"meds",
						"LTExMDYxOTA5NA=="
					]
				}
			},
			"response": []
		},
		{
			"name": "pending",
			"request": {
				"method": "GET",
				"header": [],
				"url": {
					"raw": "http://{{addr}}:{{port}}/meds/pending",
					"protocol": "http",
					"host": [
						"{{addr}}"
					],
					"port": "{{port}}",
					"path": [
						"meds",
						"pending"
					]
				}
			},
			"response": []
		},
		{
			"name": "get version",
			"request": {
				"method": "GET",
				"header": [],
				"url": {
					"raw": "http://{{addr}}:{{port}}/meds/version",
					"protocol": "http",
					"host": [
						"{{addr}}"
					],
					"port": "{{port}}",
					"path": [
						"meds",
						"version"
					]
				}
			},
			"response": []
		},
		{
			"name": "shutdown",
			"request": {
				"method": "POST",
				"header": [],
				"body": {
					"mode": "formdata",
					"formdata": [
						{
							"key": "kind",
							"value": "soft",
							"type": "text"
						}
					]
				},
				"url": {
					"raw": "http://{{addr}}:{{port}}/shutdown",
					"protocol": "http",
					"host": [
						"{{addr}}"
					],
					"port": "{{port}}",
					"path": [
						"shutdown"
					]
				}
			},
			"response": []
		},
		{
			"name": "exit to OS",
			"request": {
				"method": "POST",
				"header": [],
				"url": {
					"raw": "http://{{addr}}:{{port}}/meds/stop",
					"protocol": "http",
					"host": [
						"{{addr}}"
					],
					"port": "{{port}}",
					"path": [
						"meds",
						"stop"
					]
				}
			},
			"response": []
		},
		{
			"name": "refresh",
			"request": {
				"method": "POST",
				"header": [],
				"body": {
					"mode": "formdata",
					"formdata": [
						{
							"key": "target",
							"value": "2021-01-31",
							"type": "text",
							"disabled": true
						}
					]
				},
				"url": {
					"raw": "http://{{addr}}:{{port}}/meds/refresh",
					"protocol": "http",
					"host": [
						"{{addr}}"
					],
					"port": "{{port}}",
					"path": [
						"meds",
						"refresh"
					]
				}
			},
			"response": []
		},
		{
			"name": "add reminder",
			"request": {
				"method": "POST",
				"header": [],
				"body": {
					"mode": "formdata",
					"formdata": [
						{
							"key": "name",
							"value": "Calcium and Magnesium",
							"type": "text"
						},
						{
							"key": "start_at",
							"value": "18:30",
							"type": "text"
						}
					]
				},
				"url": {
					"raw": "http://{{addr}}:{{port}}/meds",
					"protocol": "http",
					"host": [
						"{{addr}}"
					],
					"port": "{{port}}",
					"path": [
						"meds"
					]
				}
			},
			"response": []
		},
		{
			"name": "delete reminder",
			"request": {
				"method": "DELETE",
				"header": [],
				"url": {
					"raw": "http://{{addr}}:{{port}}/meds/LTExMDYxOTA5NA==",
					"protocol": "http",
					"host": [
						"{{addr}}"
					],
					"port": "{{port}}",
					"path": [
						"meds",
						"LTExMDYxOTA5NA=="
					]
				}
			},
			"response": []
		},
		{
			"name": "get single reminder",
			"request": {
				"method": "GET",
				"header": [],
				"url": {
					"raw": "http://{{addr}}:{{port}}/meds/MTM0NjM2MjM5NQ==",
					"protocol": "http",
					"host": [
						"{{addr}}"
					],
					"port": "{{port}}",
					"path": [
						"meds",
						"MTM0NjM2MjM5NQ=="
					]
				}
			},
			"response": []
		},
		{
			"name": "postpone one or all reminders",
			"request": {
				"method": "POST",
				"header": [],
				"body": {
					"mode": "formdata",
					"formdata": [
						{
							"key": "minutes",
							"value": "5",
							"type": "text"
						}
					]
				},
				"url": {
					"raw": "http://{{addr}}:{{port}}/meds/postpone/MTM0NjM2MjM5NQ==",
					"protocol": "http",
					"host": [
						"{{addr}}"
					],
					"port": "{{port}}",
					"path": [
						"meds",
						"postpone",
						"MTM0NjM2MjM5NQ=="
					]
				}
			},
			"response": []
		}
	],
	"event": [
		{
			"listen": "prerequest",
			"script": {
				"type": "text/javascript",
				"exec": [
					""
				]
			}
		},
		{
			"listen": "test",
			"script": {
				"type": "text/javascript",
				"exec": [
					""
				]
			}
		}
	],
	"variable": [
		{
			"key": "addr",
			"value": "127.0.0.1"
		},
		{
			"key": "port",
			"value": "6666"
		}
	]
}

Credits

Panos Parisis
1 project • 0 followers
I have been developing software professionally since 1987.

Comments