Commit d759f464 authored by Grégor JOUET's avatar Grégor JOUET 🔧
Browse files

Added casing projet & octoprint, added queues routes

parent f72a665a
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -40,6 +40,8 @@ Fablab 3d printing request application

# Target Architecture

Right now DFPM is not to be implemented

```
                 +-------------------------------------------------------------------------------------------------------------------------+
                 |                                                                                    |---------------------------------+  |
+15 −2
Original line number Diff line number Diff line
version: '2'

services:
  app:
    image: dvic.devinci.fr/server/myfab:latest
    ports:
      - 7414:80
    environment:
      - MYSQL_HOST=db
      - MYSQL_DB=db
      - MYSQL_USER=user
      - MYSQL_PASSWORD=password
    links:
      - db
  db:
    image: "mysql"
    environment:
@@ -8,5 +19,7 @@ services:
      MYSQL_DATABASE: db
      MYSQL_USER: user
      MYSQL_PASSWORD: password
    ports:
      - "3306:3306"
 No newline at end of file

  # rtsp_streamer:
  #   image: aler9/rtsp-simple-server
  #   network: host
+165 −25
Original line number Diff line number Diff line
@@ -16,6 +16,8 @@ from myfab.model import (
    init_db_connection,
    Queue,
    QueueElement,
    PrinterQueue,
    Message,
)
from flask_api import status
from flask import jsonify
@@ -23,6 +25,14 @@ import string
import random
import os
from flask_cors import CORS
from myfab.queue import (
    get_printers_from_db,
    get_printers,
    get_printer_by_id,
    get_printers_for_queue,
    get_queue_by_id,
    delete_printer
)

init_db_connection()
log.info("Database connected")
@@ -162,7 +172,6 @@ def loop_requests(reqs):

def get_access_level_to_request(req: PrintRequest, user):
    # req is id or req same for user
    # TODO
    if req is not PrintRequest:
        req = get_request_by_id(req)
        if req is None:
@@ -372,21 +381,71 @@ def update_user(username):

# region Message system routes


@app.route("/message/<request_id>")
@jwt_required
def get_messages(request_id):
    pass
    messages = []
    req = get_request_by_id(request_id)
    if req == None:
        return jsonify({"error": "No such request"}), status.HTTP_404_NOT_FOUND
    if get_access_level_to_request(req, current_user) < REQUEST_ACCESS_OBSERVER:
        return jsonify({"error": "Access Denied"}), status.HTTP_401_UNAUTHORIZED
    for msg in (
        Message.select()
        .where(Message.request == req)
        .order_by(Message.id)
        .desc()
        .limit(30)
    ):
        messages.append(
            {
                "req": msg.request.id,
                "author": msg.author.fullname,
                "text": msg,
                "creation": msg.creation,
                "id": msg.id,
                "read": msg.read,
            }
        )
        if msg.author != current_user:
            msg.read = True
            msg.save()
    return jsonify(messages)


@app.route("/message/<request_id>", methods=["POST"])
@jwt_required
def post_message(request_id):
    pass
    data = request.get_json()
    req = get_request_by_id(request_id)
    if not req:
        return jsonify({"error": "No such request"}), status.HTTP_404_NOT_FOUND
    Message.create(request=req, text=data["message"], author=current_user)
    return jsonify({})


# @app.route("/message/unread")
# @jwt_required
# def unread_messages():
#     messages = []
#     for msg in Message.select().where(
#         Message.request.author == current_user and Message.read == False
#     ):
#         messages.append(
#             {
#                 "req": msg.request.id,
#                 "author": msg.author.fullname,
#                 "text": msg,
#                 "creation": msg.creation,
#                 "id": msg.id,
#             }
#         )


# endregion

# region printer management routes TODO
# region printer management routes

# list printers & status
@app.route("/printers")
@@ -407,44 +466,98 @@ def list_printers():
        }
    ]
    """
    pass

    printers = []
    for printer in get_printers():
        printers.append({
            "id": printer.id,
            "name": printer.name,
            "current_print_request": printer.current_print.id,
            "version": printer.version(),
            "printer_type": printer.printer_type(),
            "status": printer.status(),
            "printer_progress": printer.print_progress(),
            "infos": printer.infos(),
            "remaining_print_time": printer.remaining_print_time()
        })
    return jsonify(printers)

# action on printer TODO v2
@app.route("/printers/<printer_id>/action", methods=["POST"])
@app.route("/printers/<printer_id>/action/<action>", methods=["GET"])
@jwt_required
def action_printer(printer_id):
    request.get_json()
def action_printer(printer_id, action):
    """
    {
        "action": "pause", "resume", "cancel", "cycle", "disable", "enable", "restart"
    }
    pause resume cacel are self explainatory
    cycle: goes to the next file in queue, implies no print job is running. Display a warn message first
    disable: put printer out of commission
    enable: !disable
    """
    pass


# Edit printer config
# Edit printer config exceot queues
@app.route("/printers/<printer_id>", methods=["PUT"])
@jwt_required
def update_printer(printer_id):
    pass
    printer = get_printer_by_id(printer_id)
    if not printer:
        return jsonify({'error': 'No such printer'}), status.HTTP_404_NOT_FOUND
    printer_update = request.get_json()
    for elem in printer_update:
        setattr(printer.printer_obj, elem, printer_update[elem])
    printer.printer_obj.save()
    return jsonify({})
    

@app.route("/printers/<printer_id>/queue/<qid>", methods=["PUT", "DELETE"])
@jwt_required
def printer_queue(printer_id, qid): 
    printer = get_printer_by_id(printer_id)
    if not printer:
        return jsonify({'error': 'No such priner'}), status.HTTP_404_NOT_FOUND
    q = get_queue_by_id(qid)
    if not q:
        return jsonify({'error': 'No such queue'}), status.HTTP_404_NOT_FOUND
    if request.method == "PUT":
        for q in printer.queues:
            if q.id == qid:
                return jsonify({'error': 'Printer already attached to this queue'}), status.HTTP_409_CONFLICT
        PrinterQueue.create(printer=printer, queue=q)
        return jsonify({})
    else:
        for q in printer.queues:
            if q.id == qid:
                PrinterQueue.delete().where(PrinterQueue.printer=printer and PrinterQueue.queue=q)
                return jsonify({})        
        return jsonify({'error': 'Printer is not attched to this queue'}), status.HTTP_404_NOT_FOUND

# Add printer config
@app.route("/printers/new", methods=["POST"])
@app.route("/printers", methods=["POST"])
@jwt_required
def add_new_printer():
    pass
    printer_info = request.get_json()
    Printer.create(name=printer_info['name'],
                   model=printer_info['model'],
                   config=printer_info['config'])
    return jsonify({})

@app.route("/printers/<printer_id>", methods=["DELETE"])
@jwt_required
def remove_printer(printer_id):
    #TODO check access level
    delete_printer(printer_id)
    return jsonify({})

# endregion printer management

# region queues management TODO
# region queues management routes

# list queues
@app.route("/queues")
@app.route("/queues", methods=["GET", "POST"])
@jwt_required
def list_queues():
def manage_queues():
    """
    [
        {
@@ -452,19 +565,46 @@ def list_queues():
            "name": "queue_name",
            "weight": 0, queue weight,
            "printers": [ids ],*
            "enabled": true,
            "process_span": when to process the queue (type unknown) ?? FIXME
            "enabled": true
        }
    ]
    """
    pass


# create queue

# delete queue
    if request.method == "GET":
        # get queues    
        qs = []
        for q in get_queues():
            qs.append({
                "id": q.id,
                "name": q.name,
                "weight": q.weight,
                "printers": map(getprinters_for_queue(q), lambda p: {'id': p.id, 'name': p.name})
                "enabled": true
            })
        return jsonify({'printers': qs})
    elif request.method == "POST":
        # create new queue
        queue_info = request.get_json()
        Queue.create(enabled = True, 
                     name=queue_info['name'],
                     weight=queue_info['weight'],
                     meta=queue_info['meta'])
        return jsonify({})
    else:
        return jsonify({'error': 'Method not allowed'}), status.HTTP_405_METHOD_NOT_ALLOWED

# update queue
@app.route("/queues/<qid>", methods=["DELETE", "PUT"])
@jwt_required
def manage_queues_with_id(qid):
    if request.method == "DELETE":
        # :)
        remove_queue(qid)
        return jsonify({})
    elif request.method == "PUT":
        # update queue info
        Queue.update(**request.get_json()).where(Queue.id==quid)
        return jsonify({})
    else:
        return jsonify({'error': 'Method not allowed'}), status.HTTP_405_METHOD_NOT_ALLOWED


# endregion queues
+55 −30
Original line number Diff line number Diff line
from peewee import Model, MySQLDatabase, AutoField, IntegerField, CharField, TextField, ForeignKeyField, DateTimeField, DoubleField, BooleanField
from peewee import (
    Model,
    MySQLDatabase,
    AutoField,
    IntegerField,
    CharField,
    TextField,
    ForeignKeyField,
    DateTimeField,
    DoubleField,
    BooleanField,
)
import datetime
import sys, os
import myfab.log as log

CONNECTION = None


def create_mysql_connection() -> MySQLDatabase:
    global CONNECTION

    assert CONNECTION == None
    if not 'MYSQL_HOST' in os.environ:
        os.environ['MYSQL_HOST'] = "127.0.0.1"
        os.environ['MYSQL_DB'] = 'db'
        os.environ['MYSQL_USER'] = 'user'
        os.environ['MYSQL_PASSWORD'] = 'password'
        log.warn('No MYSQL_HOST, using default values')

    url = os.environ['MYSQL_HOST']
    if not "MYSQL_HOST" in os.environ:
        os.environ["MYSQL_HOST"] = "127.0.0.1"
        os.environ["MYSQL_DB"] = "db"
        os.environ["MYSQL_USER"] = "user"
        os.environ["MYSQL_PASSWORD"] = "password"
        log.warn("No MYSQL_HOST, using default values")

    url = os.environ["MYSQL_HOST"]
    log.info("Using MYSQL_HOST %s" % url)
    return  MySQLDatabase(os.environ['MYSQL_DB'], 
                            user=os.environ['MYSQL_USER'], 
                            password=os.environ['MYSQL_PASSWORD'],
                            port=3306)
    return MySQLDatabase(
        os.environ["MYSQL_DB"],
        user=os.environ["MYSQL_USER"],
        host = url,
        password=os.environ["MYSQL_PASSWORD"],
        port=3306,
    )


def init_db_connection():
@@ -37,11 +52,14 @@ class User(Model):
    fullname = CharField()
    access = IntegerField(default=0)


class Queue(Model):
    id = AutoField(primary_key=True)
    enabled = BooleanField()
    name = CharField()
    weight = IntegerField(default=1)
    meta = TextField() # json


class QueueElement(Model):
    id = AutoField()
@@ -49,12 +67,15 @@ class QueueElement(Model):
    queue = ForeignKeyField(Queue)
    time_added = DateTimeField(default=datetime.datetime.now())


class PrintRequest(Model):
    id = AutoField(primary_key=True)
    title = CharField()
    description = TextField()
    creation = DateTimeField(default=datetime.datetime.now(), column_name='creation')
    last_modification = DateTimeField(default=datetime.datetime.now(), column_name='last_modification')
    creation = DateTimeField(default=datetime.datetime.now(), column_name="creation")
    last_modification = DateTimeField(
        default=datetime.datetime.now(), column_name="last_modification"
    )
    author = ForeignKeyField(User)
    reject_message = CharField()
    stl = TextField(null=True, default=None)
@@ -62,15 +83,20 @@ class PrintRequest(Model):
    status = IntegerField(default=0)
    project = TextField()
    operator = ForeignKeyField(User, null=True, default=None)
    queue_element = ForeignKeyField(QueueElement, null=True, default=None) # Only the queue info, the actual queue is not store in db
    queue_element = ForeignKeyField(
        QueueElement, null=True, default=None
    )  # Only the queue info, the actual queue is not store in db
    recup_id = CharField(default="")


class Message(Model):
    id = AutoField(primary_key=True)
    author = ForeignKeyField(User, null=False)
    text = TextField()
    creation = DateTimeField(default=datetime.datetime.now(), column_name='creation')
    creation = DateTimeField(default=datetime.datetime.now(), column_name="creation")
    request = ForeignKeyField(PrintRequest)
    read = BooleanField(default=False)


class Event(Model):  # to form timeline
    request = ForeignKeyField(PrintRequest)
@@ -78,6 +104,7 @@ class Event(Model): # to form timeline
    date = DateTimeField(default=datetime.datetime.now())
    operartor = ForeignKeyField(User, null=True, default=None)


class Printer(Model):  # with connection information
    id = AutoField()
    name = CharField()
@@ -85,14 +112,12 @@ class Printer(Model): # with connection information
    config = CharField()  # json
    current_print = ForeignKeyField(PrintRequest, null=True)


class PrinterQueue(Model):
    printer = ForeignKeyField(Printer)
    queue = ForeignKeyField(Queue)





# V2, Slicer vm info ? maybe not even necessary
# class Slicer:
#     pass
+321 −146
Original line number Diff line number Diff line
from myfab.log import info, warn, error
from myfab.model import PrintRequest, QueueElement, Queue, Printer, PrinterQueue
from myfab.model import PrinterQueue, PrintRequest, QueueElement, Queue, Printer, PrinterQueue
import requests as r
import json
import myfab.log as log
@@ -9,8 +9,35 @@ QUEUE_ELEMENT_STATE_INQUEUE = 0
QUEUE_ELEMENT_STATE_DONE = 1
QUEUE_ELEMENT_STATE_REMOVED = 2

PRINTERS = None

# region QUEUE UTILS


"""
    Get queues in the DB
"""
def get_queues():
    return Queue.select().get()

def get_printers_for_queue(q: Queue):
    printers = []
    for pq in PrinterQueue.select().where(PrinterQueue.queue == q):
        printers.append(pq.printer)

    return printers
"""
    Remove queue
    TODO: what append to the items in the queue ?
"""
def remove_queue(qid):
    if not get_queue_by_id(qid):
        return
    Queue.delete().where(Queue.id == qid)

def create_queue(name, weight, meta):
    Queue.create(name=name, weight=weight, meta=meta)

"""
  Add request to the end of the queue
"""
@@ -19,59 +46,141 @@ def enqueue(req : PrintRequest, queue):
    req.queue_element = elem.id
    return elem


"""
  Peek the immediate next queue item
"""
def get_next_request(queue):
    try:
    return QueueElement.select().where(QueueElement.state == QUEUE_ELEMENT_STATE_INQUEUE and QueueElement.queue == queue).order_by(QueueElement.id).limit(1).get()
        return (
            QueueElement.select()
            .where(
                QueueElement.state == QUEUE_ELEMENT_STATE_INQUEUE
                and QueueElement.queue == queue
            )
            .order_by(QueueElement.id)
            .limit(1)
            .get()
        )
    except peewee.DoesNotExist:
        return None


def rm_queue_top(queue, reason=QUEUE_ELEMENT_STATE_REMOVED):
    e: QueueElement = get_next_request(queue)
    if e != None:
        e.state = reason
        e.save()


"""
  Number of elements before queue_element.
  0 means queue_element is the current top of the queue and next dequeued object
"""


def get_queue_element_pos(queue_element: QueueElement):
  return QueueElement.select().where(QueueElement.queue == queue_element.queue and QueueElement.state == QUEUE_ELEMENT_STATE_INQUEUE, QueueElement.id < queue_element.id).count()
    return (
        QueueElement.select()
        .where(
            QueueElement.queue == queue_element.queue
            and QueueElement.state == QUEUE_ELEMENT_STATE_INQUEUE,
            QueueElement.id < queue_element.id,
        )
        .count()
    )


def get_queue_by_id(qid):
    r = Queue.select().where(Queue.id == qid)
    if not r.count():
        return None
    return r.get()


# endregion

# region PRINTER UTILS & class


def get_printers_from_db():
    printers = []
    for p in Printer.select():
        printers.append(build_printer(p))
    return printers


"""
    Get printer 
"""
def build_printer(p: Printer):
    if p.model == "fake":
        # fake printer
      printers.append(FakePrinter(p))
        return FakePrinter(p)
    elif p.model == "octoprint":
        # octo printer
      printers.append(OctoPrinter(p))
        return OctoPrinter(p)
    else:
        raise Exception("Unknown printer model")


class PrinterBase:
def get_printer_by_id(printer_id):
    for p in get_printers():
        if p.id == printer_id:
            return p
    return None


def pull_printers():
    # Called once to fetch printers from db
    global PRINTERS
    PRINTERS = get_printers_from_db()


def get_printers():
    global PRINTERS
    if not PRINTERS:
        pull_printers()

    return PRINTERS


def delete_printer(printer_id):
    global PRINTERS
    PRINTERS = list(filter(lambda printer: printer.id() != printer_id, PRINTERS))


class PrinterBase:
    def __init__(self, printer_obj):
    self.printer_obj = printer_obj
    self.id = printer_obj.id
    self.name = printer_obj.name
    self.current_print : PrintRequest = None
        self.printer_obj: Printer = printer_obj
        # self.id = printer_obj.id
        # self.name = printer_obj.name
        self.queues = []
        self.load_queues()

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

    @property
    def name(self):
        return self.printer_obj.name

    @property
    def current_print(self):
        return self.printer_obj.current_print

    def load_queues(self):
        self.queues = []
        for q in PrinterQueue.select().where(PrinterQueue.printer == Printer.id):
            self.queues.append(q)

    def remove_queue(self, qid):
        pass  # TODO

    def add_queue(self, qid):
        pass  # TODO

    def set_print(self, pr: PrintRequest):
        raise NotImplementedError()

@@ -82,32 +191,44 @@ class PrinterBase:
        raise NotImplementedError()

    def printer_model(self):
    raise NotImplementedError()
        return self.printer_obj.model

    def refresh(self):
        raise NotImplementedError()

    def status(self):
        raise NotImplementedError()


# Printer not linked to Octoprint but can represent a printer
class FakePrinter(PrinterBase):

    def __init__(self, printer_obj):
        super().__init__(printer_obj)

    def refresh(self):
        pass

    def status(self):
        return "printing" if self.current_print != None else "idle"

    def infos(self):
        return {}

    def print_progress(self):
        return -1

    def remaining_print_time(self):
        return -1

    def printer_type(self):
        return "fake"

  def printer_model(self):
    return "N/A"

    def version(self):
        return "N/A"


# Printer with Octoprint backend
class OctoPrinter(PrinterBase):

    def __init__(self, printer_obj):
        super().__init__(printer_obj)
        config = printer_obj.config
@@ -118,7 +239,7 @@ class OctoPrinter(PrinterBase):
       'key': the api-key
     }
    """
    self._version = 'Unknown'
        self._version = "Unknown"
        self.hostname = config.hostname
        self.port = config.port
        self.key = config.key
@@ -126,10 +247,7 @@ class OctoPrinter(PrinterBase):
        self.test_and_pull_printer_global_info()
        self.load_queues()
        self.printer_state = {
      'temperatures': {
        'bed': float('nan'),
        'tip': float('nan')
      },
            "temperatures": {"bed": float("nan"), "tip": float("nan")},
            "job": None,
            "state": {
                "text": "Unknown",
@@ -142,24 +260,44 @@ class OctoPrinter(PrinterBase):
                    "sdReady": False,
                    "error": False,
                    "ready": False,
          "closedOrError": False
        }
      }
                    "closedOrError": False,
                },
            },
        }
        self.reset_current_print()

  def set_print(self, print_obj: PrintRequest):
    if(not self.ready_for_printing):
      raise Exception('Printer is not ready')
    def start_print(self, print_obj: PrintRequest):
        self._refresh_printer_status()
        self._refresh_ready_state()
        if not self.ready_state:
            raise Exception("Printer is not ready")

        with open(os.path.join(print_obj.gcode)) as fh: #! FIXME path
            gcode = fh.read()
        
        self.printer_obj.current_print = print_obj
    self.current_print = None
        self.printer_obj.save()
        self._refresh_ready_state()

        log.info(f'Printer #{self.id} ({self.name}): Starting print #{self.current_print.id} ({self.current_print.title})')

        return self._make_octoprint_raw_request("/api/files/sdcard", method="post", data=None, files={'file': gcode, 'select': 'true', 'print': 'true'})
        

    """
    Can accept new print (or move safely at all)
    """
    @property
    def ready_state(self):
        return self.ready_for_printing

    def _refresh_ready_state(self):
    self.ready_for_printing = self.printer_state['state']['flags']['ready'] and self.current_print == None
        self.ready_for_printing = (
            self.printer_state["state"]["flags"]["ready"] and self.printer_state["state"]["flags"]["operational"] and self.current_print == None
        )

    """
    Object was removed from printing bed, printer is ready to operate again
    Object was removed from printing bed, printer is ready to operate again (unless octoprint says otherwise)
    """
    def reset_current_print(self):
        self.current_print = None
@@ -168,36 +306,72 @@ class OctoPrinter(PrinterBase):
        self._refresh_printer_status()
        self._refresh_ready_state()

  def _make_octoprint_raw_request(self, path, method = 'get', data = None):
    return json.loads(r.request(method, f'http://{self.hostname}:{self.port}{path}', headers = {'X-Api-Key': self.key}, payload = data).text)
    def _make_octoprint_raw_request(self, path, method="get", data=None, files=None):
        return json.loads(
            r.request(
                method,
                f"http://{self.hostname}:{self.port}{path}",
                headers={"X-Api-Key": self.key},
                payload=data,
                files=None
            ).text
        )

    """
    Home 3 axis
    """
    def goto_home(self):
        if not self.ready_state:
            raise Exception("Printer is not ready")
        self._make_octoprint_raw_request("/api/printer/printhead", method="post", data=json.dumps({
            "axes": ["x", "y", "z"], "command": "home"
        }))

    def _refresh_printer_status(self):
    log.info(f'Printer #{self.id} ({self.name}) @ {self.hostname}: refreshing status')
    status = self._make_octoprint_raw_request('/api/printer')
        log.info(
            f'Printer #{self.id} ({self.name}) @ {self.hostname}: refreshing status'
        )
        status = self._make_octoprint_raw_request("/api/printer")
        self.printer_state = {
            "temperatures": {
        "bed": status['temperature']['bed']['actual'],
        "tip": status['temperature']['tool0']['actual']
                "bed": status["temperature"]["bed"]["actual"],
                "tip": status["temperature"]["tool0"]["actual"],
            },
      "state": status["state"]
            "state": status["state"],
        }
    if(not status['state']['flags']['printing']):
      self.printer_state['job'] = None
        #TODO: Event feed
        if not status["state"]["flags"]["printing"]:
            self.printer_state["job"] = None
            return

    job = self._make_octoprint_raw_request('/api/job')
    self.printer_state['job'] = {
      "printTime": job['progress']['printTime'],
      "printTimeLeft": job['progress']['printTimeLeft'],
      "progress": job['progress']['completion'],
      "print_length": job['filament']['length']
        job = self._make_octoprint_raw_request("/api/job")
        self.printer_state["job"] = {
            "printTime": job["progress"]["printTime"],
            "printTimeLeft": job["progress"]["printTimeLeft"],
            "progress": job["progress"]["completion"],
            "print_length": job["filament"]["length"],
        }

    def test_and_pull_printer_global_info(self):
    versions = self._make_octoprint_raw_request('/api/version')
    log.info(f'Printer #{self.id} ({self.name}) @ {self.hostname}: connected to octoprint')
        versions = self._make_octoprint_raw_request("/api/version")
        log.info(
            f"Printer #{self.id} ({self.name}) @ {self.hostname}: connected to octoprint"
        )
        log.info(f'Version info: {versions["text"]} API v{versions["text"]})')
    self._version = versions['text']
        self._version = versions["text"]

    def print_progress(self):
        if not self.printer_state["job"]:
            return -1
        return self.printer_state["job"]["progress"]

    def remaining_print_time(self):
        if not self.printer_state["job"]:
            return -1
        return self.printer_state["job"]["printTimeLeft"]

    def infos(self):
        return self.printer_state

    def version(self):
        return self._version
@@ -205,8 +379,9 @@ class OctoPrinter(PrinterBase):
    def printer_type(self):
        return "Octoprint"

  def printer_model(self):
    return "wallah jsp" # FIXME xptdr
    def status(self):
        return self.printer_state["state"]["text"]


# endregion

Loading