Compare commits

..

No commits in common. "93b4075c3572aead02cf12641ec638f98aeea1c6" and "062d08fe1ac3bfea1696b8c71478d84874ccc904" have entirely different histories.

12 changed files with 92 additions and 257 deletions

14
.idea/deployment.xml generated
View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PublishConfigData" serverName="backend" remoteFilesAllowedToDisappearOnAutoupload="false">
<serverData>
<paths name="backend">
<serverdata>
<mappings>
<mapping deploy="/home/lego/SMARTGARDENING" local="$PROJECT_DIR$/software" web="/" />
</mappings>
</serverdata>
</paths>
</serverData>
</component>
</project>

8
.idea/sshConfigs.xml generated
View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SshConfigs">
<configs>
<sshConfig authType="PASSWORD" host="lego-K53SV" id="40359a92-2c87-41df-943b-cfac73c7ea3d" port="22" nameFormat="DESCRIPTIVE" username="lego" useOpenSSHConfig="true" />
</configs>
</component>
</project>

14
.idea/webServers.xml generated
View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="WebServers">
<option name="servers">
<webServer id="1b3e9e3f-7394-4cbc-a32d-b95ef2abda52" name="backend">
<fileTransfer accessType="SFTP" host="lego-K53SV" port="22" sshConfigId="40359a92-2c87-41df-943b-cfac73c7ea3d" sshConfig="lego@lego-K53SV:22 password">
<advancedOptions>
<advancedOptions dataProtectionLevel="Private" keepAliveTimeout="0" passiveMode="true" shareSSLContext="true" />
</advancedOptions>
</fileTransfer>
</webServer>
</option>
</component>
</project>

View File

@ -29,8 +29,8 @@ urllib3==1.26.14
Werkzeug==2.2.3
zipp==3.15.0
python-ev3dev2==2.1.0.post1
pytest==7.3.1
pytest
pip==23.1.2
# spidev==3.6
# picamera==1.13
numpy==1.19.5
numpy==1.19.5

Binary file not shown.

View File

