Commit c51b00ee authored by zhangyibo's avatar zhangyibo

[0.9][zyb][第一版]

parent 142071c5
# pengli_iot
鹏力视觉系统数据采集到mysql
\ No newline at end of file
鹏力视觉系统数据采集到mysql
## 目录结构
main:主程序及配置文件
test:模拟机台的tcp服务器
## 安装
运行环境为python3,需要有pymysql包
### ubuntu下安装python3
```bash
sudo apt install python3 python3-pip
```
### centOS下安装python3
```bash
sudo yum install python38
```
### 安装pymysql包
```bash
pip3 install pymysql
```
## 配置
```JSON
{
"logLevel":"debug",//日志等级debug,info,warning,error
"dataBase":{
"host":"192.168.40.2",//数据库地址
"port":3306,//数据库端口号
"username":"usrname",//数据库用户名
"password":"password",//密码
"Database":"pengli_iot"//数据库
},
"machineList":[//机台列表
{
"host":"192.168.40.151",//机台IP
"port":7777,//机台端口号
"name":"machine1"//机台名称
},
{
"host":"127.0.0.1",
"port":8001,
"name":"machine1"
}
]
}
```
## 运行
```bash
/usr/bin/python3 main.py
```
\ No newline at end of file
import pymysql
import logging
import time
import datetime
class MySQLDB:
"""
与mysql交互
:param host:数据库服务器地址
:param port:数据库服务器端口
:param user:数据库用户名
:param password:密码
:param database:数据库名称
"""
def __init__(self,host,port,user,password,database) -> None:
self.host = host
self.port = port
self.user = user
self.password = password
self.database = database
self.stopFlag = False
self.logger = logging.getLogger("MysqlDAO")
self.connect() # 初始化时自动连接
def connect(self):
"""创建MySQL连接"""
while not self.stopFlag:
try:
self.db = pymysql.connect(host=self.host,port=self.port,user=self.user,password=self.password, database=self.database,connect_timeout=10)
self.logger.info("数据库连接成功")
break
except Exception as e:
self.logger.error(f"数据库连接失败{e}")
time.sleep(1)
def close(self):
"""断开MySQL连接"""
self.db.close()
self.stopFlag = True
def checkConnection(self):
"""检查MySQL连接状态,若没有连接则自动重连"""
if self.db is None or not self.db.open:
self.logger.error("数据库连接已断开")
self.connect()
def saveTotalOutPut(self, TotalOutPutList):
"""
保存总产量信息
:param TotalOutPutList:总产量信息列表
"""
self.checkConnection()
timenow = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
insert_total_output_sql = """
INSERT INTO iotmachine_total_output (all_num, ng_num, ok_num, parent_equip_no, product_name, product_no, shift_num, statistics_date,create_time,update_time,is_delete)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
query_total_output_sql = """
SELECT *
FROM iotmachine_total_output
WHERE parent_equip_no=%s AND product_no=%s AND shift_num=%s AND statistics_date = %s
"""
update_total_output_sql = """
UPDATE iotmachine_total_output
SET all_num=%s, ng_num=%s, ok_num=%s, update_time=%s
WHERE parent_equip_no=%s AND product_no=%s AND shift_num=%s AND statistics_date = %s
"""
try:
cursor = self.db.cursor()
# self.db.begin()
for TotalOutput in TotalOutPutList :
# 准备SQL参数
values = (
TotalOutput['ParentEquipNo'],
TotalOutput['ProductNo'],
TotalOutput['ShiftNum'],
TotalOutput['StatisticsDate']
)
recoedNum = cursor.execute(query_total_output_sql,values) # 先查询是否有该记录
self.logger.debug(recoedNum)
if recoedNum == 0:
self.logger.info("新增总产量记录")
self.logger.debug(TotalOutput)
values = (
TotalOutput['AllNum'],
TotalOutput['NGNum'],
TotalOutput['OKNum'],
TotalOutput['ParentEquipNo'],
TotalOutput['ProductName'],
TotalOutput['ProductNo'],
TotalOutput['ShiftNum'],
TotalOutput['StatisticsDate'],
timenow,
timenow,
0
)
cursor.execute(insert_total_output_sql, values)
else:
self.logger.info("更新总产量记录")
self.logger.debug(TotalOutput)
values = (
TotalOutput['AllNum'],
TotalOutput['NGNum'],
TotalOutput['OKNum'],
timenow,
TotalOutput['ParentEquipNo'],
TotalOutput['ProductNo'],
TotalOutput['ShiftNum'],
TotalOutput['StatisticsDate']
)
cursor.execute(update_total_output_sql, values)
self.db.commit()
cursor.close()
self.logger.info("总产量记录已更新")
except pymysql.MySQLError as e:
self.logger.error(f"总产量记录保存失败{e}")
# self.db.rollback()
cursor.close()
except Exception as e:
self.logger.error(f"总产量记录保存失败{e}")
cursor.close()
self.db.close()
def saveNGDetails(self, NGDetailsList):
"""
保存ng明细信息
:param NGDetailsList:ng明细信息列表
"""
self.checkConnection()
timenow = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
insert_ng_details_sql = """
INSERT INTO iotmachine_ng_details (record_id, equip_no, ng_class_name, ng_class_no, num, parent_equip_no, product_name, product_no, shift_num, statistics_date,create_time,update_time,is_delete)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
query_total_output_sql = """
SELECT id
FROM iotmachine_total_output
WHERE parent_equip_no=%s AND product_no=%s AND shift_num=%s AND statistics_date = %s
"""
query_ng_details_sql = """
SELECT *
FROM iotmachine_ng_details
WHERE parent_equip_no=%s AND product_no=%s AND shift_num=%s AND statistics_date = %s AND ng_class_no = %s
"""
update_ng_details_sql = """
UPDATE iotmachine_ng_details
SET num=%s, update_time=%s
WHERE parent_equip_no=%s AND product_no=%s AND shift_num=%s AND statistics_date = %s AND ng_class_no = %s
"""
try:
cursor = self.db.cursor()
# self.db.begin()
for NGDetails in NGDetailsList :
# 准备SQL参数
values = (
NGDetails['ParentEquipNo'],
NGDetails['ProductNo'],
NGDetails['ShiftNum'],
NGDetails['StatisticsDate']
)
recoedNum = cursor.execute(query_total_output_sql,values) # 先查询主表(totaloutput)中对应的id
if recoedNum == 0:
self.logger.error("NG明细没有在主表中找到记录")
self.logger.error(values)
record_id = 0
else:
record_id = cursor.fetchone()[0]
values = (
NGDetails['ParentEquipNo'],
NGDetails['ProductNo'],
NGDetails['ShiftNum'],
NGDetails['StatisticsDate'],
NGDetails['NGClassNo'],
)
recoedNum = cursor.execute(query_ng_details_sql,values) # 查询是否有该记录
self.logger.debug(f"查找到记录数:{recoedNum}")
if recoedNum == 0:
self.logger.info("新增NG明细记录")
self.logger.debug(NGDetails)
values = (
record_id,
NGDetails['EquipNo'],
NGDetails['NGClassName'],
NGDetails['NGClassNo'],
NGDetails['Num'],
NGDetails['ParentEquipNo'],
NGDetails['ProductName'],
NGDetails['ProductNo'],
NGDetails['ShiftNum'],
NGDetails['StatisticsDate'],
timenow,
timenow,
0
)
cursor.execute(insert_ng_details_sql, values)
else:
self.logger.info("更新NG明细记录")
self.logger.debug(NGDetails)
values = (
NGDetails['Num'],
timenow,
NGDetails['ParentEquipNo'],
NGDetails['ProductNo'],
NGDetails['ShiftNum'],
NGDetails['StatisticsDate'],
NGDetails['NGClassNo'],
)
cursor.execute(update_ng_details_sql, values)
self.db.commit()
cursor.close()
self.logger.info("NG明细记录已更新")
except pymysql.MySQLError as e:
self.logger.error(f"NG明细记录保存失败{e}")
# self.db.rollback()
cursor.close()
except Exception as e:
self.logger.error(f"NG明细记录保存失败{e}")
cursor.close()
self.db.close()
def saveNGHourDetails(self, NGHourDetailsList):
"""
保存ng小时分布信息
:param NGHourDetailsList:ng小时分布信息列表
"""
self.checkConnection()
timenow = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
insert_ng_hour_details_sql = """
INSERT INTO iotmachine_ng_hour_details (record_id, ng_create_date, equip_no, ng_class_name, ng_class_no, num, parent_equip_no, product_name, product_no, shift_num, statistics_date,create_time,update_time,is_delete)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
query_ng_details_sql = """
SELECT id
FROM iotmachine_ng_details
WHERE parent_equip_no=%s AND product_no=%s AND shift_num=%s AND statistics_date = %s AND ng_class_no = %s
"""
query_ng_hour_details_sql = """
SELECT *
FROM iotmachine_ng_hour_details
WHERE parent_equip_no=%s AND product_no=%s AND shift_num=%s AND statistics_date = %s AND ng_class_no = %s AND ng_create_date = %s
"""
update_ng_hour_details_sql = """
UPDATE iotmachine_ng_hour_details
SET num=%s, update_time=%s
WHERE parent_equip_no=%s AND product_no=%s AND shift_num=%s AND statistics_date = %s AND ng_class_no = %s AND ng_create_date = %s
"""
try:
cursor = self.db.cursor()
# self.db.begin()
for NGHourDetails in NGHourDetailsList :
# 准备SQL参数
values = (
NGHourDetails['ParentEquipNo'],
NGHourDetails['ProductNo'],
NGHourDetails['ShiftNum'],
NGHourDetails['StatisticsDate'],
NGHourDetails["NGClassNo"]
)
recoedNum = cursor.execute(query_ng_details_sql,values) # 先查询主表(ngdetails)中对应的id
if recoedNum == 0:
self.logger.error("NG小时分布没有在主表中找到记录")
self.logger.error(values)
record_id = 0
else:
record_id = cursor.fetchone()[0]
values = (
NGHourDetails['ParentEquipNo'],
NGHourDetails['ProductNo'],
NGHourDetails['ShiftNum'],
NGHourDetails['StatisticsDate'],
NGHourDetails['NGClassNo'],
NGHourDetails["CreateDate"]
)
recoedNum = cursor.execute(query_ng_hour_details_sql,values) # 查询是否有该记录
self.logger.debug(f"查找到记录数:{recoedNum}")
if recoedNum == 0:
self.logger.info("新增NG小时分布记录")
self.logger.debug(NGHourDetails)
values = (
record_id,
NGHourDetails['CreateDate'],
NGHourDetails['EquipNo'],
NGHourDetails['NGClassName'],
NGHourDetails['NGClassNo'],
NGHourDetails['Num'],
NGHourDetails['ParentEquipNo'],
NGHourDetails['ProductName'],
NGHourDetails['ProductNo'],
NGHourDetails['ShiftNum'],
NGHourDetails['StatisticsDate'],
timenow,
timenow,
0
)
cursor.execute(insert_ng_hour_details_sql, values)
else:
self.logger.info("更新NG小时分布记录")
self.logger.debug(NGHourDetails)
values = (
NGHourDetails['Num'],
timenow,
NGHourDetails['ParentEquipNo'],
NGHourDetails['ProductNo'],
NGHourDetails['ShiftNum'],
NGHourDetails['StatisticsDate'],
NGHourDetails['NGClassNo'],
NGHourDetails['CreateDate'],
)
cursor.execute(update_ng_hour_details_sql, values)
self.db.commit()
cursor.close()
self.logger.info("NG小时分布记录已更新")
except pymysql.MySQLError as e:
self.logger.error(f"NG小时分布记录保存失败{e}")
# self.db.rollback()
cursor.close()
except Exception as e:
self.logger.error(f"NG小时分布记录保存失败{e}")
self.db.close()
{
"logLevel":"debug",
"dataBase":{
"host":"192.168.40.2",
"port":3306,
"username":"root",
"password":"nangao2019-",
"Database":"pengli_iot"
},
"machineList":[
{
"host":"192.168.40.151",
"port":7777,
"name":"machine1"
},
{
"host":"127.0.0.1",
"port":8001,
"name":"machine1"
}
]
}
\ No newline at end of file
import socket
import json
import threading
import time
import signal
import queue
import logging
import sys
import DAO
class TCPClient(threading.Thread):
"""
对于每一个机台的Tcp客户端,用于接收数据的线程
:param ip:机台ip地址
:param port:机台端口
:param name:机台名称
"""
def __init__(self, ip, port,name):
threading.Thread.__init__(self)
self.ip = ip
self.port = port
self.stopflag = False
self.isconnected = False
self.equipname=name
self.logger = logging.getLogger(self.equipname+"-thread")
def run(self):
while not self.stopflag: # 循环接收数据
if not self.isconnected: # 先判断连接状态
self.connect()
if not self.isconnected:
continue
try:
data = b''
while True: # 拼接数据,如果收到数据0.5秒内没有再收到数据,则认为接收结束
try:
buff = self.sock.recv(1024)
if len(buff) == 0: # 某些情况下,收到数据为空也是断开
raise Exception("连接已断开")
data = data + buff
except socket.timeout:
if data:
break
data = data.decode('utf-8',errors='ignore')
if not q.full():
q.put(data) # 解码后放入队列
except Exception as e:
self.logger.error(f"连接中断:{e}")
self.isconnected = False
self.sock.close()
self.logger.debug("停止循环接收数据")
def connect(self):
"""
连接tcp服务器
"""
while not self.isconnected and not self.stopflag:
try:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(3) # 3秒内没连接成功视为连接失败
self.sock.connect((self.ip, self.port))
self.sock.settimeout(0.5)
self.isconnected = True
self.logger.info("连接成功")
except Exception as e:
self.logger.info(f"重连失败{e}")
self.sock.close()
time.sleep(2)
def stop(self):
"""
停止线程
"""
self.stopflag = True
self.sock.close()
class DataHandler(threading.Thread):
"""
解析并存储数据的线程
"""
def __init__(self):
threading.Thread.__init__(self)
self.stopflag = False
self.logger = logging.getLogger("datahandler")
def run(self):
while not self.stopflag:
json_data = q.get() # 等待队列数据
if json_data is None: # 如果队列中元素是None,代表停止处理,程序退出
q.task_done()
break
TotalOutPutList = []
NGDetailsList = []
NGHourDetailsList = []
try:
data = json.loads(json_data)
# 解析总产量json
for TotalOutPut in data["TotalOutPut"]:# 遍历JSON对象
twoCameraFlag = False
for item in TotalOutPutList:# 查找是否有已存在的数据,并将两个相机的机台汇总到一起
if item["StatisticsDate"] == TotalOutPut["StatisticsDate"] and item["ShiftNum"] == TotalOutPut["ShiftNum"] and item["ProductNo"] == TotalOutPut["ProductNo"] and item["ParentEquipNo"] == TotalOutPut["ParentEquipNo"]:
item["AllNum"] = item["AllNum"] + TotalOutPut["AllNum"]
item["NGNum"] = item["NGNum"] + TotalOutPut["NGNum"]
item["OKNum"] = item["OKNum"] + TotalOutPut["OKNum"]
twoCameraFlag = True
if twoCameraFlag == False:# 如果没有该机台数据,则新增
TotalOutPutList.append({
"AllNum":TotalOutPut["AllNum"],
"NGNum":TotalOutPut["NGNum"],
"OKNum":TotalOutPut["OKNum"],
"ParentEquipNo":TotalOutPut["ParentEquipNo"],
"ProductName":TotalOutPut["ProductName"],
"ProductNo":TotalOutPut["ProductNo"],
"ShiftNum":TotalOutPut["ShiftNum"],
"StatisticsDate":TotalOutPut["StatisticsDate"]
})
# 解析NG明细json
for NGDetails in data["NGDetails"]:# 遍历JSON对象
twoCameraFlag = False
for item in NGDetailsList:# 查找是否有已存在的数据,并将两个相机的机台汇总到一起
if item["StatisticsDate"] == NGDetails["F_StatisticsDate"] and item["ShiftNum"] == NGDetails["ShiftNum"] and item["ProductNo"] == NGDetails["ProductNo"] and item["ParentEquipNo"] == NGDetails["ParentEquipNo"] and item["NGClassNo"] == NGDetails["NGClassNo"]:
item["Num"] = item["Num"] + NGDetails["Num"]
twoCameraFlag = True
if twoCameraFlag == False:# 如果没有该机台数据,则新增
NGDetailsList.append({
"EquipNo":NGDetails["EquipNo"],
"Num":NGDetails["Num"],
"NGClassNo":NGDetails["NGClassNo"],
"NGClassName":NGDetails["NGClassName"],
"ParentEquipNo":NGDetails["ParentEquipNo"],
"ProductName":NGDetails["ProductName"],
"ProductNo":NGDetails["ProductNo"],
"ShiftNum":NGDetails["ShiftNum"],
"StatisticsDate":NGDetails["F_StatisticsDate"]
})
# 解析ng小时分布
for NGHourDetails in data["NGHourDetails"]:# 遍历JSON对象
twoCameraFlag = False
for item in NGHourDetailsList:# 查找是否有已存在的数据,并将两个相机的机台汇总到一起
if item["StatisticsDate"] == NGHourDetails["F_StatisticsDate"] and item["ShiftNum"] == NGHourDetails["ShiftNum"] and item["ProductNo"] == NGHourDetails["ProductNo"] and item["ParentEquipNo"] == NGHourDetails["ParentEquipNo"] and item["NGClassNo"] == NGHourDetails["NGClassNo"] and item["CreateDate"] == NGHourDetails["CreateDate"]:
item["Num"] = item["Num"] + NGHourDetails["Num"]
twoCameraFlag = True
if twoCameraFlag == False:# 如果没有该机台数据,则新增
NGHourDetailsList.append({
"EquipNo":NGHourDetails["EquipNo"],
"Num":NGHourDetails["Num"],
"CreateDate":NGHourDetails["CreateDate"],
"NGClassNo":NGHourDetails["NGClassNo"],
"NGClassName":NGHourDetails["NGClassName"],
"ParentEquipNo":NGHourDetails["ParentEquipNo"],
"ProductName":NGHourDetails["ProductName"],
"ProductNo":NGHourDetails["ProductNo"],
"ShiftNum":NGHourDetails["ShiftNum"],
"StatisticsDate":NGHourDetails["F_StatisticsDate"]
})
except: # 如果解析过程的任何失败都认为json解析失败,并丢弃当前数据
self.logger.error(f"json解析失败:{json_data}")
q.task_done()
continue
self.logger.debug("解析TotalOutPutList:")
self.logger.debug(TotalOutPutList)
self.logger.debug("解析NGDetailsList:")
self.logger.debug(NGDetailsList)
self.logger.debug("解析NGHourDetailsList:")
self.logger.debug(NGHourDetailsList)
# 如果解析成功,依次存入数据库
db.saveTotalOutPut(TotalOutPutList)
db.saveNGDetails(NGDetailsList)
db.saveNGHourDetails(NGHourDetailsList)
# 标记队列中该数据已处理
q.task_done()
def stop(self):
q.put(None)
self.stopflag = True
def quit_handler(signum, frame): # 退出程序
global quitflag
rootlogger.debug(f"退出中")
for TcpClient in TcpClientList:
rootlogger.debug(f"退出{TcpClient.equipname}")
TcpClient.stop()
TcpClient.join()
rootlogger.debug(f"退出datahandler")
while not q.empty(): # 清空队列
try:
item = q.get_nowait()
q.task_done()
except queue.Empty:
break
datahandler.stop()
datahandler.join()
rootlogger.debug(f"关闭数据库连接")
db.close()
quitflag = True
print("正常退出")
# 配置logging
stream_handler = logging.StreamHandler(sys.stdout)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
stream_handler.setFormatter(formatter)
rootlogger = logging.getLogger()
rootlogger.addHandler(stream_handler)
# 初始化
TcpClientList = []
try:
conf = json.load(open("conf.json", "r",encoding="utf-8"))
logLevel = conf['logLevel'] # 配置文件三个级别
if logLevel == "debug":
rootlogger.setLevel(logging.DEBUG)
elif logLevel == "info":
rootlogger.setLevel(logging.INFO)
elif logLevel == "warning":
rootlogger.setLevel(logging.WARNING)
else:
rootlogger.setLevel(logging.ERROR)
dbHost = conf['dataBase']['host']
dbPort = conf['dataBase']['port']
dbUserName = conf['dataBase']['username']
dbPassword = conf['dataBase']['password']
dbDatabase = conf['dataBase']['Database']
machineList = conf['machineList']
for machine in machineList:
TcpClientList.append(TCPClient(machine["host"], machine["port"],machine["name"]))
except Exception as e:
rootlogger.fatal(f"初始化失败{e}")
sys.exit(1)
q = queue.Queue(maxsize=1000) # 待解析数据队列
db = DAO.MySQLDB(dbHost,dbPort,dbUserName,dbPassword,dbDatabase)# 初始化数据库
quitflag = False
signal.signal(signal.SIGINT,quit_handler) # 注册中断信号处理函数
for TcpClient in TcpClientList:# 启动全部tcp客户端
TcpClient.start()
datahandler = DataHandler()
datahandler.start()
while not quitflag:
time.sleep(1)
import socket
import time
tcpserver = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcpserver.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
tcpserver.bind(("0.0.0.0", 8001))
tcpserver.listen(1)
# 打开文件
fo = open("test.json", "r",encoding="utf-8")
message = fo.read()
# message = message.encode('latin1').decode('utf-8')
print(message)
# 关闭文件
fo.close()
while True:
print("等待连接")
client, addr = tcpserver.accept()
print("Connected by", addr)
# data = client.recv(1024)
while True:
try:
time.sleep(5)
# input("发送JSON:")
client.send(message.encode('utf-8'))
print("sended")
except Exception as e:
print("断开连接:",e)
client.close()
break
# tcpserver.sendall("Hello World".encode('utf-8'))
\ No newline at end of file
{
"TotalOutPut": [{
"AllNum": 2,
"CameraIp": "1",
"NGNum": 1,
"OKNum": 1,
"ParentEquipNo": "GVDE3334",
"ProductName": "产品1",
"ProductNo": "200304",
"ShiftNum": 1,
"StatisticsDate": "2023-04-05"
}, {
"AllNum": 2,
"CameraIp": "1",
"NGNum": 1,
"OKNum": 1,
"ParentEquipNo": "GVDE3334",
"ProductName": "产品2",
"ProductNo": "200305",
"ShiftNum": 1,
"StatisticsDate": "2023-04-05"
}],
"NGDetails":[
{
"CameraIp": "1",
"EquipNo": "GSV102233",
"F_StatisticsDate": "2023-04-05",
"NGClassName": "异常类别1",
"NGClassNo": 10001,
"Num": 1,
"ParentEquipNo": "GVDE3334",
"ProductName": "产品1",
"ProductNo": "200304",
"ShiftNum": 1
},
{
"CameraIp": "1",
"EquipNo": "GSV102233",
"F_StatisticsDate": "2023-04-05",
"NGClassName": "异常类别1",
"NGClassNo": 10001,
"Num": 1,
"ParentEquipNo": "GVDE3334",
"ProductName": "产品2",
"ProductNo": "200305",
"ShiftNum": 1
}
],
"NGHourDetails":[
{
"CameraIp": "1",
"CreateDate": "2023-04-05 08:30:00.000",
"EquipNo": "GSV102233",
"F_StatisticsDate": "2023-04-05",
"NGClassName": "异常类别1",
"NGClassNo": 10001,
"Num": 1,
"ParentEquipNo": "GVDE3334",
"ProductName": "产品1",
"ProductNo": "200304",
"ShiftNum": 1
},
{
"CameraIp": "1",
"CreateDate": "2023-04-05 09:30:00.000",
"EquipNo": "GSV102233",
"F_StatisticsDate": "2023-04-05",
"NGClassName": "异常类别1",
"NGClassNo": 10001,
"Num": 1,
"ParentEquipNo": "GVDE3334",
"ProductName": "产品2",
"ProductNo": "200305",
"ShiftNum": 1
}
]
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment