diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 5603606..3d231a5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -8,7 +8,7 @@ android { compileSdkVersion 28 defaultConfig { applicationId "io.github.assis10t.bobandroid" - minSdkVersion 22 + minSdkVersion 23 targetSdkVersion 28 versionCode 1 versionName "1.0" @@ -29,10 +29,13 @@ dependencies { implementation 'com.android.support:design:28.0.0' implementation 'com.android.support.constraint:constraint-layout:1.1.3' implementation 'com.android.support:support-v4:28.0.0' + implementation 'com.jakewharton.timber:timber:4.7.1' + testImplementation "com.nhaarman:mockito-kotlin:1.1.0" testImplementation 'junit:junit:4.12' androidTestImplementation 'com.android.support.test:runner:1.0.2' androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' implementation 'org.jmdns:jmdns:3.5.1' implementation 'com.squareup.okhttp3:okhttp:3.12.0' implementation 'com.google.code.gson:gson:2.8.5' + implementation 'org.jetbrains.anko:anko-common:0.9' } diff --git a/android/app/src/main/java/io/github/assis10t/bobandroid/Application.kt b/android/app/src/main/java/io/github/assis10t/bobandroid/Application.kt index a02a552..21494dd 100644 --- a/android/app/src/main/java/io/github/assis10t/bobandroid/Application.kt +++ b/android/app/src/main/java/io/github/assis10t/bobandroid/Application.kt @@ -1,11 +1,19 @@ package io.github.assis10t.bobandroid import android.app.Application +import timber.log.Timber class Application: Application() { override fun onCreate() { super.onCreate() ServerConnection.initialize() + + //From: https://github.com/oktay-sen/Coinz + Timber.plant(object : Timber.DebugTree() { + override fun createStackElementTag(element: StackTraceElement): String? { + return super.createStackElementTag(element) + ':' + element.lineNumber + } + }) } } \ No newline at end of file diff --git a/android/app/src/main/java/io/github/assis10t/bobandroid/MainActivity.kt b/android/app/src/main/java/io/github/assis10t/bobandroid/MainActivity.kt index d415837..149e097 100644 --- a/android/app/src/main/java/io/github/assis10t/bobandroid/MainActivity.kt +++ b/android/app/src/main/java/io/github/assis10t/bobandroid/MainActivity.kt @@ -1,12 +1,18 @@ package io.github.assis10t.bobandroid -import android.content.Context -import android.net.nsd.NsdManager -import android.net.nsd.NsdServiceInfo import android.support.v7.app.AppCompatActivity import android.os.Bundle -import android.util.Log -import java.net.InetAddress +import android.support.v7.widget.CardView +import android.support.v7.widget.LinearLayoutManager +import android.support.v7.widget.RecyclerView +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import io.github.assis10t.bobandroid.pojo.Item +import io.github.assis10t.bobandroid.pojo.Order +import kotlinx.android.synthetic.main.activity_main.* +import timber.log.Timber class MainActivity : AppCompatActivity() { @@ -15,5 +21,97 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + + container.isRefreshing = true + container.setOnRefreshListener { refreshItems() } + ServerConnection().connect { + container.isRefreshing = false + } + item_list.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) + item_list.adapter = ItemAdapter { selected -> + if (selected.isEmpty()) + make_order.hide() + else + make_order.show() + } + + //make_order.hide() + make_order.setOnClickListener { + container.isRefreshing = true + make_order.hide() + val adapter = item_list.adapter as ItemAdapter + ServerConnection().makeOrder(Order(null, adapter.selectedItems)) { success -> + if (!success) + Timber.e("Could not make order.") + else { + Timber.d("Order made.") + refreshItems() + } + } + } + + refreshItems() + } + + fun refreshItems() { + container.isRefreshing = true + ServerConnection().getItems { success, items -> + container.isRefreshing = false + if (!success) { + Timber.e("getItems failed.") + return@getItems + } + Timber.d("GetItems success. Items: ${items?.size}") + val adapter = item_list.adapter as ItemAdapter + adapter.updateItems(items!!) + } + } + + class ItemAdapter(var onSelectionChanged: (selected: List) -> Unit): RecyclerView.Adapter() { + var itemList: List = listOf() + val selectedItems: MutableList = mutableListOf() + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.fragment_shop_item, parent, false) + return ViewHolder(view) + } + + override fun getItemCount(): Int = itemList.size + + override fun onBindViewHolder(vh: ViewHolder, pos: Int) { + val item = itemList[pos] + val context = vh.container.context + vh.title.text = item.name + vh.container.setCardBackgroundColor( + if (selectedItems.contains(item)) + vh.container.context.getColor(R.color.selectHighlight) + else + vh.container.context.getColor(R.color.white) + ) + vh.container.cardElevation = + if (selectedItems.contains(item)) + dp(context, 4f) + else + dp(context, 1f) + vh.container.setOnClickListener { + if (selectedItems.contains(item)) + selectedItems.remove(item) + else + selectedItems.add(item) + onSelectionChanged(itemList) + notifyItemChanged(pos) + } + } + + fun updateItems(items: List) { + this.itemList = items + this.selectedItems.clear() + notifyDataSetChanged() + } + + class ViewHolder(view: View): RecyclerView.ViewHolder(view) { + val title: TextView = view.findViewById(R.id.title) + val quantity: TextView = view.findViewById(R.id.quantity) + val container: CardView = view.findViewById(R.id.container) + } } } diff --git a/android/app/src/main/java/io/github/assis10t/bobandroid/ServerConnection.kt b/android/app/src/main/java/io/github/assis10t/bobandroid/ServerConnection.kt index b6880fa..ade80c9 100644 --- a/android/app/src/main/java/io/github/assis10t/bobandroid/ServerConnection.kt +++ b/android/app/src/main/java/io/github/assis10t/bobandroid/ServerConnection.kt @@ -1,60 +1,148 @@ package io.github.assis10t.bobandroid -import android.os.AsyncTask -import android.util.Log -import java.net.Inet4Address +import com.google.gson.Gson +import io.github.assis10t.bobandroid.pojo.GetItemsResponse +import io.github.assis10t.bobandroid.pojo.Item +import io.github.assis10t.bobandroid.pojo.Order +import okhttp3.MediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import org.jetbrains.anko.doAsync +import org.jetbrains.anko.uiThread +import timber.log.Timber +import java.io.IOException +import java.util.concurrent.TimeUnit import javax.jmdns.JmDNS import javax.jmdns.ServiceEvent import javax.jmdns.ServiceListener class ServerConnection { - companion object { - private val TAG = "ServerConnection" val SERVER_NAME = "assis10t" - var serverIp: String? = null + var serverAddress: String? = null + val httpClient: OkHttpClient = OkHttpClient() - val onConnectedListeners: MutableList<(String) -> Unit> = mutableListOf() + val onConnectedListeners: MutableList<(serverAddress: String) -> Unit> = mutableListOf() - class ConnectTask: AsyncTask() { - override fun doInBackground(vararg params: Unit?) { - Log.d(TAG, "Discovery started") + fun initialize() { + doAsync { + Timber.d("Discovery started") val mJmDNS = JmDNS.create() mJmDNS.addServiceListener("_http._tcp.local.", object : ServiceListener { + override fun serviceResolved(event: ServiceEvent?) { val info = mJmDNS.getServiceInfo(event!!.type, event.name) - Log.d(TAG, "Service resolved: $info") + Timber.d("Service resolved: $info") if (info.name.contains(SERVER_NAME)) { - serverIp = "${info.inet4Addresses[0]!!.hostAddress}:${info.port}" - onConnectedListeners.forEach { it(serverIp!!) } - onConnectedListeners.clear() + uiThread { + serverAddress = "http://${info.inet4Addresses[0]!!.hostAddress}:${info.port}" + onConnectedListeners.forEach { it(serverAddress!!) } + onConnectedListeners.clear() + } } } override fun serviceRemoved(event: ServiceEvent?) { - Log.d(TAG, "Service removed") + Timber.d("Service removed") } override fun serviceAdded(event: ServiceEvent?) { val info = mJmDNS.getServiceInfo(event!!.type, event.name) - Log.d(TAG, "Service added: $info") + Timber.d("Service added: $info") } }) } - } - - fun initialize() { - ConnectTask().execute() ServerConnection().connect { ip -> - Log.d(TAG, "Server found at $ip") + Timber.d("Server found at $ip") } } } fun connect(onConnected: (String) -> Unit) { - if (serverIp != null) - onConnected(serverIp!!) + if (serverAddress != null) + onConnected(serverAddress!!) else onConnectedListeners.add(onConnected) } + + val getRequestFactory = { http: OkHttpClient -> + { url: String, onGetComplete: (success: Boolean, response: String?) -> Unit -> + doAsync { + Timber.d("Get request to $url") + try { + val request = Request.Builder().url(url).build() + val response = http.newCall(request).execute() + Timber.d("Response received.") + if (!response.isSuccessful) { + Timber.e("Get Request failed: (${response.code()}) ${response.body().toString()}") + uiThread { onGetComplete(false, null) } + } else { + uiThread { onGetComplete(true, response.body()?.string()) } + } + } catch (e: IOException) { + Timber.e(e, "Get Request failed") + uiThread { onGetComplete(false, null) } + } + } + } + } + + val postRequestFactory = { http: OkHttpClient, gson: Gson -> + { url: String, body: Any, onPostComplete: (success: Boolean, response: String?) -> Unit -> + doAsync { + Timber.d("Post request to $url") + try { + val JSON = MediaType.get("application/json; charset=utf-8") + val requestBody = RequestBody.create(JSON, gson.toJson(body)) + val request = Request.Builder().url(url).post(requestBody).build() + val response = http.newCall(request).execute() + Timber.d("Response received.") + if (!response.isSuccessful) { + Timber.e("Post Request failed: (${response.code()}) ${response.body().toString()}") + uiThread { onPostComplete(false, null) } + } else { + uiThread { onPostComplete(true, response.body()?.string()) } + } + } catch (e: IOException) { + Timber.e(e, "Post Request failed") + uiThread { onPostComplete(false, null) } + } + } + } + } + + val getItemsFactory = { http: OkHttpClient, gson: Gson -> + { onGetItems: (success: Boolean, items: List?) -> Unit -> + connect { server -> + getRequestFactory(http)("$server/items") { success, str -> + Timber.d("Result: $success, response: $str") + if (!success) { + onGetItems(success, null) + } else { + val response = gson.fromJson(str!!, GetItemsResponse::class.java) + onGetItems(response.success, response.items) + } + } + } + } + } + val getItems = getItemsFactory(httpClient, Gson()) + + val makeOrderFactory = { http: OkHttpClient, gson: Gson -> + { order: Order, onOrderComplete: ((success: Boolean) -> Unit)? -> + connect { server -> + postRequestFactory(http, gson)("$server/order", order) { success, str -> + Timber.d("Result: $success, response: $str") + if (!success) { + onOrderComplete?.invoke(success) + } else { + val response = gson.fromJson(str!!, GetItemsResponse::class.java) + onOrderComplete?.invoke(response.success) + } + } + } + } + } + val makeOrder = makeOrderFactory(httpClient, Gson()) } \ No newline at end of file diff --git a/android/app/src/main/java/io/github/assis10t/bobandroid/pojo/GetItemsResponse.kt b/android/app/src/main/java/io/github/assis10t/bobandroid/pojo/GetItemsResponse.kt new file mode 100644 index 0000000..0f016e9 --- /dev/null +++ b/android/app/src/main/java/io/github/assis10t/bobandroid/pojo/GetItemsResponse.kt @@ -0,0 +1,3 @@ +package io.github.assis10t.bobandroid.pojo + +class GetItemsResponse(val success: Boolean = false, val items:List = listOf()) \ No newline at end of file diff --git a/android/app/src/main/java/io/github/assis10t/bobandroid/pojo/Item.kt b/android/app/src/main/java/io/github/assis10t/bobandroid/pojo/Item.kt new file mode 100644 index 0000000..ac2644b --- /dev/null +++ b/android/app/src/main/java/io/github/assis10t/bobandroid/pojo/Item.kt @@ -0,0 +1,6 @@ +package io.github.assis10t.bobandroid.pojo + +class Item ( + val _id:String? = null, + val name:String? = null +) \ No newline at end of file diff --git a/android/app/src/main/java/io/github/assis10t/bobandroid/pojo/Order.kt b/android/app/src/main/java/io/github/assis10t/bobandroid/pojo/Order.kt new file mode 100644 index 0000000..74d3da4 --- /dev/null +++ b/android/app/src/main/java/io/github/assis10t/bobandroid/pojo/Order.kt @@ -0,0 +1,6 @@ +package io.github.assis10t.bobandroid.pojo + +class Order( + val _id: String? = null, + val items: List = listOf() +) \ No newline at end of file diff --git a/android/app/src/main/java/io/github/assis10t/bobandroid/utils.kt b/android/app/src/main/java/io/github/assis10t/bobandroid/utils.kt new file mode 100644 index 0000000..1e16ba0 --- /dev/null +++ b/android/app/src/main/java/io/github/assis10t/bobandroid/utils.kt @@ -0,0 +1,7 @@ +package io.github.assis10t.bobandroid + +import android.content.Context +import android.util.TypedValue + +fun dp(context: Context, dp: Float) = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.resources.displayMetrics) \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_send_black_24dp.xml b/android/app/src/main/res/drawable/ic_send_black_24dp.xml new file mode 100644 index 0000000..e145ca8 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_send_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 90b3c39..cef0973 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -12,10 +12,20 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + tools:listitem="@layout/fragment_shop_item" + tools:itemCount="5"/> + \ No newline at end of file diff --git a/android/app/src/main/res/layout/fragment_shop_item.xml b/android/app/src/main/res/layout/fragment_shop_item.xml new file mode 100644 index 0000000..93bb644 --- /dev/null +++ b/android/app/src/main/res/layout/fragment_shop_item.xml @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 69b2233..966d6b7 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -3,4 +3,7 @@ #008577 #00574B #D81B60 + #FFFFFF + #E0F2F1 + #33000000 diff --git a/robot_software/follow.py b/robot_software/follow.py new file mode 100644 index 0000000..b2dbc7d --- /dev/null +++ b/robot_software/follow.py @@ -0,0 +1,101 @@ +#! /usr/bin/env python3 +import ev3dev.ev3 as ev3 +import logging +from time import sleep + + +class FollowLine: + # From https://gist.github.com/CS2098/ecb3a078ed502c6a7d6e8d17dc095b48 + MOTOR_SPEED = 700 + KP = 20 + KD = 0.1 # derivative gain medium + KI = 0 # integral gain lowest + + DT = 50 # milliseconds - represents change in time since last sensor reading/movement + + # Constructor + def __init__(self): + self.btn = ev3.Button() + self.shut_down = False + + def onLine(self, sensor_value, position): + if position == 'left': + return sensor_value < 30 + if position == 'right': + return sensor_value < 40 + logging.error("onLine: wrong position value for sensor") + return False + + def correctTrajectory(self, csfl, csfr, lm, rm): + integral = 0 + previous_error = 0 + + while not self.shut_down: + lval = csfl.value() + rval = csfr.value() + error = lval - rval - 10 + logging.info("PID error: ", error) + integral += (error * self.DT) + derivative = (error - previous_error) / self.DT + + # u zero: on target, drive forward + # u positive: too bright, turn right + # u negative: too dark, turn left + # u is torque (See IVR lecture on Control) + u = (self.KP * error) + (self.KI * integral) + (self.KD* derivative) + + # limit u to safe values: [-1000, 1000] deg/sec + if self.MOTOR_SPEED + abs(u) > 1000: # reduce u if speed and torque are too high + if u >= 0: + u = 1000 - self.MOTOR_SPEED + else: + u = self.MOTOR_SPEED - 1000 + + # run motors + lm.run_timed(time_sp=self.DT, speed_sp=-(self.MOTOR_SPEED + u)) + rm.run_timed(time_sp=self.DT, speed_sp=-(self.MOTOR_SPEED - u)) + sleep(self.DT / 1000) + + + print("u {}".format(u)) + print("lm {}\n".format(lm.speed_sp)) + print("rm {}".format(rm.speed_sp)) + print("PID:", lval, rval) + + previous_error = error + + def run(self): + + # colour sensors + csfl = ev3.ColorSensor('in1') # colour sensor front left + csfr = ev3.ColorSensor('in2') # colour sensor front right + assert csfl.connected + assert csfr.connected + csfl.mode = 'COL-REFLECT' # measure light intensity + csfr.mode = 'COL-REFLECT' # measure light intensity + + # motors + lm = ev3.LargeMotor('outA') # left motor + rm = ev3.LargeMotor('outC') # right motor + assert lm.connected + assert rm.connected + self.correctTrajectory(csfl, csfr, lm, rm) + + """ + lm.run_forever(speed_sp=-self.MOTOR_SPEED) + rm.run_forever(speed_sp=-self.MOTOR_SPEED) + while not self.shut_down: + print(csfl.value(), csfr.value()) + if self.onLine(csfl.value(), 'left') or self.onLine(csfr.value(), 'right'): # left sensor is on the line + lm.stop() + rm.stop() + self.correctTrajectory(csfl, csfr, lm, rm) + """ + rm.stop() + lm.stop() + + +# Main function +if __name__ == "__main__": + robot = FollowLine() + robot.run() diff --git a/robot_software/follow.pyc b/robot_software/follow.pyc new file mode 100644 index 0000000..3ecc3d4 Binary files /dev/null and b/robot_software/follow.pyc differ diff --git a/server/db.js b/server/db.js index 6829375..37e4580 100644 --- a/server/db.js +++ b/server/db.js @@ -24,9 +24,9 @@ module.exports.init = () => { } db = client.db('bob') //FOR DEMO 1 - db.collection('bob_movement').insertOne({"_id":"movement","moving":false}, (err,doc) => { + db.collection('bob_movement').insertOne({ _id: 'movement', moving: false }, (err, doc) => { if (err) { - console.log("Movment already in database") + console.log('Movment already in database') } }) res(db) diff --git a/server/index.js b/server/index.js index d480b87..f8cfb8f 100644 --- a/server/index.js +++ b/server/index.js @@ -71,22 +71,39 @@ app.get('/jobs', (req, res, next) => { .catch(next) }) -app.put('/turnon', (req,res,next) => { +app.get('/items', (req, res, next) => { + model + .getItems() + .then(items => { + if (items) res.json({ success: true, items }) + else res.status(404).json({ success: true, items: null }) + }) + .catch(next) +}) + +app.post('/items', (req, res, next) => { + model + .addItem(req.body) + .then(item => res.json({ success: true, item })) + .catch(next) +}) + +app.put('/turnon', (req, res, next) => { model .turnOn() - .then(on => res.json({success: true, on})) + .then(on => res.json({ success: true, on })) .catch(next) }) -app.put('/turnoff', (req,res,next) => { +app.put('/turnoff', (req, res, next) => { model .turnOff() - .then(off => res.json({success: true, off})) + .then(off => res.json({ success: true, off })) .catch(next) }) -app.get('/getmovement', (req,res,next) => { +app.get('/getmovement', (req, res, next) => { model .getMovement() - .then(status => res.json({success:true, status})) + .then(status => res.json({ success: true, status })) .catch(next) }) @@ -104,7 +121,6 @@ app.use((err, req, res, next) => { }) app.listen(PORT, () => { - console.log(`Listening on port ${PORT}.`) - + console.log(`Listening on port ${PORT}.`) }) -bonjour.publish({ name: 'assis10t', type: 'http', port: PORT }) +bonjour.publish({ name: 'assis10t', type: 'http', host: utils.getIp(), port: PORT }) diff --git a/server/model.js b/server/model.js index 1293300..12a6098 100644 --- a/server/model.js +++ b/server/model.js @@ -20,58 +20,88 @@ const factory = db => ({ err ? rej(err) : res(docs[0]) }) }), - addOrder: orderData => new Promise((res, rej) => { db() .collection('orders') .insertOne(orderData, (err, order) => { - err ? rej(err) : res(orderData) + if (err) { + rej(err) + return + } + Promise.all(orderData.items.map(i => factory(db).removeItem(i))) + .then(() => res(orderData)) + .catch(err => rej(err)) }) }), - addJob: jobData => + addJob: jobData => new Promise((res, rej) => { db() .collection('jobs') - .insertOne(jobData, (err,job) => { + .insertOne(jobData, (err, job) => { err ? rej(err) : res(jobData) }) }), getAllJobs: () => - new Promise((res, rej) => { - db() - .collection('jobs') - .find({}) - .toArray((err, docs) => { - err ? rej(err) : res(docs) - }) + new Promise((res, rej) => { + db() + .collection('jobs') + .find({}) + .toArray((err, docs) => { + err ? rej(err) : res(docs) + }) }), turnOn: () => - new Promise((res,rej) => { + new Promise((res, rej) => { db() .collection('bob_movement') - .updateOne({"_id":"movement"}, {"$set":{"moving":true}}, (err, count_modified) => { + .updateOne({ _id: 'movement' }, { $set: { moving: true } }, (err, count_modified) => { err ? rej(err) : res('on') }) - }), + }), turnOff: () => - new Promise((res,rej) => { - db() - .collection('bob_movement') - .updateOne({"_id":"movement"}, {"$set":{"moving":false}}, (err, count_modified) => { - err ? rej(err) : res('off') - }) + new Promise((res, rej) => { + db() + .collection('bob_movement') + .updateOne({ _id: 'movement' }, { $set: { moving: false } }, (err, count_modified) => { + err ? rej(err) : res('off') + }) }), getMovement: () => new Promise((res, rej) => { db() .collection('bob_movement') .find({}) - .toArray((err,docs) => { + .toArray((err, docs) => { err ? rej(err) : res(docs[0]) }) + }), + + addItem: item => + new Promise((res, rej) => { + db() + .collection('inventory') + .insertOne({ _id: new ObjectID(), ...item }, (err, item) => { + err ? rej(err) : res(item) + }) + }), + removeItem: item => + new Promise((res, rej) => { + db() + .collection('inventory') + .deleteOne({ _id: new ObjectID(item._id) }, (err, item) => { + err ? rej(err) : res(item) + }) + }), + getItems: () => + new Promise((res, rej) => { + db() + .collection('inventory') + .find({}) + .toArray((err, items) => { + err ? rej(err) : res(items) + }) }) - }) module.exports = factory(db)