@ -10,103 +10,73 @@ from software.defines import Topics, MAX_PLANT_COUNT
import json
import uuid
from typing import Union
from datetime import datetime
import logging
from robot import Robot
# Robot Channel Reactions
def data_sensordata(client: mqtt.Client, userdata, message: mqtt.MQTTMessage, mydatabase: PlantDataBase,
robot: Robot):
order_handler: list):
print("message received")
# TODO: Store data in database
str_in = str(message.payload.decode("UTF-8"))
payload = json.loads(str_in)
logging.info("ROBOT_DATA_SENSORDATA Received data: " + json.dumps(payload))
drive_data = {
"PlantID": payload['PlantID'],
"ActionID": payload['ActionID']
}
print("Received data: ", json.dumps(payload))
try:
robot.delete_order(drive_data)
except Exception as e:
logging.error("Could not delete order: " + str(e))
order_handler.remove(payload['ActionID'])
mydatabase.insert_measurement_data(plant_id=payload['PlantID'],
sensordata_temp=payload['AirTemperature'],
sensordata_humidity=payload['AirHumidity'],
sensordata_soil_moisture=payload['SoilMoisture'],
sensordata_brightness=payload['Brightness'])
logging.debug("Inserted to data base: " + json.dumps(payload))
def data_position(client: mqtt.Client, userdata, message: mqtt.MQTTMessage, robot: Robot):
logging.info("ROBOT_DATA_POSITION Received data: " + json.dumps(message.payload.decode("UTF-8")))
robot.store_position(json.loads(message.payload.decode("UTF-8"))["Position"])
position_data = {
"Position": robot.get_position(),
"Timestamp": str(datetime.now())
}
client.publish(Topics['BACKEND_DATA_POSITION'], json.dumps(position_data))
def data_position(client: mqtt.Client, userdata, message: mqtt.MQTTMessage, mydatabase: PlantDataBase):
# TODO: Forward to frontend in json format
client.publish(Topics['BACKEND_DATA_POSITION'], message.payload.decode("utf-8"))
def data_battery(client: mqtt.Client, userdata, message: mqtt.MQTTMessage, robot: Robot):
logging.info("ROBOT_DATA_BATTERY Received data: " + json.dumps(message.payload.decode("UTF-8")))
robot.store_battery(json.loads(message.payload.decode("UTF-8"))["Battery"])
battery_data = {
"Battery": robot.get_battery(),
"Timestamp": str(datetime.now())
}
client.publish(Topics['BACKEND_DATA_BATTERY'], json.dumps(battery_data))
def data_battery(client: mqtt.Client, userdata, message: mqtt.MQTTMessage, mydatabase: PlantDataBase):
# TODO: Forward to frontend in json format
client.publish(Topics['BACKEND_DATA_BATTERY'], message.payload.decode("utf-8"))
# FrontEnd Channel Reactions
def action_drive(client: mqtt.Client, userdata, message: mqtt.MQTTMessage, mydatabase: PlantDataBase,
robot: Robot):
plant_id = mydatabase.get_plant_id(plant_name=json.loads(message.payload.decode("UTF-8"))["PlantName"])
action_id = str(uuid.uuid4())
drive_data = {
"PlantID": plant_id,
"ActionID": action_id
}
if robot.get_order_number() < 5 and robot.get_robot_status() is True:
robot.add_order(drive_data)
client.publish(Topics['ROBOT_ACTION_DRIVE'], json.dumps(drive_data))
logging.info("BACKEND_ACTION_DRIVE Drive Command published: " + json.dumps(drive_data))
order_handler: list):
# TODO: ROBOT READY CHECK
if len(order_handler) < 5:
order_handler.append(uuid.uuid4())
else:
if robot.get_order_number() < 5:
robot.add_order(drive_data)
logging.info("BACKEND_ACTION_DRIVE New data added to order list: " + str(drive_data))
elif robot.get_order_number() >= 5:
logging.error("Could not add Order to list. Order discarded")
client.publish(Topics['BACKEND_DATA_ERROR'], "Could not add Order to list. Order discarded")
# TODO: What to do when no place in order_list left
pass
client.publish(Topics['ROBOT_ACTION_DRIVE'], message.payload.decode("utf-8"))
def action_driveall(client: mqtt.Client, userdata, message: mqtt.MQTTMessage, mydatabase: PlantDataBase,
robot: Robot):
def action_driveall(client: mqtt.Client, userdata, message: mqtt.MQTTMessage, mydatabase: PlantDataBase):
# TODO: Implement here
print("HELLO")
pass
def action_getposition(client: mqtt.Client, userdata, message: mqtt.MQTTMessage, mydatabase: PlantDataBase):
client.publish(Topics['ROBOT_ACTION_GETPOSITION'])
logging.info("BACKEND_ACTION_GETPOSITION message forwarded to Robot")
def action_getbattery(client: mqtt.Client, userdata, message: mqtt.MQTTMessage):
def action_getbattery(client: mqtt.Client, userdata, message: mqtt.MQTTMessage, mydatabase: PlantDataBase):
client.publish(Topics['ROBOT_ACTION_GETBATTERY'])
logging.info("BACKEND_ACTION_GETBATTERY message forwarded to Robot")
def action_getalldata(client: mqtt.Client, userdata, message: Union[mqtt.MQTTMessage, list], mydatabase: PlantDataBase):
plant_names = mydatabase.get_plant_names()
print(type(plant_names))
alldata = []
for i in plant_names:
print("I Type: " + str(type(i)))
print("I: " + i[0])
alldata.append(mydatabase.get_latest_data(plant_name=i[0]))
client.publish(Topics['BACKEND_DATA_SENSORDATAALL'], json.dumps(alldata))
logging.info("BACKEND_DATA_SENSORDATAALL got data from database:" + str(alldata))
print("BACKEND_DATA_SENSORDATAALL SEND DATA:" + str(alldata))
def action_newplant(client: mqtt.Client, userdata, message: mqtt.MQTTMessage, mydatabase: PlantDataBase):
@ -117,7 +87,8 @@ def action_newplant(client: mqtt.Client, userdata, message: mqtt.MQTTMessage, my
sensordata_humidity=plant_data["AirHumidity"],
sensordata_soil_moisture=plant_data["SoilMoisture"],
sensordata_brightness=plant_data["Brightness"])
logging.info("BACKEND_ACTION_NEWPLANT new plant data received and inserted: " + str(plant_data))
print("BACKEND_ACTION_NEWPLANT RECEIVED DATA: " + str(plant_data))
print(mydatabase.get_plant_names())
action_getalldata(client, userdata, message, mydatabase)
@ -129,14 +100,14 @@ def action_configureplant(client: mqtt.Client, userdata, message: mqtt.MQTTMessa
sensordata_humidity=plant_data["AirHumidity"],
sensordata_soil_moisture=plant_data["SoilMoisture"],
sensordata_brightness=plant_data["Brightness"])
logging.info("BACKEND_ACTION_CONFIGUREPLANT configure plant data received and inserted: " + str(plant_data))
print("BACKEND_ACTION_CONFIGUREPLANT RECEIVED DATA: " + str(plant_data))
action_getalldata(client, userdata, message, mydatabase)
def action_deleteplant(client: mqtt.Client, userdata, message: mqtt.MQTTMessage, mydatabase: PlantDataBase):
delete_plant = json.loads(message.payload.decode("UTF-8"))
mydatabase.delete_plant(plant_id=delete_plant)
logging.info("BACKEND_ACTION_DELETEPLANT delete plant data received and deleted: " + str(delete_plant))
print("BACKEND_ACTION_DELETEPLANT RECEIVED DATA: " + str(delete_plant))
action_getalldata(client, userdata, message, mydatabase)
@ -147,20 +118,4 @@ def action_countplants(client: mqtt.Client, userdata, message: mqtt.MQTTMessage,
"MaxCount": MAX_PLANT_COUNT
}
client.publish(Topics["BACKEND_DATA_PLANTCOUNT"], json.dumps(count_payload))
logging.info("BACKEND_DATA_PLANTCOUNT forwarded plant count to FrontEnd: " + str(count_payload))
def data_error(client: mqtt.Client, userdata, message: mqtt.MQTTMessage, robot: Robot):
robot.store_last_error(message.payload.decode("UTF-8"))
logging.error("ROBOT_DATA_ERROR new error received from Robot: " + robot.get_last_error())
client.publish(Topics['BACKEND_DATA_ERROR'], message.payload.decode("UTF-8"))
def data_robotready(client: mqtt.Client, userdata, message: mqtt.MQTTMessage, robot: Robot):
robot.change_robot_status(message.payload.decode("UTF-8") == 'True')
if robot.get_robot_status() is True and robot.get_order_number() >= 1:
client.publish(Topics['ROBOT_ACTION_DRIVE'], json.dumps(robot.get_next_order()))
logging.info("Waiting Order send to Robot")
logging.info("ROBOT_DATA_ROBOTREADY status updated: " + str(robot.get_robot_status()))
client.publish(Topics['BACKEND_DATA_ROBOTREADY'], message.payload.decode("UTF-8"))
print("BACKEND_DATA_PLANTCOUNT SEND DATA: " + str(count_payload))

