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.
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
@ -62,18 +62,19 @@ To set up a development version of the software (most likely using emulated came
* `cd openflexure-microscope-server`
### Set up the Python environment and run a test server
* (Optional) Set local Python version to match what is available on the Pi
* `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`
* (Optional) Set local Python version to 3.11
* Create a virtual environment and activate it:
* `python -m venv .venv`
* `source .venv/bin/activate` (on Linux) or `.venv/Scripts/activate` (on Windows)
* `pip install --upgrade pip wheel pipenv`
* `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:
* You can use `ofm serve` or `ofm restart` on the Raspberry Pi to manage the server.
* 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`
* `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.)
* 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.
### Run the server manually on a Raspberry Pi
```
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
* 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 loadingContent from "./components/loadingContent.vue";
import axios from "axios";
var Mousetrap = require("mousetrap");
Mousetrap.prototype.stopCallback = function(e, element) {
@ -305,24 +304,31 @@ export default {
},
methods: {
checkConnection: function() {
async checkConnection() {
var baseUri = this.$store.getters.baseUri;
this.$store.commit("changeWaiting", true);
axios
// TODO: more robust check - e.g. use a microscope Thing
.get(`${baseUri}/stage/`)
.then(() => {
this.$store.commit("setConnected");
this.$store.commit("setErrorMessage", null);
})
.catch(error => {
this.$store.commit("setErrorMessage", error);
})
.finally(() => {
this.$store.commit("changeWaiting", false);
});
// TODO: more robust check - e.g. use a microscope Thing
// TODO: should we purge existing consumedThings?
try {
await this.$store.dispatch(
"wot/fetchThingDescriptions",
`${baseUri}/thing_descriptions/`
);
for (let requiredThing of ["camera", "stage"]) {
if (!this.$store.getters["wot/thingAvailable"](requiredThing)) {
throw new Error(
`No ${requiredThing} found, the GUI won't work without one.`
);
}
}
this.$store.commit("setConnected");
this.$store.commit("setErrorMessage", null);
} catch (error) {
this.$store.commit("setErrorMessage", error);
} finally {
this.$store.commit("changeWaiting", false);
}
},
handleExit: function() {
this.$root.$emit("globalTogglePreview", false);
},

View file

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

View file

@ -142,7 +142,10 @@ export default {
if (task.status == "pending" || task.status == "running") {
this.taskStarted = true;
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) => {
// If the condition is met, we're done!
axios.get(
this.taskUrl,
{baseURL: this.$store.getters.baseUri}
).then(response => {
var result = response.data.status;
// If the task ends with success
if (result == "completed") {
resolve(response.data);
}
// If task ends with an error
else if (result == "error") {
// Pass the error string back with reject
axios
.get(this.taskUrl, { baseURL: this.$store.getters.baseUri })
.then(response => {
var result = response.data.status;
// If the task ends with success
if (result == "completed") {
resolve(response.data);
}
// If task ends with an error
else if (result == "error") {
// Pass the error string back with reject
reject(new Error(response.data.output));
}
// If task ends with termination
else if (result == "cancelled") {
// Pass a generic termination error back with reject
reject(new Error(response.data.output));
}
// If task ends with termination
else if (result == "cancelled") {
// Pass a generic termination error back with reject
reject(new Error("Task cancelled"));
} else {
// Since the task is still running, we can update the progress bar
this.progress = response.data.progress;
// Check again after timeout
setTimeout(checkCondition, interval, resolve, reject);
}
});
reject(new Error("Task cancelled"));
} else {
// Since the task is still running, we can update the progress bar
this.progress = response.data.progress;
// Check again after timeout
setTimeout(checkCondition, interval, resolve, reject);
}
});
};
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
:show-extra-settings="false"
:camera-uri="cameraUri"
></cameraCalibrationSettings>
</div>
</div>
@ -146,8 +147,6 @@
</template>
<script>
import axios from "axios";
import cameraCalibrationSettings from "../tabContentComponents/settingsComponents/cameraSettingsComponents/cameraCalibrationSettings.vue";
import CSMCalibrationSettings from "../tabContentComponents/settingsComponents/CSMSettingsComponents/CSMCalibrationSettings.vue";
import miniStreamDisplay from "../genericComponents/miniStreamDisplay.vue";
@ -172,62 +171,29 @@ export default {
return {
ready: false,
stepValue: 0,
settings: {},
config: {}
isCSMCalibrated: undefined,
isLSTCalibrated: undefined
};
},
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() {
// Assert CSM extension is enabled
var extensionEnabled = this.availablePluginTitles.includes(
"org.openflexure.camera_stage_mapping"
);
// Assert real stage is connected
var stageConnected = this.config.stage.type !== "MissingStage";
// Combine
return stageConnected && extensionEnabled;
return this.thingAvailable("camera_stage_mapping");
},
canLSTCalibrated: function() {
// Assert LST extension is enabled
var extensionEnabled = this.availablePluginTitles.includes(
"org.openflexure.calibration.picamera"
return (
"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() {
var CSMUseful = this.canCSMCalibrated && !this.isCSMCalibrated;
var LSTUseful = this.canLSTCalibrated && !this.isLSTCalibrated;
return CSMUseful || LSTUseful;
},
cameraUri: function() {
return `${this.$store.getters.baseUri}/camera/`;
}
},
@ -236,25 +202,33 @@ export default {
},
methods: {
show: function() {
// Get current settings
this.getSettings()
.then(() => {
return this.getConfig();
})
.then(() => {
// 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();
}
});
show: async function() {
// Check if the camera and stage are calibrated, if they can be
if (this.canCSMCalibrated) {
let csm = await this.readThingProperty(
"camera_stage_mapping",
"image_to_stage_displacement_matrix",
true
);
this.isCSMCalibrated = Boolean(csm);
}
if (this.canLSTCalibrated) {
this.isLSTCalibrated = await this.readThingProperty(
"camera",
"lens_shading_is_static"
);
}
// 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() {
@ -268,28 +242,6 @@ export default {
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() {
if (this.stepValue > 0) {
this.stepValue = this.stepValue - 1;

View file

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

View file

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

View file

@ -102,7 +102,7 @@
<ul uk-accordion="multiple: true">
<!--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>
<div class="uk-accordion-content">
<div class="uk-margin">
@ -221,7 +221,10 @@
</select>
</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">
<input
v-model="detectEmptyFieldsAndSkipAutofocus"
@ -256,7 +259,10 @@
</div>
</li>
<!--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>
<div
class="uk-accordion-content"
@ -401,10 +407,7 @@ export default {
data: function() {
return {
...defaultCaptureSettings(),
scanCapture: false, // Don't remember the "scan" tickbox in local storage.
scanUri: null,
smartScanUri: null,
backgroundDetectUri: null
scanCapture: false // Don't remember the "scan" tickbox in local storage.
};
},
@ -415,10 +418,13 @@ export default {
};
},
captureActionUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions/camera/capture`;
return this.thingActionUrl("camera", "capture");
},
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
scanUri: function() {
return this.thingActionUrl("scan", "scan");
},
smartScanUri: function() {
return this.thingActionUrl("scan", "scan");
},
basePayload: function() {
var payload = {};
@ -475,7 +481,8 @@ export default {
namemode: this.namingStyle.toLowerCase(),
autofocus_dz: afDeltas[this.scanDeltaZ],
fast_autofocus: this.scanDeltaZ == "Fast",
detect_empty_fields_and_skip_autofocus: this.detectEmptyFieldsAndSkipAutofocus
detect_empty_fields_and_skip_autofocus: this
.detectEmptyFieldsAndSkipAutofocus
};
},
smartScanPayload: function() {
@ -493,7 +500,6 @@ export default {
},
mounted() {
this.updateScanUri();
// Load settings if they have been saved, and set up watchers to sync with local storage
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() {
this.modalNotify("Finished scan.");
// 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() {
this.updateZipperUri();
},
@ -84,7 +78,7 @@ export default {
methods: {
updateZipperUri: function() {
if (this.$store.state.available) {
axios
/*axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
@ -100,7 +94,7 @@ export default {
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
});*/
} else {
this.zipBuilderUri = null;
this.zipGetterUri = null;

View file

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

View file

@ -68,66 +68,46 @@
</div>
</div>
<button
class="uk-button uk-button-default uk-margin uk-width-1-1"
@click="zeroRequest()"
>
Zero coordinates
</button>
<taskSubmitter
:submit-url="zeroActionUri"
:submit-label="'Zero Coordinates'"
:canTerminate="false"
@finished="updatePosition"
@error="modalError"
></taskSubmitter>
</div>
</li>
<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">
<form @submit.prevent="handleSubmit">
<form>
<!-- Text boxes to set and view position -->
<div class="uk-grid-small uk-child-width-1-3" uk-grid>
<div>
<label class="uk-form-label" for="form-stacked-text">x</label>
<div class="uk-form-controls">
<input
v-model="setPosition.x"
class="uk-input uk-form-small"
type="number"
name="inputPositionX"
/>
</div>
</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 class="input-and-buttons-container">
<input
v-for="(_v, key) in setPosition"
:key="`setPosition_${key}`"
v-model="setPosition[key]"
class="uk-form-small numeric-setting-line-input"
type="number"
@keyup.enter="startMoveTask"
/>
<a class="button-next-to-input" @click="updatePosition">
<i class="material-icons">refresh</i>
</a>
</div>
<p>
<button
type="submit"
class="uk-button uk-button-default uk-float-right uk-width-1-1"
>
Move
</button>
<taskSubmitter
ref="moveTaskSubmitter"
:submit-url="absoluteMoveUri"
:submit-data="setPosition"
:submit-label="'Move'"
:canTerminate="false"
:pollInterval="0.05"
@taskStarted="moveLock = true"
@finished="moveLock = false"
@error="modalError"
></taskSubmitter>
</p>
</form>
</div>
@ -143,7 +123,7 @@
v-if="fastAutofocusUri"
:submit-url="fastAutofocusUri"
:submit-data="{ dz: 2000 }"
:submit-label="'Fast'"
:submit-label="'Autofocus'"
:button-primary="false"
:submit-on-event="'globalFastAutofocusEvent'"
@taskStarted="isAutofocusing = 1"
@ -151,32 +131,34 @@
@error="modalError"
></taskSubmitter>
</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">
<taskSubmitter
v-if="normalAutofocusUri"
:submit-url="normalAutofocusUri"
:submit-data="{ dz: [-90, -60, -30, 0, 30, 60, 90] }"
:submit-label="'Medium'"
:button-primary="false"
@taskStarted="isAutofocusing = 2"
@finished="isAutofocusing = 0"
@error="modalError"
></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 class="uk-grid-small uk-child-width-expand" uk-grid>
<taskSubmitter
v-if="captureUri"
:submit-url="captureUri"
:submit-data="{ resolution: 'full' }"
submit-label="Full Resolution"
submit-on-event="globalCaptureEvent"
@response="handleCaptureResponse"
@error="modalError"
></taskSubmitter>
</div>
</div>
</li>
@ -220,10 +202,7 @@ export default {
},
setPosition: null,
isAutofocusing: 0,
moveLock: false,
fastAutofocusUri: null,
normalAutofocusUri: null,
moveInImageCoordinatesUri: null
moveLock: false
};
},
@ -231,17 +210,26 @@ export default {
baseUri: function() {
return this.$store.getters.baseUri;
},
moveActionUri: function() {
return `${this.$store.getters.baseUri}/stage/move_relative`;
absoluteMoveUri: function() {
return this.thingActionUrl("stage", "move_absolute");
},
zeroActionUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions/stage/zero`;
return this.thingActionUrl("stage", "set_zero_position");
},
positionStatusUri: function() {
return `${this.baseUri}/stage/position`;
},
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
fastAutofocusUri: function() {
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.getLocalStorageObj("navigation_stepSize") || this.stepSize;
this.invert = this.getLocalStorageObj("navigation_invert") || this.invert;
let self = this;
// A global signal listener to perform a move action
this.$root.$on("globalMoveEvent", (x, y, z, absolute) => {
this.moveRequest(x, y, z, absolute);
});
this.$root.$on("globalMoveEvent", self.move);
// A global signal listener to perform a move action in pixels
this.$root.$on("globalMoveInImageCoordinatesEvent", (x, y, absolute) => {
this.moveInImageCoordinatesRequest(x, y, absolute);
});
// 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.moveRequest(
this.$root.$emit(
"globalMoveEvent",
x_steps * this.stepSize.x * (this.invert.x ? -1 : 1),
y_steps * this.stepSize.y * (this.invert.y ? -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
this.updatePosition();
// Look for autofocus plugin
this.updateAutofocusUri();
this.updateMoveInImageCoordinatesUri();
},
beforeDestroy() {
@ -297,40 +282,32 @@ export default {
},
methods: {
handleSubmit: function() {
this.moveRequest(
this.setPosition.x,
this.setPosition.y,
this.setPosition.z,
true
);
timeout(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
},
moveRequest: function(x, y, z, absolute) {
// If not movement-locked
if (!this.moveLock) {
// Lock move requests
this.moveLock = true;
let move_type = absolute ? "absolute" : "relative";
// Send move request
axios
.post(`${this.baseUri}/stage/move_${move_type}`, {
x: x,
y: y,
z: z
})
.then(() => {
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
});
async move(x, y, z, absolute) {
// Move the stage, by updating the controls and starting a move task
// This is equivalent to clicking the "move" button.
if (this.moveLock) return; // Discard move requests if we're already moving
// NB moveLock is just boolean flag - it's not as safe as a "proper" lock.
this.moveLock = true; // This will also be set by the task submitter, but
// setting it here avoids multiple moves being requested simultaneously.
if (absolute){
this.setPosition = {"x": x, "y": y, "z": z}
}else{
await this.updatePosition();
this.setPosition = {
"x": this.setPosition.x + x,
"y": this.setPosition.y + y,
"z": this.setPosition.z + z
}
}
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) {
// If not movement-locked
if (!this.moveLock) {
@ -373,24 +350,58 @@ export default {
});
},
updatePosition: function() {
axios
.get(this.positionStatusUri)
.then(response => {
this.setPosition = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
async updatePosition() {
this.setPosition = await this.readThingProperty("stage", "position")
},
updateAutofocusUri: function() {
this.fastAutofocusUri = `${this.baseUri}/autofocus/fast_autofocus`;
},
updateMoveInImageCoordinatesUri: function() {
this.moveInImageCoordinatesUri = `${this.baseUri}/camera_stage_mapping/move_in_image_coordinates`;
handleCaptureResponse: async function(response) {
// Retrieve the captured image and save it
let imageUri = response.output.href;
if (!imageUri) {
this.modalError("No image URI returned from capture task.");
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>
<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>
<script>
import axios from "axios";
import CSMCalibrationSettings from "./CSMSettingsComponents/CSMCalibrationSettings.vue";
import miniStreamDisplay from "../../genericComponents/miniStreamDisplay.vue";
@ -31,122 +30,6 @@ export default {
components: {
CSMCalibrationSettings,
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>

View file

@ -1,7 +1,7 @@
<template>
<div id="CSMCalibrationSettings">
<!--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
:button-primary="true"
:can-terminate="false"
@ -9,16 +9,15 @@
:confirmation-message="
'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'"
@response="onRecalibrateResponse"
@error="modalError"
>
</taskSubmitter>
/>
</div>
<button
v-if="'get_calibration' in recalibrationLinks"
v-show="dataAvailable && showExtraSettings"
v-if="'last_calibration' in properties"
v-show="showExtraSettings"
type="button"
class="uk-button uk-button-default uk-width-1-1"
@click="getCalibrationData()"
@ -29,7 +28,6 @@
</template>
<script>
import axios from "axios";
import taskSubmitter from "../../../genericComponents/taskSubmitter";
// Export main app
@ -48,107 +46,44 @@ export default {
}
},
data: function() {
return {
settings: null,
recalibrationLinks: {},
isCalibrating: false,
dataAvailable: false
};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
actions() {
return this.$store.getters["wot/thingDescription"]("camera_stage_mapping")
.actions;
},
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
properties() {
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: {
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: async function() {
try {
let data = await this.readThingProperty(
"camera_stage_mapping",
"last_calibration"
);
if (data == {}) {
throw "No calibration data available.";
}
const dataStr = JSON.stringify(data);
const url = window.URL.createObjectURL(new Blob([dataStr]));
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
}
},
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();
}
}
};

View file

@ -2,6 +2,9 @@
<div id="cameraSettings">
<div class="uk-grid uk-grid-divider uk-child-width-expand" uk-grid>
<div class="uk-width-large">
<h3>Automatic calibration</h3>
<cameraCalibrationSettings :camera-uri="cameraUri" />
<h3>Manual camera settings</h3>
<form @submit.prevent="applySettingsRequest">
<div class="uk-margin-small-bottom">
@ -9,139 +12,47 @@
<li class="uk-open">
<a class="uk-accordion-title" href="#">Pi Camera Settings</a>
<div class="uk-accordion-content">
<div v-if="picamera.shutter_speed !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>Exposure time</label
>
<div class="uk-form-controls">
<input
v-model="picamera.shutter_speed"
class="uk-input uk-form-small"
type="number"
/>
</div>
</div>
<div v-if="picamera.analog_gain !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>Analogue gain</label
>
<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>
<PropertyControl
label="Exposure time"
property-name="exposure_time"
thing-name="camera"
:read-back-delay="1000"
/>
<PropertyControl
label="Analogue gain"
property-name="analogue_gain"
thing-name="camera"
:read-back-delay="1000"
/>
<PropertyControl
label="Colour gains"
property-name="colour_gains"
thing-name="camera"
:read-back-delay="1000"
/>
</div>
</li>
<li class="uk-open">
<a class="uk-accordion-title" href="#">Image Quality</a>
<div class="uk-accordion-content">
<div class="uk-child-width-1-2" uk-grid>
<div v-if="jpeg_quality !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>JPEG capture quality (%)</label
>
<div class="uk-form-controls">
<input
v-model="jpeg_quality"
class="uk-input uk-form-small"
type="number"
step="1"
/>
</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>
<PropertyControl
label="MJPEG stream bit rate"
property-name="mjpeg_bitrate"
thing-name="camera"
:read-back-delay="100"
/>
<PropertyControl
label="MJPEG stream resolution"
property-name="stream_resolution"
thing-name="camera"
:read-back-delay="100"
/>
</div>
</li>
<!--
<li>
<a class="uk-accordion-title" href="#">Advanced</a>
<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
v-if="picamera.framerate !== undefined"
@ -166,27 +77,14 @@
This option sets the framerate of the Pi Camera video
encoder. The actual stream framerate is determined by
network speed, and is limited by the encoder framerate.<br />
<b
>Lowering framerate may impact fast-autofocus
accuracy.</b
>
<b>Lowering framerate may impact fast-autofocus accuracy.</b>
</p>
</div>
</div>
</li>
</li>-->
</ul>
</div>
<button
type="submit"
class="uk-button uk-button-primary uk-margin-small uk-width-1-1"
>
Apply Settings
</button>
</form>
<h3>Automatic calibration</h3>
<cameraCalibrationSettings></cameraCalibrationSettings>
</div>
<div id="mini-stream">
@ -197,9 +95,9 @@
</template>
<script>
import axios from "axios";
import cameraCalibrationSettings from "./cameraSettingsComponents/cameraCalibrationSettings.vue";
import miniStreamDisplay from "../../genericComponents/miniStreamDisplay.vue";
import PropertyControl from "../../labThingsComponents/propertyControl.vue";
// Export main app
export default {
@ -207,7 +105,8 @@ export default {
components: {
cameraCalibrationSettings,
miniStreamDisplay
miniStreamDisplay,
PropertyControl
},
data: function() {
@ -242,74 +141,8 @@ export default {
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
}
},
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
});
cameraUri: function() {
return `${this.$store.getters.baseUri}/camera/`;
}
}
};

View file

@ -1,52 +1,43 @@
<template>
<div>
<!--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
:can-terminate="false"
:requires-confirmation="true"
:confirmation-message="
'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'"
@response="onRecalibrateResponse"
@error="modalError"
>
</taskSubmitter>
</div>
<div
v-if="'auto_exposure_from_raw' in recalibrationLinks"
class="uk-margin-small"
>
<div v-if="'auto_expose_from_minimum' in actions" class="uk-margin-small">
<taskSubmitter
:can-terminate="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'"
@response="onRecalibrateResponse"
@error="modalError"
>
</taskSubmitter>
</div>
<div
v-if="'auto_white_balance_from_raw' in recalibrationLinks"
class="uk-margin-small"
>
<div v-if="'calibrate_white_balance' in actions" class="uk-margin-small">
<taskSubmitter
:can-terminate="false"
:requires-confirmation="false"
:submit-url="recalibrationLinks.auto_white_balance_from_raw.href"
:submit-url="cameraUri + 'calibrate_white_balance'"
:submit-label="'Auto white balance'"
@response="onRecalibrateResponse"
@error="modalError"
>
</taskSubmitter>
</div>
<div
v-if="'auto_lens_shading_table' in recalibrationLinks"
class="uk-margin-small"
>
<div v-if="'calibrate_lens_shading' in actions" class="uk-margin-small">
<taskSubmitter
:can-terminate="false"
:requires-confirmation="true"
@ -54,7 +45,7 @@
'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.'
"
:submit-url="recalibrationLinks.auto_lens_shading_table.href"
:submit-url="cameraUri + 'calibrate_lens_shading'"
:submit-label="'Auto flat field correction'"
@response="onRecalibrateResponse"
@error="modalError"
@ -62,23 +53,35 @@
</taskSubmitter>
</div>
<div v-show="showExtraSettings" class="uk-child-width-expand">
<button
v-if="'flatten_lens_shading_table' in recalibrationLinks"
class="uk-button uk-button-danger uk-width-1-1"
@click="flattenLensShadingTableRequest"
<div
v-show="showExtraSettings"
v-if="'flatten_lens_shading_table' in actions"
class="uk-child-width-expand"
>
<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
</button>
</taskSubmitter>
</div>
<div v-if="LstDownloadEnabled">
<a
class="uk-button uk-button-default uk-width-large uk-margin-small-top uk-align-center"
:href="LstDownloadUri"
download
>Download Lens-Shading Table</a
<div
v-show="showExtraSettings"
v-if="'reset_lens_shading' in actions"
class="uk-child-width-expand"
>
<taskSubmitter
: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>
</template>
@ -100,66 +103,34 @@ export default {
type: Boolean,
required: false,
default: true
},
cameraUri: {
type: String,
required: true
}
},
data: function() {
return {
recalibrationLinks: {},
actions: {},
isCalibrating: 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() {
this.updateRecalibrationLinks();
this.checkLstDownload();
this.updateActions();
},
methods: {
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.calibration.picamera"
);
// 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
});
updateActions: async function() {
try {
let response = await axios.get(this.cameraUri); // Get the thing description
let td = response.data;
this.actions = td.actions;
} catch (error) {
this.modalError(error); // Let mixin handle error
}
},
onRecalibrateResponse: function() {

View file

@ -1,19 +1,6 @@
<template>
<div id="appSettings" class="uk-width-large">
<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 }">
<label :disabled="!imjoyPermitted">
<input
@ -57,14 +44,6 @@ export default {
},
computed: {
IHIEnabled: {
get() {
return this.$store.state.IHIEnabled;
},
set(value) {
this.$store.commit("changeIHIEnabled", value);
}
},
imjoyEnabled: {
get() {
return this.$store.state.imjoyEnabled;
@ -90,9 +69,6 @@ export default {
},
watch: {
IHIEnabled: function() {
this.setLocalStorageObj("IHIEnabled", this.IHIEnabled);
},
imjoyEnabled: function() {
this.setLocalStorageObj("imjoyEnabled", this.imjoyEnabled);
},
@ -103,7 +79,6 @@ export default {
mounted() {
// Try loading settings from localStorage. If null, don't change.
this.IHIEnabled = this.getLocalStorageObj("IHIEnabled") || this.IHIEnabled;
this.imjoyEnabled =
this.getLocalStorageObj("imjoyEnabled") || this.imjoyEnabled;
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>
<div id="stageSettings" class="uk-width-large">
<div v-if="stageType === 'MissingStage'" class="uk-text-danger">
<b>No stage connected</b>
</div>
<div v-else>
<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>
The microscope stage is a <b>{{ stageType }}</b
>. There are no settings to adjust here: to set the geometry of your stage
(i.e. switch between delta and cartesian), you will need to adjust the
configuration of your microscope, which cannot be done while it is running.
</div>
</template>
<script>
import axios from "axios";
export default {
name: "StageSettings",
components: {},
data: function() {
return {
stageType: "MissingStage",
backlash: {
x: undefined,
y: undefined,
z: undefined
},
settle_time: undefined
};
return {};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
},
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);
});
stageType: function() {
return this.thingDescription("stage").title;
}
}
};

View file

@ -70,19 +70,6 @@
Camera/stage mapping
</tabIcon>
</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>
</div>
<div class="view-component uk-width-expand uk-padding-small">
@ -136,24 +123,12 @@
<CSMSettings />
</div>
</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>
</template>
<script>
import streamSettings from "./settingsComponents/streamSettings.vue";
import microscopeSettings from "./settingsComponents/microscopeSettings.vue";
import cameraSettings from "./settingsComponents/cameraSettings.vue";
import appSettings from "./settingsComponents/appSettings.vue";
import featuresSettings from "./settingsComponents/featuresSettings.vue";
@ -171,7 +146,6 @@ export default {
streamSettings,
cameraSettings,
stageSettings,
microscopeSettings,
CSMSettings,
appSettings,
featuresSettings,

View file

@ -134,7 +134,6 @@
</template>
<script>
import axios from "axios";
import taskSubmitter from "../../genericComponents/taskSubmitter";
export default {
@ -166,9 +165,6 @@ export default {
},
computed: {
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
},
currentTimeForm: {
get() {
// Chop the timezone information from the end
@ -210,30 +206,7 @@ export default {
}
},
mounted: function() {
this.updateScanUri();
},
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) {
this.scanRunning = false;
this.modalError(error);

View file

@ -58,9 +58,6 @@ export default {
},
streamImgUri: function() {
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 App from "./App.vue";
import store from "./store";
import axios from "axios";
import UIkit from "uikit";
import VueTour from "vue-tour";
import VueFriendlyIframe from "vue-friendly-iframe";
@ -34,6 +35,49 @@ Vue.config.productionTip = false;
Vue.mixin({
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) {
var context = this;
@ -86,6 +130,7 @@ Vue.mixin({
modalError: function(error) {
var errormsg = this.getErrorMessage(error);
this.$store.commit("setErrorMessage", errormsg);
console.log("Modal error:", error);
UIkit.notification({
message: `${errormsg}`,
status: "danger"

View file

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