Merge branch 'v3-webapp' into 'v3'

Web app compatibility with v3 API

See merge request openflexure/openflexure-microscope-server!163
This commit is contained in:
Richard Bowman 2024-01-03 14:32:41 +00:00
commit 04321cac96
26 changed files with 868 additions and 1352 deletions

View file

@ -2,7 +2,7 @@
The "server" is the main component of the OpenFlexure Microscope's software. It is responsible for controlling microscope hardware, data management, and allowing it to be controlled locally and over a network. The "server" is the main component of the OpenFlexure Microscope's software. It is responsible for controlling microscope hardware, data management, and allowing it to be controlled locally and over a network.
This repository now includes the web client, which is served from the root of the Python web server. This repository now includes the web client, which is served from the root of the Python web server.
This software runs on [Python-LabThings](https://github.com/labthings/python-labthings/), and so most non-microscope functionality is handled by that library. This software runs on [LabThings-FastAPI](https://github.com/rwb27/labthings-fastapi/), and so most non-microscope functionality is handled by that library.
## Getting started ## Getting started
@ -62,18 +62,19 @@ To set up a development version of the software (most likely using emulated came
* `cd openflexure-microscope-server` * `cd openflexure-microscope-server`
### Set up the Python environment and run a test server ### Set up the Python environment and run a test server
* (Optional) Set local Python version to match what is available on the Pi * (Optional) Set local Python version to 3.11
* `pyenv init` (this may or may not be required, depending on how you installed `pyenv`)
* `pyenv install 3.7.3`
* `pyenv local 3.7.3`
* Create a virtual environment and activate it: * Create a virtual environment and activate it:
* `python -m venv .venv` * `python -m venv .venv`
* `source .venv/bin/activate` (on Linux) or `.venv/Scripts/activate` (on Windows) * `source .venv/bin/activate` (on Linux) or `.venv/Scripts/activate` (on Windows)
* `pip install --upgrade pip wheel pipenv` * `pip install -e .[dev]` (This will install development dependencies. If you don't need these, there is probably a simpler way to run the server than cloning this repo.)
* `pipenv install --dev` (This will install development dependencies. If you don't need these, `pipenv install` will get you just the dependencies needed to run the server, which takes about half the time.) * Finally, run the server: currently you can do this with `sudo systemctl start openflexure-microscope-server` if it's pre-installed on a Raspberry Pi, or `uvicorn --port 5000 openflexure_microscope_server.server:app` to run locally.
* Finally, run the server:
* You can use `ofm serve` or `ofm restart` on the Raspberry Pi to manage the server. ### Run the server manually on a Raspberry Pi
* To run the server locally, with dummy hardware, you can use `python -m openflexure_microscope.api.app` to start a development-mode Flask server on `localhost:5000` ```
cd /var/openflexure
sudo -u openflexure-ws PATH="/var/openflexure/application/openflexure-microscope-server/.venv/bin/:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" /var/openflexure/application/openflexure-microscope-server/.venv/bin/uvicorn --reload --reload-dir=./application --host 0.0.0.0 --port 5000 openflexure_microscope_server.server:app
```
### Set up the Javascript environment and build ### Set up the Javascript environment and build
* The Flask web application, written in Python, serves a web application written in `Vue.js`. This is distributed as part of the built version of the server, hosted on our [build server](https://build.openflexure.org/openflexure-microscope-server/). * The Flask web application, written in Python, serves a web application written in `Vue.js`. This is distributed as part of the built version of the server, hosted on our [build server](https://build.openflexure.org/openflexure-microscope-server/).

View file

@ -42,7 +42,6 @@
import appContent from "./components/appContent.vue"; import appContent from "./components/appContent.vue";
import loadingContent from "./components/loadingContent.vue"; import loadingContent from "./components/loadingContent.vue";
import axios from "axios";
var Mousetrap = require("mousetrap"); var Mousetrap = require("mousetrap");
Mousetrap.prototype.stopCallback = function(e, element) { Mousetrap.prototype.stopCallback = function(e, element) {
@ -305,24 +304,31 @@ export default {
}, },
methods: { methods: {
checkConnection: function() { async checkConnection() {
var baseUri = this.$store.getters.baseUri; var baseUri = this.$store.getters.baseUri;
this.$store.commit("changeWaiting", true); this.$store.commit("changeWaiting", true);
axios // TODO: more robust check - e.g. use a microscope Thing
// TODO: more robust check - e.g. use a microscope Thing // TODO: should we purge existing consumedThings?
.get(`${baseUri}/stage/`) try {
.then(() => { await this.$store.dispatch(
this.$store.commit("setConnected"); "wot/fetchThingDescriptions",
this.$store.commit("setErrorMessage", null); `${baseUri}/thing_descriptions/`
}) );
.catch(error => { for (let requiredThing of ["camera", "stage"]) {
this.$store.commit("setErrorMessage", error); if (!this.$store.getters["wot/thingAvailable"](requiredThing)) {
}) throw new Error(
.finally(() => { `No ${requiredThing} found, the GUI won't work without one.`
this.$store.commit("changeWaiting", false); );
}); }
}
this.$store.commit("setConnected");
this.$store.commit("setErrorMessage", null);
} catch (error) {
this.$store.commit("setErrorMessage", error);
} finally {
this.$store.commit("changeWaiting", false);
}
}, },
handleExit: function() { handleExit: function() {
this.$root.$emit("globalTogglePreview", false); this.$root.$emit("globalTogglePreview", false);
}, },

View file

@ -231,10 +231,6 @@ export default {
}, },
computed: { computed: {
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
},
pluginsGuiList: function() { pluginsGuiList: function() {
// List of plugin GUIs, obtained from this.plugins values // List of plugin GUIs, obtained from this.plugins values
var pluginGuis = []; var pluginGuis = [];
@ -267,34 +263,26 @@ export default {
icon: "visibility", icon: "visibility",
component: viewContent component: viewContent
}, },
{ /*{
id: "gallery", id: "gallery",
icon: "photo_library", icon: "photo_library",
component: galleryContent, component: galleryContent,
divide: true // Add a divider after this tab icon divide: true // Add a divider after this tab icon
}, },*/
{ {
id: "navigate", id: "navigate",
icon: "gamepad", icon: "gamepad",
component: navigateContent component: navigateContent
}, },
{ {
id: "capture",
icon: "camera_alt",
component: captureContent,
divide: true // Add a divider after this tab icon
}
];
if (!this.$store.state.galleryEnabled) {
tabs = tabs.filter(tab => tab.id != "gallery");
}
if (this.$store.state.IHIEnabled) {
tabs.push({
id: "slidescan", id: "slidescan",
icon: "settings_overscan", icon: "settings_overscan",
component: slideScanContent, component: slideScanContent,
divide: true divide: true
}); }
];
if (!this.$store.state.galleryEnabled) {
tabs = tabs.filter(tab => tab.id != "gallery");
} }
if (this.$store.state.imjoyEnabled) { if (this.$store.state.imjoyEnabled) {
tabs.push({ tabs.push({
@ -351,9 +339,9 @@ export default {
.catch(error => { .catch(error => {
this.modalError(error); // Let mixin handle error this.modalError(error); // Let mixin handle error
});*/ });*/
return new Promise((resolve) => { return new Promise(resolve => {
resolve({}); resolve({});
}); });
}, },
setTab: function(event, tab) { setTab: function(event, tab) {
if (!(this.currentTab == tab)) { if (!(this.currentTab == tab)) {

View file

@ -142,7 +142,10 @@ export default {
if (task.status == "pending" || task.status == "running") { if (task.status == "pending" || task.status == "running") {
this.taskStarted = true; this.taskStarted = true;
this.$emit("taskStarted", this.taskId); this.$emit("taskStarted", this.taskId);
this.startPolling(task.id, task.links.find(t => t.rel==self).href); this.startPolling(
task.id,
task.links.find(t => t.rel == self).href
);
} }
} }
}); });
@ -219,33 +222,32 @@ export default {
var checkCondition = (resolve, reject) => { var checkCondition = (resolve, reject) => {
// If the condition is met, we're done! // If the condition is met, we're done!
axios.get( axios
this.taskUrl, .get(this.taskUrl, { baseURL: this.$store.getters.baseUri })
{baseURL: this.$store.getters.baseUri} .then(response => {
).then(response => { var result = response.data.status;
var result = response.data.status; // If the task ends with success
// If the task ends with success if (result == "completed") {
if (result == "completed") { resolve(response.data);
resolve(response.data); }
} // If task ends with an error
// If task ends with an error else if (result == "error") {
else if (result == "error") { // Pass the error string back with reject
// Pass the error string back with reject
reject(new Error(response.data.output)); reject(new Error(response.data.output));
} }
// If task ends with termination // If task ends with termination
else if (result == "cancelled") { else if (result == "cancelled") {
// Pass a generic termination error back with reject // Pass a generic termination error back with reject
reject(new Error("Task cancelled")); reject(new Error("Task cancelled"));
} else { } else {
// Since the task is still running, we can update the progress bar // Since the task is still running, we can update the progress bar
this.progress = response.data.progress; this.progress = response.data.progress;
// Check again after timeout // Check again after timeout
setTimeout(checkCondition, interval, resolve, reject); setTimeout(checkCondition, interval, resolve, reject);
} }
}); });
}; };
return new Promise(checkCondition); return new Promise(checkCondition);

View file

@ -0,0 +1,238 @@
<template>
<div>
<label class="uk-form-label">{{ label }}</label>
<div v-if="dataType == 'number'" class="input-and-buttons-container">
<input
v-model="value"
class="uk-form-small numeric-setting-line-input"
type="number"
@focusin="focusIn"
@focusout="focusOut"
@keydown="keyDown"
/>
<a class="button-next-to-input" @click="readProperty">
<i class="material-icons">refresh</i>
</a>
</div>
<div v-if="dataType == 'number_array'" class="input-and-buttons-container">
<input
v-for="i in valueLength"
:key="i"
v-model="value[i - 1]"
class="uk-form-small numeric-setting-line-input"
type="number"
@focusin="focusIn"
@focusout="focusOut"
@keydown="keyDown"
/>
<a class="button-next-to-input" @click="readProperty">
<i class="material-icons">refresh</i>
</a>
</div>
<div v-if="dataType == 'number_object'" class="input-and-buttons-container">
<input
v-for="(_v, key) in value"
:key="key"
v-model="value[key]"
class="uk-form-small numeric-setting-line-input"
type="number"
@focusin="focusIn"
@focusout="focusOut"
@keydown="keyDown"
/>
<a class="button-next-to-input" @click="readProperty">
<i class="material-icons">refresh</i>
</a>
</div>
<div v-if="dataType == 'other'" class="input-and-buttons-container">
<input
:value="value"
class="uk-form-small numeric-setting-line-input"
type="text"
disabled="true"
/>
</div>
</div>
</template>
<script>
export default {
name: "PropertyControl",
props: {
label: {
type: String,
default: ""
},
propertyName: {
type: String,
required: true
},
thingName: {
type: String,
required: true
},
readBackDelay: {
type: Number,
default: undefined,
required: false
}
},
data: () => {
return {
value: undefined,
valueOnEnter: undefined,
focused: false
};
},
computed: {
valueLength: function() {
if (this.dataType == "number_array") {
if (this.value == undefined) {
return 0;
}
return this.value.length;
} else {
return 1;
}
},
readBack: function() {
return this.readBackDelay !== undefined;
},
propertyDescription: function() {
try {
return this.thingDescription(this.thingName).properties[
this.propertyName
];
} catch (error) {
return undefined;
}
},
dataType: function() {
let prop = this.propertyDescription;
if (prop == undefined) {
return "undefined";
}
const num_types = ["integer", "float", "number"];
if (num_types.includes(prop.type)) {
return "number";
}
if (prop.type == "array") {
if (num_types.includes(prop.items.type)) {
return "number_array";
}
if (Array.isArray(prop.items)) {
if (prop.items.every(t => num_types.includes(t.type))) {
return "number_array";
}
}
}
if (prop.type == "object") {
let numeric = true;
for (let key in prop.properties) {
if (!num_types.includes(prop.properties[key].type)) {
numeric = false;
break;
}
}
if (numeric) {
return "number_object";
}
}
return "other";
}
},
watch: {
propertyDescription: function() {
// Ensure we read the property once the URL is known
this.readProperty();
}
},
mounted: function() {
// Read the property when we're mounted - usually this won't
// work because the URL isn't set yet. However, it's helpful if
// the app is reloaded (e.g. from a dev server).
if (this.value == undefined) {
this.readProperty();
}
},
methods: {
readProperty: async function() {
let data = await this.readThingProperty(
this.thingName,
this.propertyName
);
this.value = data;
return data;
},
writeProperty: async function() {
try {
let requestedValue = this.value;
await this.writeThingProperty(
this.thingName,
this.propertyName,
requestedValue
);
if (this.readBack) {
await new Promise(r => setTimeout(r, this.readBackDelay));
let newVal = await this.readProperty();
if (newVal == requestedValue) {
await this.modalNotify(`Set ${this.label} to ${newVal}.`);
} else {
await this.modalNotify(
`Set ${this.label} to ${newVal} (requested ${requestedValue}).`
);
}
} else {
await this.modalNotify(`Set ${this.label} to ${this.value}.`);
}
} catch (error) {
this.modalError(error); // Let mixin handle error
}
},
focusIn: function(event) {
this.valueOnEnter = event.target.value;
},
focusOut: function(event) {
if (this.valueOnEnter != event.target.value) {
this.writeProperty(event.target.value);
}
},
keyDown: function(event) {
// Pressing enter should set the property, whether or not we think it's changed.
if (event.keyCode == 13) {
this.writeProperty();
}
}
}
};
</script>
<style scoped>
.input-and-buttons-container {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-content: stretch;
align-items: center;
width: 100%;
}
.numeric-setting-line-input {
flex-grow: 1;
margin-left: 5px;
margin-right: 5px;
width: 6em;
}
.button-next-to-input {
flex-grow: 0;
padding-left: 5px;
padding-right: 5px;
vertical-align: middle;
cursor: pointer;
}
</style>

View file

@ -48,6 +48,7 @@
<cameraCalibrationSettings <cameraCalibrationSettings
:show-extra-settings="false" :show-extra-settings="false"
:camera-uri="cameraUri"
></cameraCalibrationSettings> ></cameraCalibrationSettings>
</div> </div>
</div> </div>
@ -146,8 +147,6 @@
</template> </template>
<script> <script>
import axios from "axios";
import cameraCalibrationSettings from "../tabContentComponents/settingsComponents/cameraSettingsComponents/cameraCalibrationSettings.vue"; import cameraCalibrationSettings from "../tabContentComponents/settingsComponents/cameraSettingsComponents/cameraCalibrationSettings.vue";
import CSMCalibrationSettings from "../tabContentComponents/settingsComponents/CSMSettingsComponents/CSMCalibrationSettings.vue"; import CSMCalibrationSettings from "../tabContentComponents/settingsComponents/CSMSettingsComponents/CSMCalibrationSettings.vue";
import miniStreamDisplay from "../genericComponents/miniStreamDisplay.vue"; import miniStreamDisplay from "../genericComponents/miniStreamDisplay.vue";
@ -172,62 +171,29 @@ export default {
return { return {
ready: false, ready: false,
stepValue: 0, stepValue: 0,
settings: {}, isCSMCalibrated: undefined,
config: {} isLSTCalibrated: undefined
}; };
}, },
computed: { computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
},
configUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/configuration`;
},
availablePluginTitles: function() {
return this.availablePlugins.map(a => a.title);
},
isCSMCalibrated: function() {
if (this.settings.extensions["org.openflexure.camera_stage_mapping"]) {
return true;
} else {
return false;
}
},
isLSTCalibrated: function() {
if (
this.settings.camera.picamera &&
this.settings.camera.picamera.lens_shading_table
) {
return true;
} else {
return false;
}
},
canCSMCalibrated: function() { canCSMCalibrated: function() {
// Assert CSM extension is enabled // Assert CSM extension is enabled
var extensionEnabled = this.availablePluginTitles.includes( return this.thingAvailable("camera_stage_mapping");
"org.openflexure.camera_stage_mapping"
);
// Assert real stage is connected
var stageConnected = this.config.stage.type !== "MissingStage";
// Combine
return stageConnected && extensionEnabled;
}, },
canLSTCalibrated: function() { canLSTCalibrated: function() {
// Assert LST extension is enabled // Assert LST extension is enabled
var extensionEnabled = this.availablePluginTitles.includes( return (
"org.openflexure.calibration.picamera" "calibrate_lens_shading" in this.thingDescription("camera").actions
); );
// Assert real camera is connected
var cameraConnected = this.config.camera.type !== "MissingCamera";
// Combine
return cameraConnected && extensionEnabled;
}, },
isUseful: function() { isUseful: function() {
var CSMUseful = this.canCSMCalibrated && !this.isCSMCalibrated; var CSMUseful = this.canCSMCalibrated && !this.isCSMCalibrated;
var LSTUseful = this.canLSTCalibrated && !this.isLSTCalibrated; var LSTUseful = this.canLSTCalibrated && !this.isLSTCalibrated;
return CSMUseful || LSTUseful; return CSMUseful || LSTUseful;
},
cameraUri: function() {
return `${this.$store.getters.baseUri}/camera/`;
} }
}, },
@ -236,25 +202,33 @@ export default {
}, },
methods: { methods: {
show: function() { show: async function() {
// Get current settings // Check if the camera and stage are calibrated, if they can be
this.getSettings() if (this.canCSMCalibrated) {
.then(() => { let csm = await this.readThingProperty(
return this.getConfig(); "camera_stage_mapping",
}) "image_to_stage_displacement_matrix",
.then(() => { true
// Check if this calibration wizard can actually do anything useful );
if (this.isUseful) { this.isCSMCalibrated = Boolean(csm);
this.ready = true; }
this.stepValue = 0; if (this.canLSTCalibrated) {
// Show the modal this.isLSTCalibrated = await this.readThingProperty(
var el = this.$refs["calibrationModalEl"]; "camera",
this.showModalElement(el); // Calls the mixin "lens_shading_is_static"
} else { );
// If not useful, we just return the onClose event immediately }
this.onHide(); // Check if this calibration wizard can actually do anything useful
} if (this.isUseful) {
}); this.ready = true;
this.stepValue = 0;
// Show the modal
var el = this.$refs["calibrationModalEl"];
this.showModalElement(el); // Calls the mixin
} else {
// If not useful, we just return the onClose event immediately
this.onHide();
}
}, },
hide: function() { hide: function() {
@ -268,28 +242,6 @@ export default {
this.$emit("onClose"); this.$emit("onClose");
}, },
getSettings: function() {
return axios
.get(this.settingsUri)
.then(response => {
this.settings = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
getConfig: function() {
return axios
.get(this.configUri)
.then(response => {
this.config = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
decrement: function() { decrement: function() {
if (this.stepValue > 0) { if (this.stepValue > 0) {
this.stepValue = this.stepValue - 1; this.stepValue = this.stepValue - 1;

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="host-input"> <div class="host-input">
<div v-if="configuration"> <div v-if="$store.state.available">
<div> <div>
<div class="uk-margin-small-bottom"> <div class="uk-margin-small-bottom">
<b>API origin:</b> <b>API origin:</b>
@ -11,13 +11,9 @@
<hr /> <hr />
<div v-if="settings">
<b>Device name:</b> <br />
{{ settings.name }}
</div>
<div> <div>
<b>Server version:</b> <br /> <b>Server version:</b> <br />
{{ configuration.application.version }} TODO
</div> </div>
<hr /> <hr />
@ -25,18 +21,18 @@
<div class="uk-margin-small-bottom"> <div class="uk-margin-small-bottom">
<b>Camera:</b> <b>Camera:</b>
<br /> <br />
<div v-if="configuration.camera.type != 'MissingCamera'"> <div v-if="'camera' in things">
{{ configuration.camera.type }} {{ things.camera.title }}
</div> </div>
<div v-else class="uk-text-danger"><b>No camera connected</b></div> <div v-else class="uk-text-danger"><b>No camera configured</b></div>
</div> </div>
<div> <div>
<b>Stage:</b> <b>Stage:</b>
<br /> <br />
<div v-if="configuration.stage.type != 'MissingStage'"> <div v-if="'stage' in things">
{{ configuration.stage.type }} {{ things.stage.title }}
</div> </div>
<div v-else class="uk-text-danger"><b>No stage connected</b></div> <div v-else class="uk-text-danger"><b>No stage configured</b></div>
</div> </div>
<hr /> <hr />
@ -44,9 +40,9 @@
<div class="uk-grid-small uk-child-width-1-2" uk-grid> <div class="uk-grid-small uk-child-width-1-2" uk-grid>
<div> <div>
<button <button
v-show="'shutdown' in systemActionLinks" v-show="'shutdown' in things.system_control.actions"
class="uk-button uk-button-danger uk-float-right uk-margin uk-margin-remove-top uk-width-1-1" class="uk-button uk-button-danger uk-float-right uk-margin uk-margin-remove-top uk-width-1-1"
@click="shutdownRequest" @click="systemRequest('shutdown')"
> >
Shutdown Shutdown
</button> </button>
@ -54,9 +50,9 @@
<div> <div>
<button <button
v-show="'reboot' in systemActionLinks" v-show="'reboot' in things.system_control.actions"
class="uk-button uk-button-danger uk-float-right uk-margin uk-margin-remove-top uk-width-1-1" class="uk-button uk-button-danger uk-float-right uk-margin uk-margin-remove-top uk-width-1-1"
@click="rebootRequest" @click="systemRequest('reboot')"
> >
Restart Restart
</button> </button>
@ -79,103 +75,22 @@ import axios from "axios";
export default { export default {
name: "StatusPane", name: "StatusPane",
components: {},
data: function() {
return {
configuration: null,
settings: null,
systemActionLinks: {}
};
},
computed: { computed: {
settingsUri: function() { things: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`; return this.$store.getters["wot/thingDescriptions"];
},
configurationUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/configuration`;
},
rootUri: function() {
return `${this.$store.getters.baseUri}/api/v2`;
} }
}, },
mounted: function() {
// Watch for host 'ready', then update configuration
this.updateConfiguration();
this.updateSettings();
this.updateSystemActions();
},
methods: { methods: {
updateConfiguration: function() { systemRequest: function(action) {
axios
.get(this.configurationUri)
.then(response => {
this.configuration = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateSettings: function() {
axios
.get(this.settingsUri)
.then(response => {
this.settings = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateSystemActions: function() {
axios
.get(this.rootUri)
.then(response => {
if ("RebootAPI" in response.data.actions) {
this.$set(
this.systemActionLinks,
"reboot",
`${this.$store.getters.baseUri}/api/v2/actions/system/reboot/`
);
} else {
delete this.systemActionLinks.reboot;
}
if ("ShutdownAPI" in response.data.actions) {
this.$set(
this.systemActionLinks,
"shutdown",
`${this.$store.getters.baseUri}/api/v2/actions/system/shutdown/`
);
} else {
delete this.systemActionLinks.shutdown;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
shutdownRequest: function() {
this.modalConfirm("Shut down microscope?").then(
() => {
if ("shutdown" in this.systemActionLinks) {
this.$store.commit("resetState");
// Post and silence errors
axios.post(this.systemActionLinks.shutdown).catch(() => {});
}
},
() => {}
);
},
rebootRequest: function() {
this.modalConfirm("Restart microscope?").then( this.modalConfirm("Restart microscope?").then(
() => { () => {
if ("reboot" in this.systemActionLinks) { this.$store.commit("resetState");
this.$store.commit("resetState"); this.$store.commit("wot/deleteAllThingDescriptions");
// Post and silence errors // Post and silence errors
axios.post(this.systemActionLinks.reboot).catch(() => {}); axios
} .post(this.thingActionUrl("system_control", action))
.catch(() => {});
}, },
() => {} () => {}
); );

View file

@ -10,8 +10,9 @@
class="uk-link" class="uk-link"
target="_blank" target="_blank"
href="https://gitlab.com/openflexure/openflexure-microscope-server/-/issues" href="https://gitlab.com/openflexure/openflexure-microscope-server/-/issues"
>Report an issue</a
> >
Report an issue
</a>
</div> </div>
<div class="uk-padding-small"> <div class="uk-padding-small">
<h2>Developer tools</h2> <h2>Developer tools</h2>

View file

@ -102,7 +102,7 @@
<ul uk-accordion="multiple: true"> <ul uk-accordion="multiple: true">
<!--Show stack and scan if scan plugin is enabled--> <!--Show stack and scan if scan plugin is enabled-->
<li v-if="scanUri" :class="{ 'uk-open': scanCapture }"> <li v-if="thingAvailable('scan')" :class="{ 'uk-open': scanCapture }">
<a class="uk-accordion-title" href="#">Stack and Scan</a> <a class="uk-accordion-title" href="#">Stack and Scan</a>
<div class="uk-accordion-content"> <div class="uk-accordion-content">
<div class="uk-margin"> <div class="uk-margin">
@ -221,7 +221,10 @@
</select> </select>
</div> </div>
<div class="uk-margin-small uk-margin-remove-bottom" v-if="backgroundDetectUri"> <div
v-if="thingAvailable('background_detect')"
class="uk-margin-small uk-margin-remove-bottom"
>
<label class="uk-form-label" for="form-stacked-text"> <label class="uk-form-label" for="form-stacked-text">
<input <input
v-model="detectEmptyFieldsAndSkipAutofocus" v-model="detectEmptyFieldsAndSkipAutofocus"
@ -256,7 +259,10 @@
</div> </div>
</li> </li>
<!--Show stack and scan if scan plugin is enabled--> <!--Show stack and scan if scan plugin is enabled-->
<li v-if="smartScanUri" :class="{ 'uk-open': smartStack && scanCapture }"> <li
v-if="thingAvailable('smart_scan')"
:class="{ 'uk-open': smartStack && scanCapture }"
>
<a class="uk-accordion-title" href="#">Smart Stack</a> <a class="uk-accordion-title" href="#">Smart Stack</a>
<div <div
class="uk-accordion-content" class="uk-accordion-content"
@ -401,10 +407,7 @@ export default {
data: function() { data: function() {
return { return {
...defaultCaptureSettings(), ...defaultCaptureSettings(),
scanCapture: false, // Don't remember the "scan" tickbox in local storage. scanCapture: false // Don't remember the "scan" tickbox in local storage.
scanUri: null,
smartScanUri: null,
backgroundDetectUri: null
}; };
}, },
@ -415,10 +418,13 @@ export default {
}; };
}, },
captureActionUri: function() { captureActionUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions/camera/capture`; return this.thingActionUrl("camera", "capture");
}, },
pluginsUri: function() { scanUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`; return this.thingActionUrl("scan", "scan");
},
smartScanUri: function() {
return this.thingActionUrl("scan", "scan");
}, },
basePayload: function() { basePayload: function() {
var payload = {}; var payload = {};
@ -475,7 +481,8 @@ export default {
namemode: this.namingStyle.toLowerCase(), namemode: this.namingStyle.toLowerCase(),
autofocus_dz: afDeltas[this.scanDeltaZ], autofocus_dz: afDeltas[this.scanDeltaZ],
fast_autofocus: this.scanDeltaZ == "Fast", fast_autofocus: this.scanDeltaZ == "Fast",
detect_empty_fields_and_skip_autofocus: this.detectEmptyFieldsAndSkipAutofocus detect_empty_fields_and_skip_autofocus: this
.detectEmptyFieldsAndSkipAutofocus
}; };
}, },
smartScanPayload: function() { smartScanPayload: function() {
@ -493,7 +500,6 @@ export default {
}, },
mounted() { mounted() {
this.updateScanUri();
// Load settings if they have been saved, and set up watchers to sync with local storage // Load settings if they have been saved, and set up watchers to sync with local storage
syncDataWithLocalStorage("captureSettings", this, defaultCaptureSettings()); syncDataWithLocalStorage("captureSettings", this, defaultCaptureSettings());
@ -521,61 +527,6 @@ export default {
}); });
}, },
updateScanUri: function() {
// Update URIs for the various functions in plugins.
// This will only work if the plugins are present, so
// checking if the URIs is null will determine which
// extensions are available.
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.scan"
);
// if ScanPlugin is enabled
if (foundExtension) {
// Get plugin action link
this.scanUri = foundExtension.links.tile.href;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.smart-stack"
);
if (foundExtension) {
// Get plugin action link
this.smartScanUri = foundExtension.links.tile.href;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.background-detect"
);
if (foundExtension) {
// Get plugin action link
this.backgroundDetectUri = foundExtension.links.grab_and_classify_image.href;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onScanResponse: function() { onScanResponse: function() {
this.modalNotify("Finished scan."); this.modalNotify("Finished scan.");
// Emit signal to update capture list // Emit signal to update capture list

View file

@ -56,12 +56,6 @@ export default {
}; };
}, },
computed: {
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
}
},
mounted: function() { mounted: function() {
this.updateZipperUri(); this.updateZipperUri();
}, },
@ -84,7 +78,7 @@ export default {
methods: { methods: {
updateZipperUri: function() { updateZipperUri: function() {
if (this.$store.state.available) { if (this.$store.state.available) {
axios /*axios
.get(this.pluginsUri) // Get a list of plugins .get(this.pluginsUri) // Get a list of plugins
.then(response => { .then(response => {
var plugins = response.data; var plugins = response.data;
@ -100,7 +94,7 @@ export default {
}) })
.catch(error => { .catch(error => {
this.modalError(error); // Let mixin handle error this.modalError(error); // Let mixin handle error
}); });*/
} else { } else {
this.zipBuilderUri = null; this.zipBuilderUri = null;
this.zipGetterUri = null; this.zipGetterUri = null;

View file

@ -42,15 +42,15 @@
<div class="uk-width-xlarge uk-align-center"> <div class="uk-width-xlarge uk-align-center">
<div <div
v-for="item in pagedItems" v-for="item in pagedItems"
:key="item.timestamp" :key="item.sequence"
uk-alert uk-alert
class="logging-entry" class="logging-entry"
:class="{ :class="{
'uk-alert-warning uk-alert': item.data.levelname == 'WARNING', 'uk-alert-warning uk-alert': item.level == 'WARNING',
'uk-alert-danger uk-alert': item.data.levelname == 'ERROR' 'uk-alert-danger uk-alert': item.level == 'ERROR'
}" }"
> >
<b>{{ formatDateTime(item.data.created) }}</b> <b>{{ formatDateTime(item.timestamp) }}</b>
<div class="logging-message">{{ formatMessage(item) }}</div> <div class="logging-message">{{ formatMessage(item) }}</div>
</div> </div>
@ -102,18 +102,15 @@ export default {
var items = []; var items = [];
for (var item of this.logs) { for (var item of this.logs) {
// Add to capture list if matched // Add to capture list if matched
if (this.filteredLevels.includes(item.data.levelname)) { if (this.filteredLevels.includes(item.level)) {
items.push(item); items.push(item);
} }
} }
return items; return items;
}, },
loggingUri: function() {
return `${this.$store.getters.baseUri}/api/v2/events/logging`;
},
logFileURI: function() { logFileURI: function() {
return `${this.$store.getters.baseUri}/api/v2/log`; return `${this.$store.getters.baseUri}/log/`;
}, },
pagedItems: function() { pagedItems: function() {
let startIndex = (this.page - 1) * this.maxitems; let startIndex = (this.page - 1) * this.maxitems;
@ -124,11 +121,6 @@ export default {
} }
}, },
mounted() {
// Update on mount (does nothing if not connected)
this.updateLogs();
},
methods: { methods: {
scrollToTop() { scrollToTop() {
const el = document.querySelector("#container-left"); const el = document.querySelector("#container-left");
@ -141,22 +133,30 @@ export default {
this.updateLogs(); this.updateLogs();
} }
}, },
updateLogs: function() { async updateLogs() {
axios let response = await axios.get(this.logFileURI);
.get(this.loggingUri) let lines = response.data.split("\n").reverse();
.then(response => { this.logs = [];
this.logs = response.data.reverse(); let regexp = /\[(.+)\] \[(.+)\] (.*)$/;
}) for (let line of lines) {
.catch(error => { if (line.length > 0) {
this.modalError(error); // Let mixin handle error let m = line.match(regexp);
}); this.logs.push({
timestamp: m[1],
level: m[2],
message: m[3],
sequence: this.logs.length
});
}
}
}, },
formatDateTime: function(isoDateTimeString) { formatDateTime: function(isoDateTimeString) {
isoDateTimeString = isoDateTimeString.replace(",", ".");
let date = new Date(isoDateTimeString); let date = new Date(isoDateTimeString);
return date.toLocaleDateString() + " " + date.toLocaleTimeString(); return date.toLocaleDateString() + " " + date.toLocaleTimeString();
}, },
formatMessage: function(item) { formatMessage: function(item) {
return item.data.levelname + ": " + item.data.message; return item.level + ": " + item.message;
} }
} }
}; };

View file

@ -68,66 +68,46 @@
</div> </div>
</div> </div>
<button <taskSubmitter
class="uk-button uk-button-default uk-margin uk-width-1-1" :submit-url="zeroActionUri"
@click="zeroRequest()" :submit-label="'Zero Coordinates'"
> :canTerminate="false"
Zero coordinates @finished="updatePosition"
</button> @error="modalError"
></taskSubmitter>
</div> </div>
</li> </li>
<li class="uk-open"> <li class="uk-open">
<a class="uk-accordion-title" href="#">Move-to</a> <a class="uk-accordion-title" href="#">Position</a>
<div class="uk-accordion-content"> <div class="uk-accordion-content">
<form @submit.prevent="handleSubmit"> <form>
<!-- Text boxes to set and view position --> <!-- Text boxes to set and view position -->
<div class="input-and-buttons-container">
<div class="uk-grid-small uk-child-width-1-3" uk-grid> <input
<div> v-for="(_v, key) in setPosition"
<label class="uk-form-label" for="form-stacked-text">x</label> :key="`setPosition_${key}`"
<div class="uk-form-controls"> v-model="setPosition[key]"
<input class="uk-form-small numeric-setting-line-input"
v-model="setPosition.x" type="number"
class="uk-input uk-form-small" @keyup.enter="startMoveTask"
type="number" />
name="inputPositionX" <a class="button-next-to-input" @click="updatePosition">
/> <i class="material-icons">refresh</i>
</div> </a>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text">y</label>
<div class="uk-form-controls">
<input
v-model="setPosition.y"
class="uk-input uk-form-small"
type="number"
name="inputPositionY"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text">z</label>
<div class="uk-form-controls">
<input
v-model="setPosition.z"
class="uk-input uk-form-small"
type="number"
name="inputPositionZx"
/>
</div>
</div>
</div> </div>
<p> <p>
<button <taskSubmitter
type="submit" ref="moveTaskSubmitter"
class="uk-button uk-button-default uk-float-right uk-width-1-1" :submit-url="absoluteMoveUri"
> :submit-data="setPosition"
Move :submit-label="'Move'"
</button> :canTerminate="false"
:pollInterval="0.05"
@taskStarted="moveLock = true"
@finished="moveLock = false"
@error="modalError"
></taskSubmitter>
</p> </p>
</form> </form>
</div> </div>
@ -143,7 +123,7 @@
v-if="fastAutofocusUri" v-if="fastAutofocusUri"
:submit-url="fastAutofocusUri" :submit-url="fastAutofocusUri"
:submit-data="{ dz: 2000 }" :submit-data="{ dz: 2000 }"
:submit-label="'Fast'" :submit-label="'Autofocus'"
:button-primary="false" :button-primary="false"
:submit-on-event="'globalFastAutofocusEvent'" :submit-on-event="'globalFastAutofocusEvent'"
@taskStarted="isAutofocusing = 1" @taskStarted="isAutofocusing = 1"
@ -151,32 +131,34 @@
@error="modalError" @error="modalError"
></taskSubmitter> ></taskSubmitter>
</div> </div>
</div>
</div>
</li>
<li v-show="captureUri" class="uk-open">
<a class="uk-accordion-title" href="#">Image Capture</a>
<div class="uk-accordion-content">
<div class="uk-grid-small uk-child-width-expand" uk-grid>
<taskSubmitter
v-if="captureUri"
:submit-url="captureUri"
:submit-data="{ resolution: 'main' }"
:submit-label="'Low Resolution'"
:submit-on-event="'globalCaptureEvent'"
@response="handleCaptureResponse"
@error="modalError"
></taskSubmitter>
</div>
<div v-show="!isAutofocusing || isAutofocusing == 2"> <div class="uk-grid-small uk-child-width-expand" uk-grid>
<taskSubmitter <taskSubmitter
v-if="normalAutofocusUri" v-if="captureUri"
:submit-url="normalAutofocusUri" :submit-url="captureUri"
:submit-data="{ dz: [-90, -60, -30, 0, 30, 60, 90] }" :submit-data="{ resolution: 'full' }"
:submit-label="'Medium'" submit-label="Full Resolution"
:button-primary="false" submit-on-event="globalCaptureEvent"
@taskStarted="isAutofocusing = 2" @response="handleCaptureResponse"
@finished="isAutofocusing = 0" @error="modalError"
@error="modalError" ></taskSubmitter>
></taskSubmitter>
</div>
<div v-show="!isAutofocusing || isAutofocusing == 3">
<taskSubmitter
v-if="normalAutofocusUri"
:submit-url="normalAutofocusUri"
:submit-data="{ dz: [-30, -20, -10, 0, 10, 20, 30] }"
:submit-label="'Fine'"
:button-primary="false"
@taskStarted="isAutofocusing = 3"
@finished="isAutofocusing = 0"
@error="modalError"
></taskSubmitter>
</div>
</div> </div>
</div> </div>
</li> </li>
@ -220,10 +202,7 @@ export default {
}, },
setPosition: null, setPosition: null,
isAutofocusing: 0, isAutofocusing: 0,
moveLock: false, moveLock: false
fastAutofocusUri: null,
normalAutofocusUri: null,
moveInImageCoordinatesUri: null
}; };
}, },
@ -231,17 +210,26 @@ export default {
baseUri: function() { baseUri: function() {
return this.$store.getters.baseUri; return this.$store.getters.baseUri;
}, },
moveActionUri: function() { absoluteMoveUri: function() {
return `${this.$store.getters.baseUri}/stage/move_relative`; return this.thingActionUrl("stage", "move_absolute");
}, },
zeroActionUri: function() { zeroActionUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions/stage/zero`; return this.thingActionUrl("stage", "set_zero_position");
}, },
positionStatusUri: function() { positionStatusUri: function() {
return `${this.baseUri}/stage/position`; return `${this.baseUri}/stage/position`;
}, },
pluginsUri: function() { fastAutofocusUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`; return this.thingActionUrl("autofocus", "fast_autofocus");
},
captureUri: function() {
return this.thingActionUrl("camera", "capture_jpeg");
},
moveInImageCoordinatesUri: function() {
return this.thingActionUrl(
"camera_stage_mapping",
"move_in_image_coordinates"
);
} }
}, },
@ -265,17 +253,17 @@ export default {
this.stepSize = this.stepSize =
this.getLocalStorageObj("navigation_stepSize") || this.stepSize; this.getLocalStorageObj("navigation_stepSize") || this.stepSize;
this.invert = this.getLocalStorageObj("navigation_invert") || this.invert; this.invert = this.getLocalStorageObj("navigation_invert") || this.invert;
let self = this;
// A global signal listener to perform a move action // A global signal listener to perform a move action
this.$root.$on("globalMoveEvent", (x, y, z, absolute) => { this.$root.$on("globalMoveEvent", self.move);
this.moveRequest(x, y, z, absolute);
});
// A global signal listener to perform a move action in pixels // A global signal listener to perform a move action in pixels
this.$root.$on("globalMoveInImageCoordinatesEvent", (x, y, absolute) => { this.$root.$on("globalMoveInImageCoordinatesEvent", (x, y, absolute) => {
this.moveInImageCoordinatesRequest(x, y, absolute); this.moveInImageCoordinatesRequest(x, y, absolute);
}); });
// A global signal listener to perform a move in multiples of a step size // A global signal listener to perform a move in multiples of a step size
this.$root.$on("globalMoveStepEvent", (x_steps, y_steps, z_steps) => { this.$root.$on("globalMoveStepEvent", (x_steps, y_steps, z_steps) => {
this.moveRequest( this.$root.$emit(
"globalMoveEvent",
x_steps * this.stepSize.x * (this.invert.x ? -1 : 1), x_steps * this.stepSize.x * (this.invert.x ? -1 : 1),
y_steps * this.stepSize.y * (this.invert.y ? -1 : 1), y_steps * this.stepSize.y * (this.invert.y ? -1 : 1),
z_steps * this.stepSize.z * (this.invert.z ? -1 : 1), z_steps * this.stepSize.z * (this.invert.z ? -1 : 1),
@ -284,9 +272,6 @@ export default {
}); });
// Update the current position in text boxes // Update the current position in text boxes
this.updatePosition(); this.updatePosition();
// Look for autofocus plugin
this.updateAutofocusUri();
this.updateMoveInImageCoordinatesUri();
}, },
beforeDestroy() { beforeDestroy() {
@ -297,40 +282,32 @@ export default {
}, },
methods: { methods: {
handleSubmit: function() { timeout(ms) {
this.moveRequest( return new Promise(resolve => setTimeout(resolve, ms));
this.setPosition.x,
this.setPosition.y,
this.setPosition.z,
true
);
}, },
async move(x, y, z, absolute) {
moveRequest: function(x, y, z, absolute) { // Move the stage, by updating the controls and starting a move task
// If not movement-locked // This is equivalent to clicking the "move" button.
if (!this.moveLock) { if (this.moveLock) return; // Discard move requests if we're already moving
// Lock move requests // NB moveLock is just boolean flag - it's not as safe as a "proper" lock.
this.moveLock = true; this.moveLock = true; // This will also be set by the task submitter, but
let move_type = absolute ? "absolute" : "relative"; // setting it here avoids multiple moves being requested simultaneously.
// Send move request if (absolute){
axios this.setPosition = {"x": x, "y": y, "z": z}
.post(`${this.baseUri}/stage/move_${move_type}`, { }else{
x: x, await this.updatePosition();
y: y, this.setPosition = {
z: z "x": this.setPosition.x + x,
}) "y": this.setPosition.y + y,
.then(() => { "z": this.setPosition.z + z
this.updatePosition(); // Update the position in text boxes }
})
.catch(error => {
this.modalError(error); // Let mixin handle error
})
.then(() => {
this.moveLock = false; // Release the move lock
});
} }
await this.timeout(1); // Wait for Vue to update the position
await this.startMoveTask();
},
async startMoveTask() {
await this.$refs.moveTaskSubmitter.startTask();
}, },
moveInImageCoordinatesRequest: function(x, y) { moveInImageCoordinatesRequest: function(x, y) {
// If not movement-locked // If not movement-locked
if (!this.moveLock) { if (!this.moveLock) {
@ -373,24 +350,58 @@ export default {
}); });
}, },
updatePosition: function() { async updatePosition() {
axios this.setPosition = await this.readThingProperty("stage", "position")
.get(this.positionStatusUri)
.then(response => {
this.setPosition = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}, },
updateAutofocusUri: function() { handleCaptureResponse: async function(response) {
this.fastAutofocusUri = `${this.baseUri}/autofocus/fast_autofocus`; // Retrieve the captured image and save it
}, let imageUri = response.output.href;
if (!imageUri) {
updateMoveInImageCoordinatesUri: function() { this.modalError("No image URI returned from capture task.");
this.moveInImageCoordinatesUri = `${this.baseUri}/camera_stage_mapping/move_in_image_coordinates`; console.log(`Capture resulted in response ${response}`);
return;
}
// To save the returned data, we make a virtual link and click it
let imageResponse = await axios.get(imageUri, { responseType: "blob" });
const url = window.URL.createObjectURL(new Blob([imageResponse.data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `OFM_${new Date().toISOString()}.jpeg`);
document.body.appendChild(link);
link.click();
} }
} }
}; };
</script> </script>
<style scoped>
.input-and-buttons-container {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-content: stretch;
align-items: center;
width: 100%;
}
.numeric-setting-line-input {
flex-grow: 1;
margin-left: 5px;
margin-right: 5px;
width: 3em;
}
/* Chrome, Safari, Edge, Opera */
.numeric-setting-line-input::-webkit-outer-spin-button,
.numeric-setting-line-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.button-next-to-input {
flex-grow: 0;
padding-left: 5px;
padding-right: 5px;
vertical-align: middle;
cursor: pointer;
}
</style>

View file

@ -20,7 +20,6 @@
</template> </template>
<script> <script>
import axios from "axios";
import CSMCalibrationSettings from "./CSMSettingsComponents/CSMCalibrationSettings.vue"; import CSMCalibrationSettings from "./CSMSettingsComponents/CSMCalibrationSettings.vue";
import miniStreamDisplay from "../../genericComponents/miniStreamDisplay.vue"; import miniStreamDisplay from "../../genericComponents/miniStreamDisplay.vue";
@ -31,122 +30,6 @@ export default {
components: { components: {
CSMCalibrationSettings, CSMCalibrationSettings,
miniStreamDisplay miniStreamDisplay
},
props: {
showExtraSettings: {
type: Boolean,
required: false,
default: true
}
},
data: function() {
return {
settings: null,
recalibrationLinks: {},
isCalibrating: false,
dataAvailable: false
};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
},
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
}
},
mounted() {
this.updateSettings();
this.updateRecalibrationLinks();
},
methods: {
updateSettings: function() {
// Update links
axios
.get(this.settingsUri)
.then(response => {
this.settings =
response.data.extensions["org.openflexure.camera_stage_mapping"];
})
.catch(error => {
this.modalError(error); // Let mixin handle error
this.settings = {};
});
},
updateCalibrationDataAvailability: function() {
if ("get_calibration" in this.recalibrationLinks) {
axios
.get(this.recalibrationLinks.get_calibration.href)
.then(response => {
if (Object.keys(response.data).length === 0) {
this.dataAvailable = false;
} else {
this.dataAvailable = true;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}
},
getCalibrationData: function() {
axios
.get(this.recalibrationLinks.get_calibration.href)
.then(response => {
if (response.data != {}) {
const data = JSON.stringify(response.data);
const url = window.URL.createObjectURL(new Blob([data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "csm_calibration.json");
document.body.appendChild(link);
link.click();
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateRecalibrationLinks: function() {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.camera_stage_mapping"
);
// if camera-stage mapping extension is enabled
if (foundExtension) {
// Get plugin action link
this.recalibrationLinks = foundExtension.links;
// Update whether calibration data is available
this.updateCalibrationDataAvailability();
} else {
this.recalibrationLinks = {};
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onRecalibrateResponse: function() {
this.modalNotify("Finished stage-to-camera calibration.");
// Update local settings
this.updateSettings();
},
onRecalibrateError: function(error) {
this.modalError(error); // Let mixin handle error
}
} }
}; };
</script> </script>

View file

@ -1,7 +1,7 @@
<template> <template>
<div id="CSMCalibrationSettings"> <div id="CSMCalibrationSettings">
<!--Show auto calibrate if default plugin is enabled--> <!--Show auto calibrate if default plugin is enabled-->
<div v-if="'calibrate_xy' in recalibrationLinks" class="uk-margin-small"> <div v-if="'calibrate_xy' in actions" class="uk-margin-small">
<taskSubmitter <taskSubmitter
:button-primary="true" :button-primary="true"
:can-terminate="false" :can-terminate="false"
@ -9,16 +9,15 @@
:confirmation-message=" :confirmation-message="
'Start recalibration of the stage to the camera? This may take a while, and the microscope will be locked during this time.' 'Start recalibration of the stage to the camera? This may take a while, and the microscope will be locked during this time.'
" "
:submit-url="recalibrationLinks.calibrate_xy.href" :submit-url="calibrateXYUri"
:submit-label="'Auto-Calibrate using camera'" :submit-label="'Auto-Calibrate using camera'"
@response="onRecalibrateResponse" @response="onRecalibrateResponse"
@error="modalError" @error="modalError"
> />
</taskSubmitter>
</div> </div>
<button <button
v-if="'get_calibration' in recalibrationLinks" v-if="'last_calibration' in properties"
v-show="dataAvailable && showExtraSettings" v-show="showExtraSettings"
type="button" type="button"
class="uk-button uk-button-default uk-width-1-1" class="uk-button uk-button-default uk-width-1-1"
@click="getCalibrationData()" @click="getCalibrationData()"
@ -29,7 +28,6 @@
</template> </template>
<script> <script>
import axios from "axios";
import taskSubmitter from "../../../genericComponents/taskSubmitter"; import taskSubmitter from "../../../genericComponents/taskSubmitter";
// Export main app // Export main app
@ -48,107 +46,44 @@ export default {
} }
}, },
data: function() {
return {
settings: null,
recalibrationLinks: {},
isCalibrating: false,
dataAvailable: false
};
},
computed: { computed: {
settingsUri: function() { actions() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`; return this.$store.getters["wot/thingDescription"]("camera_stage_mapping")
.actions;
}, },
pluginsUri: function() { properties() {
return `${this.$store.getters.baseUri}/api/v2/extensions`; return this.$store.getters["wot/thingDescription"]("camera_stage_mapping")
.properties;
},
calibrateXYUri() {
return this.thingActionUrl("camera_stage_mapping", "calibrate_xy");
} }
}, },
mounted() {
this.updateSettings();
this.updateRecalibrationLinks();
},
methods: { methods: {
updateSettings: function() { getCalibrationData: async function() {
// Update links try {
axios let data = await this.readThingProperty(
.get(this.settingsUri) "camera_stage_mapping",
.then(response => { "last_calibration"
this.settings = );
response.data.extensions["org.openflexure.camera_stage_mapping"]; if (data == {}) {
}) throw "No calibration data available.";
.catch(error => { }
this.modalError(error); // Let mixin handle error const dataStr = JSON.stringify(data);
this.settings = {}; const url = window.URL.createObjectURL(new Blob([dataStr]));
}); const link = document.createElement("a");
}, link.href = url;
link.setAttribute("download", "csm_calibration.json");
updateCalibrationDataAvailability: function() { document.body.appendChild(link);
if ("get_calibration" in this.recalibrationLinks) { link.click();
axios } catch (error) {
.get(this.recalibrationLinks.get_calibration.href) this.modalError(error); // Let mixin handle error
.then(response => {
if (Object.keys(response.data).length === 0) {
this.dataAvailable = false;
} else {
this.dataAvailable = true;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
} }
}, },
getCalibrationData: function() {
axios
.get(this.recalibrationLinks.get_calibration.href)
.then(response => {
if (response.data != {}) {
const data = JSON.stringify(response.data);
const url = window.URL.createObjectURL(new Blob([data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "csm_calibration.json");
document.body.appendChild(link);
link.click();
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateRecalibrationLinks: function() {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.camera_stage_mapping"
);
// if camera-stage mapping extension is enabled
if (foundExtension) {
// Get plugin action link
this.recalibrationLinks = foundExtension.links;
// Update whether calibration data is available
this.updateCalibrationDataAvailability();
} else {
this.recalibrationLinks = {};
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onRecalibrateResponse: function() { onRecalibrateResponse: function() {
this.modalNotify("Finished stage-to-camera calibration."); this.modalNotify("Finished stage-to-camera calibration.");
// Update local settings
this.updateSettings();
} }
} }
}; };

View file

@ -2,6 +2,9 @@
<div id="cameraSettings"> <div id="cameraSettings">
<div class="uk-grid uk-grid-divider uk-child-width-expand" uk-grid> <div class="uk-grid uk-grid-divider uk-child-width-expand" uk-grid>
<div class="uk-width-large"> <div class="uk-width-large">
<h3>Automatic calibration</h3>
<cameraCalibrationSettings :camera-uri="cameraUri" />
<h3>Manual camera settings</h3> <h3>Manual camera settings</h3>
<form @submit.prevent="applySettingsRequest"> <form @submit.prevent="applySettingsRequest">
<div class="uk-margin-small-bottom"> <div class="uk-margin-small-bottom">
@ -9,139 +12,47 @@
<li class="uk-open"> <li class="uk-open">
<a class="uk-accordion-title" href="#">Pi Camera Settings</a> <a class="uk-accordion-title" href="#">Pi Camera Settings</a>
<div class="uk-accordion-content"> <div class="uk-accordion-content">
<div v-if="picamera.shutter_speed !== undefined"> <PropertyControl
<label class="uk-form-label" for="form-stacked-text" label="Exposure time"
>Exposure time</label property-name="exposure_time"
> thing-name="camera"
<div class="uk-form-controls"> :read-back-delay="1000"
<input />
v-model="picamera.shutter_speed" <PropertyControl
class="uk-input uk-form-small" label="Analogue gain"
type="number" property-name="analogue_gain"
/> thing-name="camera"
</div> :read-back-delay="1000"
</div> />
<PropertyControl
<div v-if="picamera.analog_gain !== undefined"> label="Colour gains"
<label class="uk-form-label" for="form-stacked-text" property-name="colour_gains"
>Analogue gain</label thing-name="camera"
> :read-back-delay="1000"
<div class="uk-form-controls"> />
<input
v-model="picamera.analog_gain"
class="uk-input uk-form-small"
type="number"
step="0.0000000000000001"
/>
</div>
</div>
<div v-if="picamera.digital_gain !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>Digital gain</label
>
<div class="uk-form-controls">
<input
v-model="picamera.digital_gain"
class="uk-input uk-form-small"
type="number"
step="0.0000000000000001"
/>
</div>
</div>
<div v-if="picamera.awb_gains !== undefined">
<label class="uk-form-label" for="form-stacked-text">
White Balance gains
</label>
<div class="uk-form-controls">
<label class="uk-form-label">R:</label>
<input
v-model="picamera.awb_gains[0]"
class="uk-input uk-form-small"
type="number"
step="0.0000000000000001"
/>
<label class="uk-form-label">B:</label>
<input
v-model="picamera.awb_gains[1]"
class="uk-input uk-form-small"
type="number"
step="0.0000000000000001"
/>
</div>
</div>
</div> </div>
</li> </li>
<li class="uk-open"> <li class="uk-open">
<a class="uk-accordion-title" href="#">Image Quality</a> <a class="uk-accordion-title" href="#">Image Quality</a>
<div class="uk-accordion-content"> <div class="uk-accordion-content">
<div class="uk-child-width-1-2" uk-grid> <PropertyControl
<div v-if="jpeg_quality !== undefined"> label="MJPEG stream bit rate"
<label class="uk-form-label" for="form-stacked-text" property-name="mjpeg_bitrate"
>JPEG capture quality (%)</label thing-name="camera"
> :read-back-delay="100"
<div class="uk-form-controls"> />
<input <PropertyControl
v-model="jpeg_quality" label="MJPEG stream resolution"
class="uk-input uk-form-small" property-name="stream_resolution"
type="number" thing-name="camera"
step="1" :read-back-delay="100"
/> />
</div>
</div>
<div v-if="stream_resolution !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>Stream resolution</label
>
<select
v-model="stream_resolution"
class="uk-select uk-form-small"
>
<option
v-for="option in resolutionOptions"
:key="option.value[0]"
:value="option.value"
>
{{ option.text }}
</option>
</select>
</div>
</div>
</div> </div>
</li> </li>
<!--
<li> <li>
<a class="uk-accordion-title" href="#">Advanced</a> <a class="uk-accordion-title" href="#">Advanced</a>
<div class="uk-accordion-content"> <div class="uk-accordion-content">
<div v-if="mjpeg_bitrate !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>Camera bitrate</label
>
<select
v-model="mjpeg_bitrate"
class="uk-select uk-form-small"
>
<option
v-for="option in bitrateOptions"
:key="option.value"
:value="option.value"
>
{{ option.text }}
</option>
</select>
<p class="uk-margin-remove">
This option sets the target bitrate for camera
recordings.<br />
<b
>Limiting bitrate may impact fast-autofocus
reliability.</b
>
However is guaranteed to fix the live stream disappearing
for some highly detailed samples.
</p>
</div>
<div <div
v-if="picamera.framerate !== undefined" v-if="picamera.framerate !== undefined"
@ -166,27 +77,14 @@
This option sets the framerate of the Pi Camera video This option sets the framerate of the Pi Camera video
encoder. The actual stream framerate is determined by encoder. The actual stream framerate is determined by
network speed, and is limited by the encoder framerate.<br /> network speed, and is limited by the encoder framerate.<br />
<b <b>Lowering framerate may impact fast-autofocus accuracy.</b>
>Lowering framerate may impact fast-autofocus
accuracy.</b
>
</p> </p>
</div> </div>
</div> </div>
</li> </li>-->
</ul> </ul>
</div> </div>
<button
type="submit"
class="uk-button uk-button-primary uk-margin-small uk-width-1-1"
>
Apply Settings
</button>
</form> </form>
<h3>Automatic calibration</h3>
<cameraCalibrationSettings></cameraCalibrationSettings>
</div> </div>
<div id="mini-stream"> <div id="mini-stream">
@ -197,9 +95,9 @@
</template> </template>
<script> <script>
import axios from "axios";
import cameraCalibrationSettings from "./cameraSettingsComponents/cameraCalibrationSettings.vue"; import cameraCalibrationSettings from "./cameraSettingsComponents/cameraCalibrationSettings.vue";
import miniStreamDisplay from "../../genericComponents/miniStreamDisplay.vue"; import miniStreamDisplay from "../../genericComponents/miniStreamDisplay.vue";
import PropertyControl from "../../labThingsComponents/propertyControl.vue";
// Export main app // Export main app
export default { export default {
@ -207,7 +105,8 @@ export default {
components: { components: {
cameraCalibrationSettings, cameraCalibrationSettings,
miniStreamDisplay miniStreamDisplay,
PropertyControl
}, },
data: function() { data: function() {
@ -242,74 +141,8 @@ export default {
}, },
computed: { computed: {
settingsUri: function() { cameraUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`; return `${this.$store.getters.baseUri}/camera/`;
}
},
mounted() {
this.updateSettings();
},
methods: {
updateSettings: function() {
axios
.get(this.settingsUri)
.then(response => {
const cameraSettings = response.data.camera;
// Get base camera settings
this.mjpeg_bitrate = cameraSettings.mjpeg_bitrate;
this.jpeg_quality = cameraSettings.jpeg_quality;
this.stream_resolution = cameraSettings.stream_resolution;
// Get Pi Camera settings if they exist
if (cameraSettings.picamera) {
this.picamera.analog_gain = cameraSettings.picamera.analog_gain;
this.picamera.digital_gain = cameraSettings.picamera.digital_gain;
this.picamera.shutter_speed = cameraSettings.picamera.shutter_speed;
this.picamera.framerate = cameraSettings.picamera.framerate;
this.picamera.awb_gains = cameraSettings.picamera.awb_gains;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
applySettingsRequest: function() {
// We have to use parseInt/parseFloat because JS sometimes seems to
// make the numbers be strings... TypeScript would solve this...
var payload = {
camera: {
mjpeg_bitrate: parseInt(this.mjpeg_bitrate),
jpeg_quality: parseInt(this.jpeg_quality),
stream_resolution: this.stream_resolution,
picamera: {
shutter_speed: parseFloat(this.picamera.shutter_speed),
analog_gain: parseFloat(this.picamera.analog_gain),
digital_gain: parseFloat(this.picamera.digital_gain),
framerate: parseInt(this.picamera.framerate),
awb_gains: [
parseFloat(this.picamera.awb_gains[0]),
parseFloat(this.picamera.awb_gains[1])
]
}
}
};
console.log(payload);
// Send request
axios
.put(this.settingsUri, payload)
.then(() => {
return new Promise(r => setTimeout(r, 500));
}) // why is there no built-in for this??!
.then(() => {
// Update local settings
this.updateSettings();
this.modalNotify("Camera settings applied.");
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
} }
} }
}; };

View file

@ -1,52 +1,43 @@
<template> <template>
<div> <div>
<!--Show auto calibrate if default plugin is enabled--> <!--Show auto calibrate if default plugin is enabled-->
<div v-if="'recalibrate' in recalibrationLinks" class="uk-margin-small"> <div v-if="'full_auto_calibrate' in actions" class="uk-margin-small">
<taskSubmitter <taskSubmitter
:can-terminate="false" :can-terminate="false"
:requires-confirmation="true" :requires-confirmation="true"
:confirmation-message=" :confirmation-message="
'Start recalibration? This may take a while, and the microscope will be locked during this time.' 'Start recalibration? This may take a while, and the microscope will be locked during this time.'
" "
:submit-url="recalibrationLinks.recalibrate.href" :submit-url="cameraUri + 'full_auto_calibrate'"
:submit-label="'Full Auto-Calibrate'" :submit-label="'Full Auto-Calibrate'"
@response="onRecalibrateResponse" @response="onRecalibrateResponse"
@error="modalError" @error="modalError"
> >
</taskSubmitter> </taskSubmitter>
</div> </div>
<div <div v-if="'auto_expose_from_minimum' in actions" class="uk-margin-small">
v-if="'auto_exposure_from_raw' in recalibrationLinks"
class="uk-margin-small"
>
<taskSubmitter <taskSubmitter
:can-terminate="false" :can-terminate="false"
:requires-confirmation="false" :requires-confirmation="false"
:submit-url="recalibrationLinks.auto_exposure_from_raw.href" :submit-url="cameraUri + 'auto_expose_from_minimum'"
:submit-label="'Auto gain &amp; shutter speed'" :submit-label="'Auto gain &amp; shutter speed'"
@response="onRecalibrateResponse" @response="onRecalibrateResponse"
@error="modalError" @error="modalError"
> >
</taskSubmitter> </taskSubmitter>
</div> </div>
<div <div v-if="'calibrate_white_balance' in actions" class="uk-margin-small">
v-if="'auto_white_balance_from_raw' in recalibrationLinks"
class="uk-margin-small"
>
<taskSubmitter <taskSubmitter
:can-terminate="false" :can-terminate="false"
:requires-confirmation="false" :requires-confirmation="false"
:submit-url="recalibrationLinks.auto_white_balance_from_raw.href" :submit-url="cameraUri + 'calibrate_white_balance'"
:submit-label="'Auto white balance'" :submit-label="'Auto white balance'"
@response="onRecalibrateResponse" @response="onRecalibrateResponse"
@error="modalError" @error="modalError"
> >
</taskSubmitter> </taskSubmitter>
</div> </div>
<div <div v-if="'calibrate_lens_shading' in actions" class="uk-margin-small">
v-if="'auto_lens_shading_table' in recalibrationLinks"
class="uk-margin-small"
>
<taskSubmitter <taskSubmitter
:can-terminate="false" :can-terminate="false"
:requires-confirmation="true" :requires-confirmation="true"
@ -54,7 +45,7 @@
'Is the microscope looking at an evenly illuminated, empty field of view? ' + 'Is the microscope looking at an evenly illuminated, empty field of view? ' +
'If not, the current image will show through in any images captured afterwards.' 'If not, the current image will show through in any images captured afterwards.'
" "
:submit-url="recalibrationLinks.auto_lens_shading_table.href" :submit-url="cameraUri + 'calibrate_lens_shading'"
:submit-label="'Auto flat field correction'" :submit-label="'Auto flat field correction'"
@response="onRecalibrateResponse" @response="onRecalibrateResponse"
@error="modalError" @error="modalError"
@ -62,23 +53,35 @@
</taskSubmitter> </taskSubmitter>
</div> </div>
<div v-show="showExtraSettings" class="uk-child-width-expand"> <div
<button v-show="showExtraSettings"
v-if="'flatten_lens_shading_table' in recalibrationLinks" v-if="'flatten_lens_shading_table' in actions"
class="uk-button uk-button-danger uk-width-1-1" class="uk-child-width-expand"
@click="flattenLensShadingTableRequest" >
<taskSubmitter
:can-terminate="false"
:requires-confirmation="false"
:submit-url="cameraUri + 'flat_lens_shading'"
:submit-label="'Disable flat field correction'"
@response="onRecalibrateResponse"
@error="modalError"
> >
Disable flat field correction </taskSubmitter>
</button>
</div> </div>
<div
<div v-if="LstDownloadEnabled"> v-show="showExtraSettings"
<a v-if="'reset_lens_shading' in actions"
class="uk-button uk-button-default uk-width-large uk-margin-small-top uk-align-center" class="uk-child-width-expand"
:href="LstDownloadUri" >
download <taskSubmitter
>Download Lens-Shading Table</a :can-terminate="false"
:requires-confirmation="false"
:submit-url="cameraUri + 'reset_lens_shading'"
:submit-label="'Reset flat field correction'"
@response="onRecalibrateResponse"
@error="modalError"
> >
</taskSubmitter>
</div> </div>
</div> </div>
</template> </template>
@ -100,66 +103,34 @@ export default {
type: Boolean, type: Boolean,
required: false, required: false,
default: true default: true
},
cameraUri: {
type: String,
required: true
} }
}, },
data: function() { data: function() {
return { return {
recalibrationLinks: {}, actions: {},
isCalibrating: false, isCalibrating: false,
LstDownloadEnabled: false LstDownloadEnabled: false
}; };
}, },
computed: {
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
},
LstDownloadUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/camera/lst`;
}
},
mounted() { mounted() {
this.updateRecalibrationLinks(); this.updateActions();
this.checkLstDownload();
}, },
methods: { methods: {
updateRecalibrationLinks: function() { updateActions: async function() {
axios try {
.get(this.pluginsUri) // Get a list of plugins let response = await axios.get(this.cameraUri); // Get the thing description
.then(response => { let td = response.data;
var plugins = response.data; this.actions = td.actions;
var foundExtension = plugins.find( } catch (error) {
e => e.title === "org.openflexure.calibration.picamera" this.modalError(error); // Let mixin handle error
); }
// if AutocalibrationPlugin is enabled
if (foundExtension) {
// Get plugin action link
this.recalibrationLinks = foundExtension.links;
} else {
this.recalibrationLinks = {};
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
checkLstDownload: function() {
axios
.get(this.LstDownloadUri) // Get a list of plugins
.then(response => {
if (response.status === 200) {
this.LstDownloadEnabled = true;
} else {
this.LstDownloadEnabled = false;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}, },
onRecalibrateResponse: function() { onRecalibrateResponse: function() {

View file

@ -1,19 +1,6 @@
<template> <template>
<div id="appSettings" class="uk-width-large"> <div id="appSettings" class="uk-width-large">
<h3>Additional features</h3> <h3>Additional features</h3>
<p class="uk-margin-small">
<label
><input v-model="IHIEnabled" class="uk-checkbox" type="checkbox" />
Enable IHI Slide Scan</label
>
</p>
<p class="uk-margin-small">
Enabling IHI Slide Scan will add a new tab designed to simplify acquiring
blood smear tile scans. <br />
While this may be useful for other applications, it is primarily designed
for use in clinics acquiring blood smear samples using a 100x
oil-immersion objective, with a Raspberry Pi Camera v2.
</p>
<p class="uk-margin-small" :class="{ 'uk-text-muted': !imjoyPermitted }"> <p class="uk-margin-small" :class="{ 'uk-text-muted': !imjoyPermitted }">
<label :disabled="!imjoyPermitted"> <label :disabled="!imjoyPermitted">
<input <input
@ -57,14 +44,6 @@ export default {
}, },
computed: { computed: {
IHIEnabled: {
get() {
return this.$store.state.IHIEnabled;
},
set(value) {
this.$store.commit("changeIHIEnabled", value);
}
},
imjoyEnabled: { imjoyEnabled: {
get() { get() {
return this.$store.state.imjoyEnabled; return this.$store.state.imjoyEnabled;
@ -90,9 +69,6 @@ export default {
}, },
watch: { watch: {
IHIEnabled: function() {
this.setLocalStorageObj("IHIEnabled", this.IHIEnabled);
},
imjoyEnabled: function() { imjoyEnabled: function() {
this.setLocalStorageObj("imjoyEnabled", this.imjoyEnabled); this.setLocalStorageObj("imjoyEnabled", this.imjoyEnabled);
}, },
@ -103,7 +79,6 @@ export default {
mounted() { mounted() {
// Try loading settings from localStorage. If null, don't change. // Try loading settings from localStorage. If null, don't change.
this.IHIEnabled = this.getLocalStorageObj("IHIEnabled") || this.IHIEnabled;
this.imjoyEnabled = this.imjoyEnabled =
this.getLocalStorageObj("imjoyEnabled") || this.imjoyEnabled; this.getLocalStorageObj("imjoyEnabled") || this.imjoyEnabled;
this.galleryEnabled = this.galleryEnabled =

View file

@ -1,89 +0,0 @@
<template>
<div v-if="settings" id="microscopeSettings" class="uk-width-large">
<form @submit.prevent="applySettingsRequest">
<div>
<label class="uk-form-label" for="form-stacked-text"
>Microscope name</label
>
<input
v-model="settings.name"
class="uk-input uk-width-1-1 uk-form-small"
name="inputFilename"
placeholder="Leave blank for default"
/>
</div>
<br />
<button
type="submit"
class="uk-button uk-button-primary uk-margin-small uk-width-1-1"
>
Apply Settings
</button>
</form>
</div>
</template>
<script>
import axios from "axios";
// Export main app
export default {
name: "MicroscopeSettings",
data: function() {
return {
settings: null
};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
}
},
mounted() {
this.updateSettings();
},
methods: {
updateSettings: function() {
axios
.get(this.settingsUri)
.then(response => {
this.settings = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
applySettingsRequest: function() {
var payload = {
name: this.settings.name
};
// Send request to update config
axios
.put(this.settingsUri, payload)
.then(() => {
// Update local settings
this.updateSettings();
this.modalNotify("Microscope settings applied.");
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}
}
};
</script>
<style lang="less">
.center-spinner {
margin-left: auto;
margin-right: auto;
}
</style>

View file

@ -1,192 +1,25 @@
<template> <template>
<div id="stageSettings" class="uk-width-large"> <div id="stageSettings" class="uk-width-large">
<div v-if="stageType === 'MissingStage'" class="uk-text-danger"> The microscope stage is a <b>{{ stageType }}</b
<b>No stage connected</b> >. There are no settings to adjust here: to set the geometry of your stage
</div> (i.e. switch between delta and cartesian), you will need to adjust the
<div v-else> configuration of your microscope, which cannot be done while it is running.
<h3>Stage settings</h3>
<form @submit.prevent="setStageType">
<div class="uk-margin-small-bottom">
<h4>Stage geometry</h4>
<select v-model="stageType" class="uk-select uk-margin-small-top">
<option value="SangaStage">SangaStage (Standard)</option>
<option value="SangaDeltaStage"> SangaStage (Delta)</option>
</select>
<button
type="submit"
class="uk-button uk-button-primary uk-margin-small uk-width-1-1"
>
Change stage geometry
</button>
</div>
</form>
<form @submit.prevent="applySettingsRequest">
<div class="uk-margin-small-bottom">
<h4>Backlash compensation</h4>
<p class="uk-margin-remove">
Backlash compentation causes movements to overshoot by that number
of motor steps, reducing the effect of mechanical backlash.
</p>
<div
class="uk-grid-small uk-child-width-1-3 uk-margin-small-bottom"
uk-grid
>
<div>
<label class="uk-form-label" for="form-stacked-text">x</label>
<div class="uk-form-controls">
<input
v-model="backlash.x"
class="uk-input uk-form-small"
type="number"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text">y</label>
<div class="uk-form-controls">
<input
v-model="backlash.y"
class="uk-input uk-form-small"
type="number"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text">z</label>
<div class="uk-form-controls">
<input
v-model="backlash.z"
class="uk-input uk-form-small"
type="number"
/>
</div>
</div>
</div>
<h4>Settle time</h4>
<div v-if="settle_time !== undefined">
<p class="uk-margin-small-bottom">
When moving or scanning, captures will be delayed by the settle
time (in seconds) to reduce motion blur.
</p>
<div class="uk-form-controls">
<input
v-model="settle_time"
class="uk-input uk-form-small"
type="number"
step="0.1"
/>
</div>
</div>
<button
type="submit"
class="uk-button uk-button-primary uk-margin-small uk-width-1-1"
>
Apply Settings
</button>
</div>
</form>
</div>
</div> </div>
</template> </template>
<script> <script>
import axios from "axios";
export default { export default {
name: "StageSettings", name: "StageSettings",
components: {}, components: {},
data: function() { data: function() {
return { return {};
stageType: "MissingStage",
backlash: {
x: undefined,
y: undefined,
z: undefined
},
settle_time: undefined
};
}, },
computed: { computed: {
settingsUri: function() { stageType: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`; return this.thingDescription("stage").title;
},
stageTypeUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/stage/type`;
}
},
mounted() {
this.getStageType();
this.updateSettings();
},
methods: {
updateSettings: function() {
axios
.get(this.settingsUri)
.then(response => {
const stageSettings = response.data.stage;
this.backlash.x = stageSettings.backlash.x;
this.backlash.y = stageSettings.backlash.y;
this.backlash.z = stageSettings.backlash.z;
this.settle_time = stageSettings.settle_time;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
applySettingsRequest: function() {
var payload = {
stage: {
backlash: this.backlash
}
};
// Send request to update settings
axios
.put(this.settingsUri, payload)
.then(() => {
// Update local settings
this.updateSettings();
this.modalNotify("Stage settings applied.");
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
getStageType: function() {
axios
.get(this.stageTypeUri)
.then(response => {
this.stageType = response.data;
})
.catch(error => {
this.modalError(error);
});
},
setStageType: function() {
axios
.put(this.stageTypeUri, this.stageType, {
headers: {
"Content-Type": "application/json"
}
})
.then(response => {
this.stageType = response.data;
this.modalNotify("Stage geometry changed.");
})
.catch(error => {
this.modalError(error);
});
} }
} }
}; };

View file

@ -70,19 +70,6 @@
Camera/stage mapping Camera/stage mapping
</tabIcon> </tabIcon>
</li> </li>
<li>
<tabIcon
id="settings-microscope-icon"
tab-i-d="microscope"
:show-title="false"
:show-tooltip="false"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
General
</tabIcon>
</li>
</ul> </ul>
</div> </div>
<div class="view-component uk-width-expand uk-padding-small"> <div class="view-component uk-width-expand uk-padding-small">
@ -136,24 +123,12 @@
<CSMSettings /> <CSMSettings />
</div> </div>
</tabContent> </tabContent>
<tabContent
tab-i-d="microscope"
:require-connection="true"
:current-tab="currentTab"
>
<div class="settings-pane uk-padding-small">
<h3>Microscope settings</h3>
<microscopeSettings />
</div>
</tabContent>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import streamSettings from "./settingsComponents/streamSettings.vue"; import streamSettings from "./settingsComponents/streamSettings.vue";
import microscopeSettings from "./settingsComponents/microscopeSettings.vue";
import cameraSettings from "./settingsComponents/cameraSettings.vue"; import cameraSettings from "./settingsComponents/cameraSettings.vue";
import appSettings from "./settingsComponents/appSettings.vue"; import appSettings from "./settingsComponents/appSettings.vue";
import featuresSettings from "./settingsComponents/featuresSettings.vue"; import featuresSettings from "./settingsComponents/featuresSettings.vue";
@ -171,7 +146,6 @@ export default {
streamSettings, streamSettings,
cameraSettings, cameraSettings,
stageSettings, stageSettings,
microscopeSettings,
CSMSettings, CSMSettings,
appSettings, appSettings,
featuresSettings, featuresSettings,

View file

@ -134,7 +134,6 @@
</template> </template>
<script> <script>
import axios from "axios";
import taskSubmitter from "../../genericComponents/taskSubmitter"; import taskSubmitter from "../../genericComponents/taskSubmitter";
export default { export default {
@ -166,9 +165,6 @@ export default {
}, },
computed: { computed: {
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
},
currentTimeForm: { currentTimeForm: {
get() { get() {
// Chop the timezone information from the end // Chop the timezone information from the end
@ -210,30 +206,7 @@ export default {
} }
}, },
mounted: function() {
this.updateScanUri();
},
methods: { methods: {
updateScanUri: function() {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.scan"
);
// if ScanPlugin is enabled
if (foundExtension) {
// Get plugin action link
this.scanUri = foundExtension.links.tile.href;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onScanError: function(error) { onScanError: function(error) {
this.scanRunning = false; this.scanRunning = false;
this.modalError(error); this.modalError(error);

View file

@ -58,9 +58,6 @@ export default {
}, },
streamImgUri: function() { streamImgUri: function() {
return `${this.$store.getters.baseUri}/camera/mjpeg_stream`; return `${this.$store.getters.baseUri}/camera/mjpeg_stream`;
},
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
} }
}, },

View file

@ -1,6 +1,7 @@
import Vue from "vue"; import Vue from "vue";
import App from "./App.vue"; import App from "./App.vue";
import store from "./store"; import store from "./store";
import axios from "axios";
import UIkit from "uikit"; import UIkit from "uikit";
import VueTour from "vue-tour"; import VueTour from "vue-tour";
import VueFriendlyIframe from "vue-friendly-iframe"; import VueFriendlyIframe from "vue-friendly-iframe";
@ -34,6 +35,49 @@ Vue.config.productionTip = false;
Vue.mixin({ Vue.mixin({
methods: { methods: {
thingDescription(thing) {
return this.$store.getters["wot/thingDescription"](thing);
},
thingAvailable(thing) {
return this.$store.getters["wot/thingAvailable"](thing);
},
async readThingProperty(thing, property, silence_errors = false) {
let url = this.$store.getters["wot/thingPropertyUrl"](
thing,
property,
"readproperty",
false
);
try {
let response = await axios.get(url);
return response.data;
} catch (error) {
if (!silence_errors) this.modalError(error);
return undefined;
}
},
async writeThingProperty(thing, property, value) {
let url = this.$store.getters["wot/thingPropertyUrl"](
thing,
property,
"writeproperty",
false
);
try {
await axios.put(url, value);
} catch (error) {
this.modalError(error);
}
},
thingActionUrl(thing, action) {
let url = this.$store.getters["wot/thingActionUrl"](
thing,
action,
"invokeaction",
false
);
return url;
},
modalConfirm: function(modalText) { modalConfirm: function(modalText) {
var context = this; var context = this;
@ -86,6 +130,7 @@ Vue.mixin({
modalError: function(error) { modalError: function(error) {
var errormsg = this.getErrorMessage(error); var errormsg = this.getErrorMessage(error);
this.$store.commit("setErrorMessage", errormsg); this.$store.commit("setErrorMessage", errormsg);
console.log("Modal error:", error);
UIkit.notification({ UIkit.notification({
message: `${errormsg}`, message: `${errormsg}`,
status: "danger" status: "danger"

View file

@ -1,5 +1,6 @@
import Vue from "vue"; import Vue from "vue";
import Vuex from "vuex"; import Vuex from "vuex";
import wotStoreModule from "./wot-client";
Vue.use(Vuex); Vue.use(Vuex);
@ -76,7 +77,8 @@ function getOriginFromLocation() {
export default new Vuex.Store({ export default new Vuex.Store({
modules: { modules: {
imjoy: moduleImjoy imjoy: moduleImjoy,
wot: wotStoreModule
}, },
state: { state: {
origin: getOriginFromLocation(), origin: getOriginFromLocation(),
@ -86,7 +88,6 @@ export default new Vuex.Store({
disableStream: false, disableStream: false,
autoGpuPreview: false, autoGpuPreview: false,
trackWindow: true, trackWindow: true,
IHIEnabled: false,
imjoyEnabled: false, imjoyEnabled: false,
galleryEnabled: true, galleryEnabled: true,
appTheme: "system", appTheme: "system",
@ -112,9 +113,6 @@ export default new Vuex.Store({
changeAppTheme(state, theme) { changeAppTheme(state, theme) {
state.appTheme = theme; state.appTheme = theme;
}, },
changeIHIEnabled(state, enabled) {
state.IHIEnabled = enabled;
},
changeImjoyEnabled(state, enabled) { changeImjoyEnabled(state, enabled) {
if (process.env.VUE_APP_ENABLE_IMJOY === "true") { if (process.env.VUE_APP_ENABLE_IMJOY === "true") {
state.imjoyEnabled = enabled; state.imjoyEnabled = enabled;

129
webapp/src/wot-client.js Normal file
View file

@ -0,0 +1,129 @@
import axios from "axios";
export const wotStoreModule = {
namespaced: true,
state: () => ({
thingDescriptions: {},
servient: null,
helpers: null
}),
mutations: {
addThingDescription(state, { thingName, thingDescription }) {
state.thingDescriptions[thingName] = thingDescription;
},
removeThingDescription(state, thingName) {
delete state.thingDescriptions[thingName];
},
removeAllThingDescriptions(state) {
state.thingDescriptions = {};
}
},
actions: {
async start() {
// Set up thing client - not currently used.
},
async fetchThingDescription({ commit }, { uri, name = null }) {
// Fetch the thing description from the given URI and consume it
// NB this should only be called once, or we'll duplicate effort.
// Deduplication should be done elsewhere.
let response = await axios.get(uri);
let td = response.data;
let thing_name =
name |
uri
.replace(/\/$/, "")
.split("/")
.pop();
commit("addThingDescription", {
thingName: thing_name,
thingDescription: td
});
},
async fetchThingDescriptions({ commit }, uri) {
// Fetch thing descriptions from the given URI
let response = await axios.get(uri);
if (response.status != 200) throw "Could not retrieve thing descriptions";
for (const k in response.data) {
let thing_name = k.replace(/\/$/, "").replace(/^\//, "");
commit("addThingDescription", {
thingName: thing_name,
thingDescription: response.data[k]
});
}
}
},
getters: {
thingDescriptions: state => {
return state.thingDescriptions;
},
thingDescription: state => thingName => {
return state.thingDescriptions[thingName];
},
thingAvailable: state => thingName => {
return thingName in state.thingDescriptions;
},
thingFormUrl: state => (
thing,
affordanceType,
affordance,
op,
allowUndefined = true
) => {
// Find the URL for a particular operation
let td = state.thingDescriptions[thing];
if (!td) {
if (allowUndefined) return undefined;
throw `Could not find form for ${affordanceType} ${thing}/${affordance} with op ${op}`;
}
let affordances = td[affordanceType];
let href = findFormHref(affordances[affordance], op);
if (href == undefined) {
if (allowUndefined) return undefined;
throw `Could not find form for ${affordanceType} ${thing}/${affordance} with op ${op}`;
}
// If we've found an href, prepend the `base` URL if appropriate
if (href.startsWith("http")) return href;
if ("base" in td) {
let base = td.base;
if (href.startsWith("/")) href = href.slice(1);
if (!base.endsWith("/")) base += "/";
return base + href;
}
return href;
},
thingPropertyUrl: (_state, getters) => (
thing,
property,
op,
allowUndefined
) => {
// Find the URL for a particular property
return getters.thingFormUrl(
thing,
"properties",
property,
op,
allowUndefined
);
},
thingActionUrl: (_state, getters) => (
thing,
action,
op,
allowUndefined
) => {
// Find the URL for a particular action
return getters.thingFormUrl(thing, "actions", action, op, allowUndefined);
}
}
};
export function findFormHref(affordance, op) {
// Find the form in the affordance that matches the given operation type
let forms = affordance.forms;
let matchingForm = forms.find(f => f.op == op || f.op.includes(op));
if (matchingForm == undefined) return undefined;
return matchingForm.href;
}
export default wotStoreModule;

View file

@ -1,3 +1,3 @@
module.exports = { module.exports = {
outputDir: '../openflexure_microscope/api/static/dist', outputDir: "../openflexure_microscope/api/static/dist"
}; };