From 3e69ea4a1247acf9df3807066020a2074dfcc6c0 Mon Sep 17 00:00:00 2001 From: Mike Cousins Date: Tue, 22 Jan 2019 16:00:33 -0500 Subject: [PATCH] feat(app): Configure analytics to send Python and JSON protocol info Closes #2615, closes #2618 --- app/src/analytics/README.md | 35 ++- .../analytics/__tests__/make-event.test.js | 252 +++++++++++------- app/src/analytics/hash.js | 22 ++ app/src/analytics/index.js | 63 +++-- app/src/analytics/make-event.js | 125 +++++---- app/src/analytics/selectors.js | 52 ++++ app/src/analytics/types.js | 16 ++ app/src/protocol/index.js | 32 ++- app/src/protocol/protocol-data.js | 12 +- app/src/protocol/types.js | 2 + app/src/robot/api-client/client.js | 3 +- 11 files changed, 430 insertions(+), 184 deletions(-) create mode 100644 app/src/analytics/hash.js create mode 100644 app/src/analytics/selectors.js create mode 100644 app/src/analytics/types.js diff --git a/app/src/analytics/README.md b/app/src/analytics/README.md index 195df49280e..97ce9d482f2 100644 --- a/app/src/analytics/README.md +++ b/app/src/analytics/README.md @@ -25,7 +25,11 @@ const store = createStore(reducer, middleware) For a given Redux action, add a `case` to the `action.type` switch in [`app/src/analytics/make-event.js`](./make-event.js). `makeEvent` will be passed the full state and the action. Any new case should return either `null` or an object `{name: string, properties: {}}` ```js -export default function makeEvent (state: State, action: Action): ?Event { +export default function makeEvent ( + action: Action, + nextState: State, + prevState: State +): null | AnalyticsEvent | Promise { switch (action.type) { // ... case 'some-action-type': @@ -37,3 +41,32 @@ export default function makeEvent (state: State, action: Action): ?Event { return null } ``` + +## events + +| name | redux action | payload | +| ------------------------ | ------------------------------ | --------------------------------------- | +| `robotConnect` | `robot:CONNECT_RESPONSE` | success, method, error | +| `protocolUploadRequest` | `protocol:UPLOAD` | protocol data | +| `protocolUploadResponse` | `robot:SESSION_RESPONSE/ERROR` | protocol data, success, error | +| `runStart` | `robot:RUN` | protocol data | +| `runFinish` | `robot:RUN_RESPONSE` | protocol data, success, error, run time | +| `runPause` | `robot:PAUSE` | protocol, run time data | +| `runResume` | `robot:RESUME` | protocol, run time data | +| `runCancel` | `robot:CANCEL` | protocol, run time data | + +### hashing + +Some payload fields are [hashed][] for user anonymity while preserving our ability to disambiguate unique values. Fields are hashed with the SHA-256 algorithm and are noted as such in this section. + +### protocol data sent + +- Protocol type (Python or JSON) +- Application Name (e.g. "Opentrons Protocol Designer") +- Application Version +- Protocol `metadata.source` +- Protocol `metadata.protocolName` +- Protocol `metadata.author` (hashed for anonymity) +- Protocol `metadata.protocolText` (hashed for anonymity) + +[hashed]: https://en.wikipedia.org/wiki/Hash_function diff --git a/app/src/analytics/__tests__/make-event.test.js b/app/src/analytics/__tests__/make-event.test.js index c942c023b01..b3193b711d0 100644 --- a/app/src/analytics/__tests__/make-event.test.js +++ b/app/src/analytics/__tests__/make-event.test.js @@ -1,26 +1,20 @@ // events map tests -import {LOCATION_CHANGE} from 'react-router-redux' - import makeEvent from '../make-event' import {actions as robotActions} from '../../robot' +import * as selectors from '../selectors' -describe('analytics events map', () => { - test('@@router/LOCATION_CHANGE -> url event', () => { - const state = {} - // TODO(mc, 2018-05-28): this type has changed since @beta.6 - const action = {type: LOCATION_CHANGE, payload: {pathname: '/foo'}} +jest.mock('../selectors') - expect(makeEvent(state, action)).toEqual({ - name: 'url', - properties: {pathname: '/foo'}, - }) +describe('analytics events map', () => { + beforeEach(() => { + jest.resetAllMocks() }) test('robot:CONNECT_RESPONSE -> robotConnected event', () => { const state = name => ({ robot: { connection: { - connectRequest: {name}, + connectedTo: name, }, }, discovery: { @@ -56,133 +50,191 @@ describe('analytics events map', () => { const success = robotActions.connectResponse() const failure = robotActions.connectResponse(new Error('AH')) - expect(makeEvent(state('wired'), success)).toEqual({ + expect(makeEvent(success, state('wired'))).toEqual({ name: 'robotConnect', properties: {method: 'usb', success: true, error: ''}, }) - expect(makeEvent(state('wired'), failure)).toEqual({ + expect(makeEvent(failure, state('wired'))).toEqual({ name: 'robotConnect', properties: {method: 'usb', success: false, error: 'AH'}, }) - expect(makeEvent(state('wireless'), success)).toEqual({ + expect(makeEvent(success, state('wireless'))).toEqual({ name: 'robotConnect', properties: {method: 'wifi', success: true, error: ''}, }) - expect(makeEvent(state('wireless'), failure)).toEqual({ + expect(makeEvent(failure, state('wireless'))).toEqual({ name: 'robotConnect', properties: {method: 'wifi', success: false, error: 'AH'}, }) }) - test('robot:SESSION_RESPONSE/ERROR -> protocolUpload event', () => { - const state = {} - const success = {type: 'robot:SESSION_RESPONSE', payload: {}} - const failure = { - type: 'robot:SESSION_ERROR', - payload: {error: new Error('AH')}, - } + describe('events with protocol data', () => { + var protocolData = {foo: 'bar'} - expect(makeEvent(state, success)).toEqual({ - name: 'protocolUpload', - properties: {success: true, error: ''}, + beforeEach(() => { + selectors.getProtocolAnalyticsData.mockResolvedValue(protocolData) }) - expect(makeEvent(state, failure)).toEqual({ - name: 'protocolUpload', - properties: {success: false, error: 'AH'}, + test('robot:PROTOCOL_UPLOAD > protocolUploadRequest', () => { + const prevState = {} + const nextState = {} + const success = {type: 'protocol:UPLOAD', payload: {}} + + return expect(makeEvent(success, nextState, prevState)).resolves.toEqual({ + name: 'protocolUploadRequest', + properties: protocolData, + }) }) - }) - test('robot:RUN -> runStart event', () => { - const state = {} - const action = {type: 'robot:RUN'} + test('robot:SESSION_RESPONSE with upload in flight', () => { + const prevState = {robot: {session: {sessionRequest: {inProgress: true}}}} + const nextState = {} + const success = {type: 'robot:SESSION_RESPONSE', payload: {}} - expect(makeEvent(state, action)).toEqual({ - name: 'runStart', - properties: {}, + return expect(makeEvent(success, nextState, prevState)).resolves.toEqual({ + name: 'protocolUploadResponse', + properties: {success: true, error: '', ...protocolData}, + }) }) - }) - test('robot:PAUSE_RESPONSE -> runPause event', () => { - const state = {} - const success = {type: 'robot:PAUSE_RESPONSE'} - const failure = {type: 'robot:PAUSE_RESPONSE', error: new Error('AH')} + test('robot:SESSION_ERROR with upload in flight', () => { + const prevState = {robot: {session: {sessionRequest: {inProgress: true}}}} + const nextState = {} + const failure = { + type: 'robot:SESSION_ERROR', + payload: {error: new Error('AH')}, + } + + return expect(makeEvent(failure, nextState, prevState)).resolves.toEqual({ + name: 'protocolUploadResponse', + properties: {success: false, error: 'AH', ...protocolData}, + }) + }) - expect(makeEvent(state, success)).toEqual({ - name: 'runPause', - properties: { - success: true, - error: '', - }, + test('robot:SESSION_RESPONSE/ERROR with no upload in flight', () => { + const prevState = { + robot: {session: {sessionRequest: {inProgress: false}}}, + } + const nextState = {} + const success = {type: 'robot:SESSION_RESPONSE', payload: {}} + const failure = { + type: 'robot:SESSION_ERROR', + payload: {error: new Error('AH')}, + } + + expect(makeEvent(success, nextState, prevState)).toBeNull() + expect(makeEvent(failure, nextState, prevState)).toBeNull() }) - expect(makeEvent(state, failure)).toEqual({ - name: 'runPause', - properties: { - success: false, - error: 'AH', - }, + test('robot:RUN -> runStart event', () => { + const state = {} + const action = {type: 'robot:RUN'} + + return expect(makeEvent(action, state)).resolves.toEqual({ + name: 'runStart', + properties: protocolData, + }) }) - }) - test('robot:CANCEL_REPSONSE -> runCancel event', () => { - const state = { - robot: { - session: { - startTime: 1000, - runTime: 5000, + test('robot:RUN_RESPONSE success -> runFinish event', () => { + const state = { + robot: { + session: { + startTime: 1000, + runTime: 5000, + }, }, - }, - } - const success = {type: 'robot:CANCEL_RESPONSE'} - const failure = {type: 'robot:CANCEL_RESPONSE', error: new Error('AH')} - - expect(makeEvent(state, success)).toEqual({ - name: 'runCancel', - properties: { - runTime: 4, - success: true, - error: '', - }, - }) + } + const action = {type: 'robot:RUN_RESPONSE', error: false} - expect(makeEvent(state, failure)).toEqual({ - name: 'runCancel', - properties: { - runTime: 4, - success: false, - error: 'AH', - }, + return expect(makeEvent(action, state)).resolves.toEqual({ + name: 'runFinish', + properties: {...protocolData, runTime: 4, success: true, error: ''}, + }) }) - }) - test('robot:RUN_RESPONSE success -> runFinish event', () => { - const state = { - robot: { - session: { - startTime: 1000, - runTime: 5000, + test('robot:RUN_RESPONSE error -> runFinish event', () => { + const state = { + robot: { + session: { + startTime: 1000, + runTime: 5000, + }, }, - }, - } - const action = {type: 'robot:RUN_RESPONSE', error: false} + } + const action = { + type: 'robot:RUN_RESPONSE', + error: true, + payload: new Error('AH'), + } + + return expect(makeEvent(action, state)).resolves.toEqual({ + name: 'runFinish', + properties: {...protocolData, runTime: 4, success: false, error: 'AH'}, + }) + }) - expect(makeEvent(state, action)).toEqual({ - name: 'runFinish', - properties: {runTime: 4}, + test('robot:PAUSE -> runPause event', () => { + const state = { + robot: { + session: { + startTime: 1000, + runTime: 5000, + }, + }, + } + const action = {type: 'robot:PAUSE'} + + return expect(makeEvent(action, state)).resolves.toEqual({ + name: 'runPause', + properties: { + ...protocolData, + runTime: 4, + }, + }) }) - }) - test('robot:RUN_RESPONSE error -> runError event', () => { - const state = {} - const action = {type: 'robot:RUN_RESPONSE', error: new Error('AH')} + test('robot:RESUME -> runResume event', () => { + const state = { + robot: { + session: { + startTime: 1000, + runTime: 5000, + }, + }, + } + const action = {type: 'robot:RESUME'} + + return expect(makeEvent(action, state)).resolves.toEqual({ + name: 'runResume', + properties: { + ...protocolData, + runTime: 4, + }, + }) + }) - expect(makeEvent(state, action)).toEqual({ - name: 'runError', - properties: {error: 'AH'}, + test('robot:CANCEL-> runCancel event', () => { + const state = { + robot: { + session: { + startTime: 1000, + runTime: 5000, + }, + }, + } + const action = {type: 'robot:CANCEL'} + + return expect(makeEvent(action, state)).resolves.toEqual({ + name: 'runCancel', + properties: { + ...protocolData, + runTime: 4, + }, + }) }) }) }) diff --git a/app/src/analytics/hash.js b/app/src/analytics/hash.js new file mode 100644 index 00000000000..dd66aff6719 --- /dev/null +++ b/app/src/analytics/hash.js @@ -0,0 +1,22 @@ +// @flow +// hash strings for an amount of anonymity +// note: values will be _hashed_, not _enctrypted_; hashed values should not be +// considered secure nor should they ever be released publicly +const ALGORITHM = 'SHA-256' + +export default function hash (source: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(source) + + // https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest + return global.crypto.subtle + .digest(ALGORITHM, data) + .then((digest: ArrayBuffer) => arrayBufferToHex(digest)) +} + +// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#Converting_a_digest_to_a_hex_string +function arrayBufferToHex (source: ArrayBuffer): string { + const bytes = new Uint8Array(source) + + return [...bytes].map(b => b.toString(16).padStart(2, '0')).join('') +} diff --git a/app/src/analytics/index.js b/app/src/analytics/index.js index cb1afe4f8ad..92c2d6977f4 100644 --- a/app/src/analytics/index.js +++ b/app/src/analytics/index.js @@ -3,14 +3,15 @@ import noop from 'lodash/noop' import mixpanel from 'mixpanel-browser' -import type {State, ThunkAction, Middleware} from '../types' -import type {Config} from '../config' - import {version} from '../../package.json' import {updateConfig} from '../config' import createLogger from '../logger' import makeEvent from './make-event' +import type {State, Action, ThunkAction, Middleware} from '../types' +import type {Config} from '../config' +import type {AnalyticsEvent} from './types' + type AnalyticsConfig = $PropertyType const log = createLogger(__filename) @@ -50,33 +51,33 @@ export function setAnalyticsSeen () { return updateConfig('analytics.seenOptIn', true) } -export const analyticsMiddleware: Middleware = - (store) => (next) => (action) => { - const state = store.getState() - const event = makeEvent(store.getState(), action) +export const analyticsMiddleware: Middleware = store => next => action => { + const prevState = store.getState() - if (event) { - log.debug('Trackable event', {type: action.type, event}) - track(event.name, event.properties) - } + // hit reducers to get the next state + const result = next(action) + const nextState = store.getState() + const event = makeEvent(action, nextState, prevState) - // enable mixpanel tracking if optedIn goes to true - if ( - action.type === 'config:SET' && - action.payload.path === 'analytics.optedIn' - ) { - const config = state.config.analytics - - if (action.payload.value === true) { - enableMixpanelTracking(config) - } else { - disableMixpanelTracking(config) - } - } + trackEvent(action, event) + + // enable mixpanel tracking if optedIn goes to true + if ( + action.type === 'config:SET' && + action.payload.path === 'analytics.optedIn' + ) { + const config = nextState.config.analytics - return next(action) + if (action.payload.value === true) { + enableMixpanelTracking(config) + } else { + disableMixpanelTracking(config) + } } + return result +} + export function getAnalyticsOptedIn (state: State) { return state.config.analytics.optedIn } @@ -85,6 +86,18 @@ export function getAnalyticsSeen (state: State) { return state.config.analytics.seenOptIn } +function trackEvent ( + action: Action, + event: null | AnalyticsEvent | Promise +) { + if (event && event instanceof Promise) { + event.then(e => trackEvent(action, e)) + } else if (event) { + log.debug('Trackable event', {type: action.type, event}) + track(event.name, event.properties) + } +} + function initializeMixpanel (config: AnalyticsConfig) { if (MIXPANEL_ID) { log.debug('Initializing Mixpanel') diff --git a/app/src/analytics/make-event.js b/app/src/analytics/make-event.js index 3a4fef91b6d..229ef5f335f 100644 --- a/app/src/analytics/make-event.js +++ b/app/src/analytics/make-event.js @@ -1,27 +1,23 @@ // @flow // redux action types to analytics events map -import find from 'lodash/find' import createLogger from '../logger' import {selectors as robotSelectors} from '../robot' -import {getConnectableRobots} from '../discovery' +import {getConnectedRobot} from '../discovery' +import {getProtocolAnalyticsData} from './selectors' import type {State, Action} from '../types' - -type Event = { - name: string, - properties: {}, -} +import type {AnalyticsEvent} from './types' const log = createLogger(__filename) -export default function makeEvent (state: State, action: Action): ?Event { +export default function makeEvent ( + action: Action, + nextState: State, + prevState: State +): null | AnalyticsEvent | Promise { switch (action.type) { - case '@@router/LOCATION_CHANGE': - return {name: 'url', properties: {pathname: action.payload.pathname}} - case 'robot:CONNECT_RESPONSE': - const name = state.robot.connection.connectRequest.name - const robot = find(getConnectableRobots(state), {name}) + const robot = getConnectedRobot(nextState) if (!robot) { log.warn('No robot found for connect response') @@ -37,55 +33,82 @@ export default function makeEvent (state: State, action: Action): ?Event { }, } + // TODO (ka, 2018-6-6): add file open type 'button' | 'drag-n-drop' (work required in action meta) + case 'protocol:UPLOAD': { + return getProtocolAnalyticsData(nextState).then(data => ({ + name: 'protocolUploadRequest', + properties: data, + })) + } + case 'robot:SESSION_RESPONSE': - case 'robot:SESSION_ERROR': - // TODO (ka, 2018-6-6): add file open type 'button' | 'drag-n-drop' (work required in action meta) - return { - name: 'protocolUpload', + case 'robot:SESSION_ERROR': { + // only fire event if we had a protocol upload in flight; we don't want + // to fire if user connects to robot with protocol already loaded + if (!prevState.robot.session.sessionRequest.inProgress) return null + const {type: actionType, payload: actionPayload} = action + + return getProtocolAnalyticsData(nextState).then(data => ({ + name: 'protocolUploadResponse', properties: { - success: action.type === 'robot:SESSION_RESPONSE', - error: (action.payload.error && action.payload.error.message) || '', + ...data, + success: actionType === 'robot:SESSION_RESPONSE', + error: (actionPayload.error && actionPayload.error.message) || '', }, - } + })) + } // $FlowFixMe(mc, 2018-05-28): flow type robot:RUN - case 'robot:RUN': - return {name: 'runStart', properties: {}} + case 'robot:RUN': { + return getProtocolAnalyticsData(nextState).then(data => ({ + name: 'runStart', + properties: data, + })) + } + // TODO(mc, 2019-01-22): we only get this event if the user keeps their app + // open for the entire run. Fixing this is blocked until we can fix + // session.stop from triggering a run error // $FlowFixMe(mc, 2018-05-28): flow type robot:RUN_RESPONSE - case 'robot:RUN_RESPONSE': - if (!action.error) { - const runTime = robotSelectors.getRunSeconds(state) - return {name: 'runFinish', properties: {runTime}} - } else { - return { - name: 'runError', - properties: { - error: action.error.message, - }, - } - } - // $FlowFixMe(ka, 2018-06-5): flow type robot:PAUSE_RESPONSE - case 'robot:PAUSE_RESPONSE': - return { + case 'robot:RUN_RESPONSE': { + const runTime = robotSelectors.getRunSeconds(nextState) + const success = !action.error + const error = action.error ? action.payload.message || '' : '' + + return getProtocolAnalyticsData(nextState).then(data => ({ + name: 'runFinish', + properties: {...data, runTime, success, error}, + })) + } + + // $FlowFixMe(ka, 2018-06-5): flow type robot:PAUSE + case 'robot:PAUSE': { + const runTime = robotSelectors.getRunSeconds(nextState) + + return getProtocolAnalyticsData(nextState).then(data => ({ name: 'runPause', - properties: { - success: !action.error, - error: (action.error && action.error.message) || '', - }, - } + properties: {...data, runTime}, + })) + } + + // $FlowFixMe(ka, 2018-06-5): flow type robot:RESUME + case 'robot:RESUME': { + const runTime = robotSelectors.getRunSeconds(nextState) + + return getProtocolAnalyticsData(nextState).then(data => ({ + name: 'runResume', + properties: {...data, runTime}, + })) + } // $FlowFixMe(ka, 2018-06-5): flow type robot:CANCEL - case 'robot:CANCEL_RESPONSE': - const runTime = robotSelectors.getRunSeconds(state) - return { + case 'robot:CANCEL': + const runTime = robotSelectors.getRunSeconds(nextState) + + return getProtocolAnalyticsData(nextState).then(data => ({ name: 'runCancel', - properties: { - runTime, - success: !action.error, - error: (action.error && action.error.message) || '', - }, - } + properties: {...data, runTime}, + })) } return null diff --git a/app/src/analytics/selectors.js b/app/src/analytics/selectors.js new file mode 100644 index 00000000000..96233d1b404 --- /dev/null +++ b/app/src/analytics/selectors.js @@ -0,0 +1,52 @@ +// @flow +import {createSelector} from 'reselect' + +import { + getProtocolType, + getProtocolCreatorApp, + getProtocolName, + getProtocolSource, + getProtocolAuthor, + getProtocolContents, +} from '../protocol' + +import hash from './hash' + +import type {OutputSelector} from 'reselect' +import type {State} from '../types' +import type {ProtocolAnalyticsData} from './types' + +type ProtocolDataSelector = OutputSelector + +const _getUnhashedProtocolAnalyticsData: ProtocolDataSelector = createSelector( + getProtocolType, + getProtocolCreatorApp, + getProtocolName, + getProtocolSource, + getProtocolAuthor, + getProtocolContents, + (type, app, name, source, author, contents) => ({ + protocolType: type || '', + protocolAppName: app.name || '', + protocolAppVersion: app.version || '', + protocolName: name || '', + protocolSource: source || '', + protocolAuthor: author || '', + protocolText: contents || '', + }) +) + +// TODO(mc, 2019-01-22): it would be good to have some way of caching these +// hashes; reselect isn't geared towards async, so perhaps RxJS / observables? +export function getProtocolAnalyticsData ( + state: State +): Promise { + const data = _getUnhashedProtocolAnalyticsData(state) + const hashTasks = [hash(data.protocolAuthor), hash(data.protocolText)] + + return Promise.all(hashTasks).then(result => { + const [protocolAuthor, protocolText] = result + + return {...data, protocolAuthor, protocolText} + }) +} diff --git a/app/src/analytics/types.js b/app/src/analytics/types.js new file mode 100644 index 00000000000..09947d02ca4 --- /dev/null +++ b/app/src/analytics/types.js @@ -0,0 +1,16 @@ +// @flow + +export type ProtocolAnalyticsData = { + protocolType: string, + protocolAppName: string, + protocolAppVersion: string, + protocolSource: string, + protocolName: string, + protocolAuthor: string, + protocolText: string, +} + +export type AnalyticsEvent = {| + name: string, + properties: {}, +|} diff --git a/app/src/protocol/index.js b/app/src/protocol/index.js index 069cb57cecd..56b583fe7d8 100644 --- a/app/src/protocol/index.js +++ b/app/src/protocol/index.js @@ -8,12 +8,18 @@ import { fileToProtocolFile, parseProtocolData, fileIsJson, - filenameToType, + fileToType, + filenameToMimeType, } from './protocol-data' import type {OutputSelector} from 'reselect' import type {State, Action, ThunkAction} from '../types' -import type {ProtocolState, ProtocolFile, ProtocolData} from './types' +import type { + ProtocolState, + ProtocolFile, + ProtocolData, + ProtocolType, +} from './types' export * from './types' @@ -73,7 +79,7 @@ export function protocolReducer ( const {name, metadata, protocolText: contents} = action.payload const file = !state.file || name !== state.file.name - ? {name, type: filenameToType(name), lastModified: null} + ? {name, type: filenameToMimeType(name), lastModified: null} : state.file const data = !state.data || contents !== state.contents @@ -94,12 +100,17 @@ type StringGetter = (?ProtocolData) => ?string type NumberGetter = (?ProtocolData) => ?number type StringSelector = OutputSelector type NumberSelector = OutputSelector +type ProtocolTypeSelector = OutputSelector +type CreatorAppSelector = OutputSelector const getName: StringGetter = getter('metadata.protocol-name') const getAuthor: StringGetter = getter('metadata.author') const getDesc: StringGetter = getter('metadata.description') const getCreated: NumberGetter = getter('metadata.created') const getLastModified: NumberGetter = getter('metadata.last-modified') +const getSource: NumberGetter = getter('metadata.source') const getAppName: StringGetter = getter('designer-application.application-name') const getAppVersion: StringGetter = getter( 'designer-application.application-version' @@ -136,6 +147,11 @@ export const getProtocolDescription: StringSelector = createSelector( data => getDesc(data) ) +export const getProtocolSource: StringSelector = createSelector( + getProtocolData, + data => getSource(data) +) + export const getProtocolLastUpdated: NumberSelector = createSelector( getProtocolFile, getProtocolData, @@ -143,6 +159,16 @@ export const getProtocolLastUpdated: NumberSelector = createSelector( getLastModified(data) || getCreated(data) || (file && file.lastModified) ) +export const getProtocolType: ProtocolTypeSelector = createSelector( + getProtocolFile, + fileToType +) + +export const getProtocolCreatorApp: CreatorAppSelector = createSelector( + getProtocolData, + data => ({name: getAppName(data), version: getAppVersion(data)}) +) + const METHOD_OT_API = 'Opentrons API' const METHOD_UNKNOWN = 'Unknown Application' diff --git a/app/src/protocol/protocol-data.js b/app/src/protocol/protocol-data.js index 9ab061f9363..0933c25cdfc 100644 --- a/app/src/protocol/protocol-data.js +++ b/app/src/protocol/protocol-data.js @@ -4,7 +4,7 @@ // import {getter} from '@thi.ng/paths' import createLogger from '../logger' -import type {ProtocolFile, ProtocolData} from './types' +import type {ProtocolFile, ProtocolData, ProtocolType} from './types' const log = createLogger(__filename) @@ -25,7 +25,7 @@ export function parseProtocolData ( contents: string, // optional Python protocol metadata metadata: ?$PropertyType -): ?ProtocolData { +): ProtocolData | null { if (fileIsJson(file)) { try { return JSON.parse(contents) @@ -41,12 +41,18 @@ export function parseProtocolData ( return null } -export function filenameToType (name: string): ?string { +export function filenameToMimeType (name: string): string | null { if (name.endsWith('.json')) return MIME_TYPE_JSON if (name.endsWith('.py')) return MIME_TYPE_PYTHON return null } +export function fileToType (file: ProtocolFile): ProtocolType | null { + if (file.type === MIME_TYPE_JSON) return 'json' + if (file.type === MIME_TYPE_PYTHON) return 'python' + return null +} + export function fileIsJson (file: ProtocolFile): boolean { return file.type === MIME_TYPE_JSON } diff --git a/app/src/protocol/types.js b/app/src/protocol/types.js index 7300bfda979..2169535955c 100644 --- a/app/src/protocol/types.js +++ b/app/src/protocol/types.js @@ -29,3 +29,5 @@ export type ProtocolState = { contents: ?string, data: ?ProtocolData, } + +export type ProtocolType = 'json' | 'python' diff --git a/app/src/robot/api-client/client.js b/app/src/robot/api-client/client.js index 6c0dd71fd9a..2543221ab2b 100755 --- a/app/src/robot/api-client/client.js +++ b/app/src/robot/api-client/client.js @@ -153,7 +153,8 @@ export default function client (dispatch) { .create(name, contents) .then(apiSession => { remote.session_manager.session = apiSession - handleApiSession(apiSession) + // state change will trigger a session notification, which will + // dispatch a successful sessionResponse }) .catch(error => dispatch(actions.sessionResponse(error))) }