View File

@ -1,12 +1,10 @@
import paho.mqtt.client as mqtt
import software.defines
from software.defines import MQTT_BROKER_LOCAL
from random import randrange, uniform
import time
import json
from software.defines import Topics, PLANTDATA
mqttBroker = software.defines.MQTT_BROKER_GLOBAL
mqttBroker = "192.168.178.182"
def on_connect(client, userdata, flags, rc):
@ -22,10 +20,17 @@ client.on_connect = on_connect
client.connect(mqttBroker)
plantdata = {
"PlantName": "Kemal"
"AirTemperature": 20.4,
"AirHumidity": 7.0,
"SoilMoisture": 5.0,
"Brightness": 39,
"PlantID": 2,
"Timestamp": "hallo",
"MeasurementID": 187
}
print(type(PLANTDATA))
client.publish("BACKEND/ACTION/GETBATTERY", json.dumps(plantdata))
print(json.dumps(plantdata))
time.sleep(2)
while True:
client.publish("TEST", json.dumps(plantdata))
print(json.dumps(plantdata))
time.sleep(2)

View File

@ -12,17 +12,17 @@ import paho.mqtt.client as mqtt
from software.defines import MQTT_BROKER_LOCAL, MQTT_BROKER_GLOBAL, Topics, BACKEND_CLIENT_ID, DATABASE_NAME
from plantdatabase import PlantDataBase
import data_functions
import logging
import sys
from robot import Robot
# inits
mydatabase = PlantDataBase(database_name=DATABASE_NAME)
mydatabase.create_tables()
order_handler = [] # will contain UUIDS with Order IDs
def on_connect(_client: mqtt.Client, _userdata, _flags, _rc, _mydatabase, _robot):
def on_connect(_client: mqtt.Client, _userdata, _flags, _rc):
"""
This method gets called, when it connects to a mqtt broker.
It is used to subscribe to the specific topics
:param _robot:
:param _mydatabase:
:param _client: mqtt client object
:param _userdata:
:param _flags:
@ -37,25 +37,25 @@ def on_connect(_client: mqtt.Client, _userdata, _flags, _rc, _mydatabase, _robot
# From Robot:
_client.subscribe(Topics['ROBOT_DATA_SENSORDATA'])
_client.message_callback_add(Topics['ROBOT_DATA_SENSORDATA'], lambda client, userdata, message: data_functions.
data_sensordata(client, userdata, message, _mydatabase, _robot))
data_sensordata(client, userdata, message, mydatabase, order_handler))
_client.subscribe(Topics['ROBOT_DATA_POSITION'])
_client.message_callback_add(Topics['ROBOT_DATA_POSITION'], data_functions.data_position)
_client.subscribe(Topics['ROBOT_DATA_BATTERY'])
_client.message_callback_add(Topics['ROBOT_DATA_BATTERY'], lambda client, userdata, message: data_functions.
data_battery(client, userdata, message))
data_battery(client, userdata, message, mydatabase))
# client.subscribe('Robot/Data/Picture')
# From FrontEnd:
_client.subscribe(Topics['BACKEND_ACTION_DRIVE'])
_client.message_callback_add(Topics['BACKEND_ACTION_DRIVE'], lambda client, userdata, message: data_functions.
action_drive(client, userdata, message, _mydatabase, _robot))
action_drive(client, userdata, message, mydatabase, order_handler))
_client.subscribe(Topics['BACKEND_ACTION_DRIVEALL'])
_client.message_callback_add(Topics['BACKEND_ACTION_DRIVEALL'], lambda client, userdata, message: data_functions.
action_driveall(client, userdata, message, _mydatabase, _robot))
_client.message_callback_add(Topics['BACKEND_ACTION_DRIVE'], lambda client, userdata, message: data_functions.
action_driveall(client, userdata, message, mydatabase))
_client.subscribe(Topics['BACKEND_ACTION_GETPOSITION'])
_client.message_callback_add(Topics['BACKEND_ACTION_GETPOSITION'], data_functions.action_getposition)
@ -66,54 +66,35 @@ def on_connect(_client: mqtt.Client, _userdata, _flags, _rc, _mydatabase, _robot
_client.subscribe(Topics['BACKEND_ACTION_GETALLDATA'])
_client.message_callback_add(Topics['BACKEND_ACTION_GETALLDATA'],
lambda client, userdata, message: data_functions.
action_getalldata(client, userdata, message, _mydatabase))
action_getalldata(client, userdata, message, mydatabase))
_client.subscribe(Topics['BACKEND_ACTION_NEWPLANT'])
_client.message_callback_add(Topics['BACKEND_ACTION_NEWPLANT'], lambda client, userdata, message: data_functions.
action_newplant(client, userdata, message, _mydatabase))
action_newplant(client, userdata, message, mydatabase))
_client.subscribe(Topics['BACKEND_ACTION_CONFIGUREPLANT'])
_client.message_callback_add(Topics['BACKEND_ACTION_CONFIGUREPLANT'], lambda client, userdata, message: data_functions.
action_configureplant(client, userdata, message, _mydatabase))
action_configureplant(client, userdata, message, mydatabase))
_client.subscribe(Topics['BACKEND_ACTION_DELETEPLANT'])
_client.message_callback_add(Topics['BACKEND_ACTION_DELETEPLANT'],
lambda client, userdata, message: data_functions.
action_deleteplant(client, userdata, message, _mydatabase))
action_deleteplant(client, userdata, message, mydatabase))
_client.subscribe(Topics['BACKEND_ACTION_PLANTCOUNT'])
_client.message_callback_add(Topics['BACKEND_ACTION_PLANTCOUNT'], lambda client, userdata, message: data_functions.
action_countplants(client, userdata, message, _mydatabase))
action_countplants(client, userdata, message, mydatabase))
_client.subscribe(Topics['ROBOT_DATA_ERROR'])
_client.message_callback_add(Topics['ROBOT_DATA_ERROR'], lambda client, userdata, message: data_functions.
data_error(client, userdata, message, _robot))
_client.subscribe(Topics['ROBOT_DATA_ROBOTREADY'])
_client.message_callback_add(Topics['ROBOT_DATA_ROBOTREADY'], lambda client, userdata, message: data_functions.
data_robotready(client, userdata, message, _robot))
# END TOPIC SUBSCRIPTIONS
else:
print("connection failed")
def main():
robot = Robot()
my_database = PlantDataBase(database_name=DATABASE_NAME)
my_database.create_tables()
mqttclient = mqtt.Client(BACKEND_CLIENT_ID)
mqttclient.on_connect = lambda client, userdata, flags, rc: on_connect(_client=client,
_userdata=userdata,
_flags=flags,
_rc=rc,
_mydatabase=my_database,
_robot=robot)
mqttclient.connect(MQTT_BROKER_GLOBAL)
logging.basicConfig(filename="server.log", filemode="a", encoding="utf-8", level=logging.DEBUG,
format='%(asctime)s %(name)s %(levelname)s %(message)s',
datefmt="%d-%m-%Y %H:%M:%S")
logging.getLogger().addHandler(logging.StreamHandler(sys.stdout))
mqttclient.loop_forever()
client = mqtt.Client(BACKEND_CLIENT_ID)
client.on_connect = on_connect
client.connect(MQTT_BROKER_GLOBAL)
client.loop_forever()
if __name__ == "__main__":

View File

@ -1,7 +1,6 @@
# file to create a database via python script
import sqlite3
from typing import Optional
import logging
class PlantDataBase:
@ -14,8 +13,9 @@ class PlantDataBase:
self.conn = None
try:
self.conn = sqlite3.connect(self.db_file, check_same_thread=False)
print(sqlite3.version)
except sqlite3.Error as e:
logging.error("Database init error: " + str(e))
print(e)
self.cur = self.conn.cursor()
def create_tables(self):
@ -38,36 +38,32 @@ class PlantDataBase:
self.cur.execute(table_config)
return True
except sqlite3.Warning as e:
logging.error("Could not create tables: " + str(e))
return False
return e
def insert_plant(self, plantname: str, plant_id: int) -> bool:
def insert_plant(self, plantname: str, plant_id: int):
try:
self.cur.execute("INSERT INTO plants (PlantName, PlantID) VALUES (?,?)", (plantname, plant_id))
self.conn.commit()
return True
except Exception as e:
logging.error("Could not insert plant: " + str(e))
return False
except (sqlite3.NotSupportedError, sqlite3.Warning) as e:
return e
def configure_plant(self, plant_id: int, plantname: str) -> bool:
def configure_plant(self, plant_id: int, plantname: str):
try:
self.cur.execute("UPDATE plants SET PlantID = ?, PlantName = ? WHERE PlantID= ?",
(plant_id, plantname, plant_id))
self.conn.commit()
return True
except Exception as e:
logging.error("Could not configure plant: " + str(e))
return False
except (sqlite3.NotSupportedError, sqlite3.Warning) as e:
return e
def delete_plant(self, plant_id):
try:
self.cur.execute('DELETE FROM plants WHERE PlantID = ?', (plant_id,))
self.conn.commit()
return True
except Exception as e:
logging.error("Could not delete plant: " + str(e))
return False
except (sqlite3.NotSupportedError, sqlite3.Warning) as e:
return e
def insert_measurement_data(self, plant_id,
sensordata_temp,
@ -76,14 +72,13 @@ class PlantDataBase:
sensordata_brightness) -> bool:
try:
self.cur.execute(f"INSERT INTO measurement_values (PlantID, AirTemperature, AirHumidity,"
f"SoilMoisture, Brightness) VALUES "
f"({plant_id}, {sensordata_temp}, {sensordata_humidity}, {sensordata_soil_moisture}"
f", {sensordata_brightness})")
f"SoilMoisture, Brightness) VALUES "
f"({plant_id}, {sensordata_temp}, {sensordata_humidity}, {sensordata_soil_moisture}"
f", {sensordata_brightness})")
self.conn.commit()
return True
except Exception as e:
logging.error("Could not insert measurement data: " + str(e))
return False
except (sqlite3.NotSupportedError, sqlite3.Warning) as e:
return e
def get_latest_data(self, plant_name: Optional[str] = None, plant_id: Optional[int] = None):
"""
@ -113,42 +108,27 @@ class PlantDataBase:
"PlantName": plant_name
}
return json_file
except Exception as e:
logging.error("Could not get measurement values: " + str(e))
except (sqlite3.Warning, TypeError) as e:
return e
def delete_data(self, table_name):
try:
self.cur.execute(f'DELETE FROM {table_name}')
self.conn.commit()
return True
except Exception as e:
logging.error("Could not delete data: " + str(e))
self.cur.execute(f'DELETE FROM {table_name}')
self.conn.commit()
return True
# TODO: Kemals Scheiß implementieren
def plant_count(self) -> int:
"""
returns the number of plants registered in the database
:return:
"""
try:
self.cur.execute("SELECT COUNT(*) FROM plants")
return self.cur.fetchone()[0]
except Exception as e:
logging.error("Could not count plants: " + str(e))
self.cur.execute("SELECT COUNT(*) FROM plants")
return self.cur.fetchone()[0]
def get_plant_names(self) -> list:
try:
self.cur.execute("SELECT PlantName FROM plants")
return self.cur.fetchall()
except Exception as e:
logging.error("Could not get plant names: " + str(e))
def get_plant_id(self, plant_name: str) -> int:
try:
self.cur.execute("SELECT PlantID FROM plants WHERE PlantName=?", (plant_name,))
return self.cur.fetchone()[0]
except Exception as e:
logging.error("Could not get plant id: " + str(e))
self.cur.execute("SELECT PlantName FROM plants")
return self.cur.fetchall()
def __del__(self):
self.conn.close()

View File

@ -1,47 +0,0 @@
class Robot:
"""
This class contains the features of the robot. It is used as an interface for the main to avoid global variables and
store them instead in an instance of this robot object
"""
def __init__(self):
self.robot_ready = True
self.order_handler = []
self.battery = 0
self.position = ""
self.last_error = ""
def change_robot_status(self, status: bool):
self.robot_ready = status
def add_order(self, drivedata):
self.order_handler.append(drivedata)
def delete_order(self, drivedata):
self.order_handler.remove(drivedata)
def get_next_order(self):
return self.order_handler[0]
def get_order_number(self):
return len(self.order_handler)
def store_battery(self, battery):
self.battery = battery
def store_position(self, position):
self.position = position
def store_last_error(self, error):
self.last_error = error
def get_battery(self):
return self.battery
def get_position(self):
return self.position
def get_last_error(self):
return self.last_error
def get_robot_status(self):
return self.robot_ready

View File

@ -21,8 +21,6 @@ Topics = {
"ROBOT_DATA_BATTERY": "ROBOT/DATA/BATTERY",
"ROBOT_DATA_POSITION": "ROBOT/DATA/POSITION",
"ROBOT_DATA_PICTURE": "ROBOT/DATA/PICTURE",
"ROBOT_DATA_ERROR": "ROBOT/DATA/ERROR",
"ROBOT_DATA_ROBOTREADY": "ROBOT/DATA/ROBOTREADY",
"BACKEND_ACTION_DRIVE": "BACKEND/ACTION/DRIVE",
"BACKEND_ACTION_DRIVEALL": "BACKEND/ACTION/DRIVEALL",
@ -40,8 +38,7 @@ Topics = {
"BACKEND_DATA_BATTERY": "BACKEND/DATA/BATTERY",
"BACKEND_DATA_PICTURE": "BACKEND/DATA/PICTURE",
"BACKEND_DATA_PLANTCOUNT": "BACKEND/DATA/PLANTCOUNT",
"BACKEND_DATA_ERROR": "BACKEND/DATA/ERROR",
"BACKEND_DATA_ROBOTREADY": "BACKEND/DATA/ROBOTREADY"
}