mirror of
https://github.com/PiBrewing/craftbeerpi4.git
synced 2024-11-09 17:07:43 +01:00
config fix
This commit is contained in:
parent
b1526fa247
commit
0496b04608
23 changed files with 881 additions and 599 deletions
1251
.idea/workspace.xml
1251
.idea/workspace.xml
File diff suppressed because it is too large
Load diff
|
@ -12,6 +12,8 @@ class CBPiSensor(CBPiExtension):
|
|||
self.data_logger = None
|
||||
self.state = False
|
||||
|
||||
def get_parameter(self, name, default):
|
||||
return self.cbpi.config.get(name, default)
|
||||
|
||||
|
||||
def log_data(self, value):
|
||||
|
@ -27,7 +29,7 @@ class CBPiSensor(CBPiExtension):
|
|||
self.data_logger = logging.getLogger('cbpi.sensor.%s' % self.id)
|
||||
self.data_logger.propagate = False
|
||||
self.data_logger.setLevel(logging.DEBUG)
|
||||
handler = RotatingFileHandler('./logs/sensors/sensor_%s.log' % self.id, maxBytes=2000, backupCount=10)
|
||||
handler = RotatingFileHandler('./logs/sensor_%s.log' % self.id, maxBytes=2000, backupCount=10)
|
||||
self.data_logger.addHandler(handler)
|
||||
pass
|
||||
|
||||
|
@ -39,4 +41,7 @@ class CBPiSensor(CBPiExtension):
|
|||
pass
|
||||
|
||||
def get_value(self):
|
||||
pass
|
||||
|
||||
def get_unit(self):
|
||||
pass
|
|
@ -8,12 +8,13 @@ class CBPiSimpleStep(metaclass=ABCMeta):
|
|||
|
||||
__dirty = False
|
||||
managed_fields = []
|
||||
_interval = 0.1
|
||||
_interval = 1
|
||||
_max_exceptions = 2
|
||||
_exception_count = 0
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
print(kwargs)
|
||||
for a in kwargs:
|
||||
super(CBPiSimpleStep, self).__setattr__(a, kwargs.get(a))
|
||||
self.id = kwargs.get("id")
|
||||
|
@ -60,14 +61,15 @@ class CBPiSimpleStep(metaclass=ABCMeta):
|
|||
await asyncio.sleep(self._interval)
|
||||
|
||||
if self.is_dirty():
|
||||
print("DIRTY")
|
||||
# Now we have to store the managed props
|
||||
state = {}
|
||||
for field in self.managed_fields:
|
||||
|
||||
state[field] = self.__getattribute__(field)
|
||||
#step_controller.model.update_step_state(step_controller.current_step.id, state)
|
||||
|
||||
await self.cbpi.step.model.update_step_state(self.id, state)
|
||||
|
||||
await self.cbpi.step.model.update_step_state(self.id, state)
|
||||
await self.cbpi.bus.fire("step/update")
|
||||
self.reset_dirty()
|
||||
|
||||
@abstractmethod
|
||||
|
|
|
@ -39,16 +39,19 @@ class ActorController(CRUDController):
|
|||
|
||||
try:
|
||||
if actor.type in self.types:
|
||||
|
||||
cfg = actor.config.copy()
|
||||
cfg.update(dict(cbpi=self.cbpi, id=id, name=actor.name))
|
||||
clazz = self.types[actor.type]["class"];
|
||||
self.cache[actor.id].instance = clazz(**cfg)
|
||||
self.cache[actor.id].instance.init()
|
||||
print(actor.id, self.cache[actor.id].instance)
|
||||
|
||||
await self.cbpi.bus.fire(topic="actor/%s/initialized" % actor.id, id=actor.id)
|
||||
else:
|
||||
|
||||
self.logger.error("Actor type '%s' not found (Available Actor Types: %s)" % (actor.type, ', '.join(self.types.keys())))
|
||||
except Exception as e:
|
||||
|
||||
self.logger.error("Failed to init actor %s - Reason %s" % (actor.id, str(e)))
|
||||
|
||||
async def _stop_actor(self, actor):
|
||||
|
@ -153,4 +156,5 @@ class ActorController(CRUDController):
|
|||
await self._stop_actor(actor)
|
||||
|
||||
async def _post_update_callback(self, actor):
|
||||
|
||||
await self._init_actor(actor)
|
||||
|
|
|
@ -95,11 +95,11 @@ class CRUDController(metaclass=ABCMeta):
|
|||
:return:
|
||||
'''
|
||||
|
||||
|
||||
|
||||
self.logger.debug("Update Sensor %s - %s " % (id, data))
|
||||
id = int(id)
|
||||
|
||||
if id not in self.cache:
|
||||
self.logger.debug("Sensor %s Not in Cache" % (id,))
|
||||
raise CBPiException("%s with id %s not found" % (self.name,id))
|
||||
|
||||
data["id"] = id
|
||||
|
@ -107,17 +107,18 @@ class CRUDController(metaclass=ABCMeta):
|
|||
try:
|
||||
### DELETE INSTANCE BEFORE UPDATE
|
||||
del data["instance"]
|
||||
except:
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
if self.caching is True:
|
||||
await self._pre_update_callback(self.cache[id])
|
||||
self.cache[id].__dict__.update(**data)
|
||||
m = await self.model.update(**self.cache[id].__dict__)
|
||||
await self._post_update_callback(m)
|
||||
else:
|
||||
m = await self.model.update(**data)
|
||||
m = self.cache[id] = await self.model.update(**self.cache[id].__dict__)
|
||||
await self._post_update_callback(self.cache[id])
|
||||
|
||||
else:
|
||||
|
||||
m = await self.model.update(**data)
|
||||
return m
|
||||
|
||||
|
||||
|
|
|
@ -53,11 +53,13 @@ class KettleController(CRUDController):
|
|||
match = re.match("kettle_logic_(\d+)", key)
|
||||
if match is not None:
|
||||
kid = match.group(1)
|
||||
await self.cbpi.bus.fire(topic="kettle/%s/logic/stop" % kid)
|
||||
|
||||
|
||||
kettle = self.cache[int(kid)]
|
||||
kettle.instance = None
|
||||
kettle.state = False
|
||||
print("FIRE")
|
||||
await self.cbpi.bus.fire(topic="kettle/%s/logic/stop" % kid)
|
||||
|
||||
@on_event(topic="kettle/+/automatic")
|
||||
async def handle_automtic_event(self, id, **kwargs):
|
||||
|
@ -89,7 +91,7 @@ class KettleController(CRUDController):
|
|||
cfg.update(dict(cbpi=self.cbpi))
|
||||
kettle.instance = clazz(**cfg)
|
||||
|
||||
await self.cbpi.job.start_job(kettle.instance.run(), "Kettle_logic_%s" % kettle.id, "kettle_logic%s" % id)
|
||||
await self.cbpi.job.start_job(kettle.instance.run(), "kettle_logic_%s" % kettle.id, "kettle_logic%s" % id)
|
||||
kettle.state = True
|
||||
|
||||
await self.cbpi.bus.fire(topic="kettle/%s/logic/start" % id)
|
||||
|
|
|
@ -42,13 +42,14 @@ class SensorController(CRUDController):
|
|||
self.cache[sensor.id].instance.init()
|
||||
scheduler = get_scheduler_from_app(self.cbpi.app)
|
||||
self.cache[sensor.id].instance.job = await scheduler.spawn(self.cache[sensor.id].instance.run(self.cbpi), sensor.name, "sensor")
|
||||
await self.cbpi.bus.fire(topic="sensor/%s/initialized" % sensor.id, id=sensor.id)
|
||||
else:
|
||||
self.logger.error("Sensor type '%s' not found (Available Sensor Types: %s)" % (sensor.type, ', '.join(self.types.keys())))
|
||||
|
||||
|
||||
|
||||
async def stop_sensor(self, sensor):
|
||||
print("STOP", sensor.id)
|
||||
|
||||
sensor.instance.stop()
|
||||
await self.cbpi.bus.fire(topic="sensor/%s/stopped" % sensor.id, id=sensor.id)
|
||||
|
||||
|
@ -78,4 +79,4 @@ class SensorController(CRUDController):
|
|||
await self.stop_sensor(sensor)
|
||||
|
||||
async def _post_update_callback(self, sensor):
|
||||
self.init_sensor(sensor)
|
||||
await self.init_sensor(sensor)
|
|
@ -154,8 +154,12 @@ class StepController(CRUDController):
|
|||
|
||||
if next_step is not None:
|
||||
step_type = self.types[next_step.type]
|
||||
print(step_type)
|
||||
managed_fields = self._get_manged_fields_as_array(step_type)
|
||||
config = dict(cbpi=self.cbpi, id=next_step.id, name=next_step.name, managed_fields=managed_fields)
|
||||
config.update(**next_step.config)
|
||||
self._set_default(step_type, config, managed_fields)
|
||||
|
||||
config = dict(cbpi=self.cbpi, id=next_step.id, name=next_step.name, managed_fields=self._get_manged_fields_as_array(step_type))
|
||||
self.current_step = step_type["class"](**config)
|
||||
|
||||
next_step.state = 'A'
|
||||
|
@ -176,6 +180,12 @@ class StepController(CRUDController):
|
|||
self.logger.error("Process Already Running")
|
||||
print("----------- END")
|
||||
|
||||
def _set_default(self, step_type, config, managed_fields):
|
||||
for key in managed_fields:
|
||||
if key not in config:
|
||||
config[key] = None
|
||||
|
||||
|
||||
@on_event("step/stop")
|
||||
async def stop(self, **kwargs):
|
||||
|
||||
|
@ -204,7 +214,13 @@ class StepController(CRUDController):
|
|||
|
||||
await self.model.reset_all_steps()
|
||||
|
||||
async def sort(self, data):
|
||||
@on_event("step/clear")
|
||||
async def clear_all(self, **kwargs):
|
||||
await self.model.delete_all()
|
||||
self.cbpi.notify(key="Steps Cleared", message="Steps cleared successfully", type="success")
|
||||
|
||||
@on_event("step/sort")
|
||||
async def sort(self, topic, data, **kwargs):
|
||||
await self.model.sort(data)
|
||||
|
||||
async def _pre_add_callback(self, data):
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import datetime
|
||||
import re
|
||||
|
||||
from aiohttp import web
|
||||
import os
|
||||
from aiojobs.aiohttp import get_scheduler_from_app
|
||||
|
||||
from cbpi.api import *
|
||||
|
@ -49,7 +52,7 @@ class SystemController():
|
|||
return web.Response(text="NOT IMPLEMENTED")
|
||||
|
||||
@request_mapping("/shutdown", method="POST", name="ShutdownSerer", auth_required=False)
|
||||
def restart(self, request):
|
||||
def shutdown(self, request):
|
||||
"""
|
||||
---
|
||||
description: Shutdown System - Not implemented
|
||||
|
@ -94,3 +97,50 @@ class SystemController():
|
|||
"""
|
||||
return web.json_response(data=self.cbpi.bus.dump())
|
||||
|
||||
@request_mapping(path="/logs", auth_required=False)
|
||||
async def http_get_log(self, request):
|
||||
result = []
|
||||
file_pattern = re.compile("^(\w+.).log(.?\d*)")
|
||||
for filename in sorted(os.listdir("./logs"), reverse=True):#
|
||||
if file_pattern.match(filename):
|
||||
result.append(filename)
|
||||
|
||||
return web.json_response(result)
|
||||
|
||||
@request_mapping(path="/logs/{name}", method="DELETE", auth_required=False)
|
||||
async def http_delete_log(self, request):
|
||||
log_name = request.match_info['name']
|
||||
file_patter = re.compile("^(\w+.).log(.?\d*)")
|
||||
file_sensor_log = re.compile("^sensor_(\d).log(.?\d*)")
|
||||
|
||||
if file_patter.match(log_name):
|
||||
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@request_mapping(path="/logs", method="DELETE", auth_required=False)
|
||||
async def http_delete_logs(self, request):
|
||||
|
||||
sensor_log_pattern = re.compile("sensor_([\d]).log$")
|
||||
sensor_log_pattern2 = re.compile("sensor_([\d]).log.[\d]*$")
|
||||
|
||||
app_log_pattern = re.compile("app.log$")
|
||||
|
||||
for filename in sorted(os.listdir("./logs"), reverse=True):#
|
||||
if app_log_pattern.match(filename):
|
||||
with open(os.path.join("./logs/%s" % filename), 'w'):
|
||||
pass
|
||||
continue
|
||||
|
||||
|
||||
for filename in sorted(os.listdir("./logs/sensors"), reverse=True):
|
||||
|
||||
if sensor_log_pattern.match(filename):
|
||||
with open(os.path.join("./logs/sensors/%s" % filename), 'w'):
|
||||
pass
|
||||
continue
|
||||
elif sensor_log_pattern2.match(filename):
|
||||
os.remove(os.path.join("./logs/sensors/%s" % filename))
|
||||
|
||||
return web.Response(status=204)
|
|
@ -38,6 +38,7 @@ class ConfigModel(DBModel):
|
|||
__table_name__ = "config"
|
||||
__json_fields__ = ["options"]
|
||||
__priamry_key__ = "name"
|
||||
__order_by__ = "name"
|
||||
|
||||
|
||||
class KettleModel(DBModel):
|
||||
|
@ -53,6 +54,7 @@ class StepModel(DBModel):
|
|||
|
||||
@classmethod
|
||||
async def update_step_state(cls, step_id, state):
|
||||
print("NOW UPDATE", state)
|
||||
async with aiosqlite.connect(DATABASE_FILE) as db:
|
||||
cursor = await db.execute("UPDATE %s SET stepstate = ? WHERE id = ?" % cls.__table_name__, (json.dumps(state), step_id))
|
||||
await db.commit()
|
||||
|
|
|
@ -95,6 +95,12 @@ class DBModel(object):
|
|||
await db.execute("DELETE FROM %s WHERE %s = ? " % (cls.__table_name__, cls.__priamry_key__), (id,))
|
||||
await db.commit()
|
||||
|
||||
@classmethod
|
||||
async def delete_all(cls):
|
||||
async with aiosqlite.connect(DATABASE_FILE) as db:
|
||||
await db.execute("DELETE FROM %s" % cls.__table_name__)
|
||||
await db.commit()
|
||||
|
||||
@classmethod
|
||||
async def insert(cls, **kwargs):
|
||||
|
||||
|
|
|
@ -54,15 +54,15 @@ class CustomLogic(CBPiKettleLogic):
|
|||
result = await self.wait_for_event("sensor/1/data", callback=my_callback)
|
||||
'''
|
||||
|
||||
|
||||
value = 0
|
||||
|
||||
|
||||
while self.running:
|
||||
|
||||
print("RUN", self.test)
|
||||
value = await self.cbpi.sensor.get_value(1)
|
||||
value = value + 1
|
||||
print(value)
|
||||
if value >= 10:
|
||||
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
from cbpi.api import *
|
||||
|
@ -29,10 +30,15 @@ class CustomSensor(CBPiSensor):
|
|||
def get_state(self):
|
||||
return self.state
|
||||
|
||||
|
||||
|
||||
def get_value(self):
|
||||
|
||||
return self.value
|
||||
|
||||
def get_unit(self):
|
||||
return "°%s" % self.get_parameter("TEMP_UNIT", "C")
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
|
@ -40,9 +46,8 @@ class CustomSensor(CBPiSensor):
|
|||
self.value = 0
|
||||
while True:
|
||||
await asyncio.sleep(self.interval)
|
||||
self.log_data(10)
|
||||
self.value = self.value + 1
|
||||
|
||||
self.log_data(self.value)
|
||||
await cbpi.bus.fire("sensor/%s/data" % self.id, value=self.value)
|
||||
|
||||
cache = {}
|
||||
|
|
|
@ -1,26 +1,31 @@
|
|||
import asyncio
|
||||
import time
|
||||
|
||||
from cbpi.api import *
|
||||
|
||||
|
||||
class CustomStepCBPi(CBPiSimpleStep):
|
||||
|
||||
name = Property.Number(label="Test")
|
||||
|
||||
name1 = Property.Number(label="Test", configurable=True)
|
||||
timer_end = Property.Number(label="Test", default_value=None)
|
||||
temp = Property.Number(label="Temperature", default_value=50, configurable=True)
|
||||
i = 0
|
||||
|
||||
@action(key="name", parameters=None)
|
||||
def test(self, **kwargs):
|
||||
self.name="WOOHOO"
|
||||
|
||||
|
||||
|
||||
async def run_cycle(self):
|
||||
print("RUN", self.name)
|
||||
print("RUN", self.name1, self.managed_fields, self.timer_end)
|
||||
self.i = self.i + 1
|
||||
await asyncio.sleep(5)
|
||||
print("WAIT")
|
||||
self.next()
|
||||
#if self.i == 5:
|
||||
# print("NEXT")
|
||||
|
||||
if self.timer_end is None:
|
||||
self.timer_end = time.time() + 10
|
||||
|
||||
if self.i == 10:
|
||||
self.next()
|
||||
|
||||
|
||||
#self.cbpi.notify(key="step", message="HELLO FROM STEP")
|
||||
|
|
|
@ -32,8 +32,6 @@ class HttpCrudEndpoints():
|
|||
@request_mapping(path="/", method="POST", auth_required=False)
|
||||
async def http_add(self, request):
|
||||
data = await request.json()
|
||||
|
||||
|
||||
obj = await self.controller.add(**data)
|
||||
return web.json_response(obj, dumps=json_dumps)
|
||||
|
||||
|
@ -42,8 +40,7 @@ class HttpCrudEndpoints():
|
|||
id = int(request.match_info['id'])
|
||||
data = await request.json()
|
||||
obj = await self.controller.update(id, data)
|
||||
|
||||
return web.json_response(await self.controller.get_one(id), dumps=json_dumps)
|
||||
return web.json_response(obj, dumps=json_dumps)
|
||||
|
||||
@request_mapping(path="/{id}", method="DELETE", auth_required=False)
|
||||
async def http_delete_one(self, request):
|
||||
|
|
|
@ -144,6 +144,9 @@ class SensorHttpEndpoints(HttpCrudEndpoints):
|
|||
"""
|
||||
return await super().http_delete_one(request)
|
||||
|
||||
|
||||
|
||||
|
||||
@request_mapping(path="/{id:\d+}/log", auth_required=False)
|
||||
async def http_get_log(self, request):
|
||||
sensor_id = request.match_info['id']
|
||||
|
@ -162,7 +165,7 @@ class SensorHttpEndpoints(HttpCrudEndpoints):
|
|||
sensor_id = request.match_info['id']
|
||||
|
||||
for filename in sorted(os.listdir("./logs/sensors"), reverse=True):
|
||||
print(filename)
|
||||
|
||||
if filename == "sensor_%s.log" % sensor_id:
|
||||
with open(os.path.join("./logs/sensors/%s" % filename), 'w'):
|
||||
pass
|
||||
|
@ -170,4 +173,39 @@ class SensorHttpEndpoints(HttpCrudEndpoints):
|
|||
if filename.startswith("sensor_%s" % sensor_id):
|
||||
os.remove(os.path.join("./logs/sensors/%s" % filename))
|
||||
|
||||
return web.Response(status=204)
|
||||
|
||||
@request_mapping(path="/{id:\d+}/action", method="POST", auth_required=False)
|
||||
async def http_action(self, request) -> web.Response:
|
||||
"""
|
||||
|
||||
---
|
||||
description: Toogle an actor on or off
|
||||
tags:
|
||||
- Actor
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
description: "Actor ID"
|
||||
required: true
|
||||
type: "integer"
|
||||
format: "int64"
|
||||
- in: body
|
||||
name: body
|
||||
description: Update an actor
|
||||
required: false
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
config:
|
||||
type: object
|
||||
responses:
|
||||
"204":
|
||||
description: successful operation
|
||||
"""
|
||||
sensor_id = int(request.match_info['id'])
|
||||
|
||||
await self.cbpi.bus.fire(topic="sensor/%s/action" % sensor_id, sensor_id=sensor_id, data=await request.json())
|
||||
return web.Response(status=204)
|
|
@ -162,7 +162,8 @@ class StepHttpEndpoints(HttpCrudEndpoints):
|
|||
"204":
|
||||
description: successful operation
|
||||
"""
|
||||
self.cbpi.notify(key="step_delete_all", message="NOT IMPLEMENTE", type="danger")
|
||||
|
||||
await self.cbpi.bus.fire("step/clear")
|
||||
return web.Response(status=204)
|
||||
|
||||
|
||||
|
@ -243,5 +244,5 @@ class StepHttpEndpoints(HttpCrudEndpoints):
|
|||
@request_mapping(path="/sort", method="POST", auth_required=False)
|
||||
async def http_sort(self, request):
|
||||
data = await request.json()
|
||||
await self.cbpi.step.sort(data)
|
||||
await self.cbpi.bus.fire("step/sort", data=data)
|
||||
return web.Response(status=204)
|
|
@ -23,6 +23,7 @@ class ComplexEncoder(JSONEncoder):
|
|||
elif isinstance(obj, SensorModel):
|
||||
data = dict(**obj.__dict__)
|
||||
data["value"] = value=obj.instance.get_value()
|
||||
data["unit"] = value = obj.instance.get_unit()
|
||||
data["state"] = obj.instance.get_state()
|
||||
del data["instance"]
|
||||
return data
|
||||
|
@ -35,6 +36,6 @@ class ComplexEncoder(JSONEncoder):
|
|||
else:
|
||||
raise TypeError()
|
||||
except Exception as e:
|
||||
|
||||
print(e)
|
||||
pass
|
||||
return None
|
||||
|
|
BIN
craftbeerpi.db
BIN
craftbeerpi.db
Binary file not shown.
|
@ -46,7 +46,7 @@ class ConfigTestCase(AioHTTPTestCase):
|
|||
await self.cbpi.config.set(key, value)
|
||||
assert self.cbpi.config.get(key, 1) == value
|
||||
|
||||
resp = await self.client.request("POST", "/config/%s/" % key, json={'value': '1'})
|
||||
resp = await self.client.request("PUT", "/config/%s/" % key, json={'value': '1'})
|
||||
assert resp.status == 204
|
||||
assert self.cbpi.config.get(key, -1) == "1"
|
||||
|
||||
|
|
|
@ -60,7 +60,8 @@ class DashboardTestCase(AioHTTPTestCase):
|
|||
resp = await self.client.get(path="/dashboard/%s/content" % (dashboard_id))
|
||||
assert resp.status == 200
|
||||
|
||||
resp = await self.client.put(path="/dashboard/%s/content/%s/move" % (dashboard_id, content_id), json=dict(x=1,y=1))
|
||||
|
||||
resp = await self.client.post(path="/dashboard/%s/content/%s/move" % (dashboard_id, content_id), json=dict(x=1,y=1))
|
||||
assert resp.status == 200
|
||||
|
||||
resp = await self.client.delete(path="/dashboard/%s/content/%s" % (dashboard_id, content_id))
|
||||
|
|
|
@ -22,7 +22,7 @@ class IndexTestCase(AioHTTPTestCase):
|
|||
async def test_404(self):
|
||||
# Test Index Page
|
||||
resp = await self.client.get(path="/abc")
|
||||
assert resp.status == 200
|
||||
assert resp.status == 500
|
||||
|
||||
@unittest_run_loop
|
||||
async def test_wrong_login(self):
|
||||
|
|
|
@ -37,14 +37,14 @@ class KettleTestCase(AioHTTPTestCase):
|
|||
@unittest_run_loop
|
||||
async def test_temp(self):
|
||||
resp = await self.client.get("/kettle/1/temp")
|
||||
assert resp.status == 200
|
||||
assert resp.status == 204
|
||||
|
||||
resp = await self.client.get("/kettle/1/targettemp")
|
||||
assert resp.status == 200
|
||||
|
||||
@unittest_run_loop
|
||||
async def test_automatic(self):
|
||||
resp = await self.client.get("/kettle/1/automatic")
|
||||
resp = await self.client.post("/kettle/1/automatic")
|
||||
assert resp.status == 204
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue