diff --git a/.github/workflows/docker-release-build.yml b/.github/workflows/docker-release-build.yml index 0defa02a6e..c51d60ec23 100644 --- a/.github/workflows/docker-release-build.yml +++ b/.github/workflows/docker-release-build.yml @@ -138,7 +138,7 @@ jobs: id: buildx uses: docker/setup-buildx-action@v2 with: - version: latest + version: v0.9.1 - name: ↙️ Download build artifact uses: actions/download-artifact@v3 with: diff --git a/front/package-lock.json b/front/package-lock.json index 2cf2687b4c..543a423843 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "gladys-front", "dependencies": { - "@gladysassistant/gladys-gateway-js": "^4.5.0", + "@gladysassistant/gladys-gateway-js": "^4.8.1", "@gladysassistant/theme-optimized": "^1.0.3", "@jaames/iro": "^5.5.2", "@yaireo/tagify": "^4.5.0", @@ -4089,9 +4089,9 @@ "dev": true }, "node_modules/@gladysassistant/gladys-gateway-js": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@gladysassistant/gladys-gateway-js/-/gladys-gateway-js-4.5.0.tgz", - "integrity": "sha512-+Purv/yJqQwTpi0foO9ssdbPWtDuWr9TnRnqpbfMF2ipVVZN8mk54A7syHDIC7l2UmFyNCzW9EEKXwZ7cY7gzw==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@gladysassistant/gladys-gateway-js/-/gladys-gateway-js-4.8.1.tgz", + "integrity": "sha512-2LuyxOUaUiBfEdMrQ5iXPSvC1u33NhC/zf7cTGgkDqVlmQriqSE0vFUgeihpQrK8p5IlHXwnd482JK+kPj/lHw==", "dependencies": { "@ctrlpanel/pbkdf2": "^1.0.0", "array-buffer-to-hex": "^1.0.0", @@ -36670,9 +36670,9 @@ "dev": true }, "@gladysassistant/gladys-gateway-js": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@gladysassistant/gladys-gateway-js/-/gladys-gateway-js-4.5.0.tgz", - "integrity": "sha512-+Purv/yJqQwTpi0foO9ssdbPWtDuWr9TnRnqpbfMF2ipVVZN8mk54A7syHDIC7l2UmFyNCzW9EEKXwZ7cY7gzw==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@gladysassistant/gladys-gateway-js/-/gladys-gateway-js-4.8.1.tgz", + "integrity": "sha512-2LuyxOUaUiBfEdMrQ5iXPSvC1u33NhC/zf7cTGgkDqVlmQriqSE0vFUgeihpQrK8p5IlHXwnd482JK+kPj/lHw==", "requires": { "@ctrlpanel/pbkdf2": "^1.0.0", "array-buffer-to-hex": "^1.0.0", diff --git a/front/package.json b/front/package.json index 6a4a09ca97..11f91ed621 100644 --- a/front/package.json +++ b/front/package.json @@ -44,7 +44,7 @@ "prettier": "^1.17.1" }, "dependencies": { - "@gladysassistant/gladys-gateway-js": "^4.5.0", + "@gladysassistant/gladys-gateway-js": "^4.8.1", "@gladysassistant/theme-optimized": "^1.0.3", "@jaames/iro": "^5.5.2", "@yaireo/tagify": "^4.5.0", diff --git a/front/src/assets/integrations/cover/openai.jpg b/front/src/assets/integrations/cover/openai.jpg new file mode 100644 index 0000000000..9170a8d4eb Binary files /dev/null and b/front/src/assets/integrations/cover/openai.jpg differ diff --git a/front/src/components/app.jsx b/front/src/components/app.jsx index 8d937b1c7a..82a6de31ac 100644 --- a/front/src/components/app.jsx +++ b/front/src/components/app.jsx @@ -119,6 +119,9 @@ import EweLinkEditPage from '../routes/integration/all/ewelink/edit-page'; import EweLinkDiscoverPage from '../routes/integration/all/ewelink/discover-page'; import EweLinkSetupPage from '../routes/integration/all/ewelink/setup-page'; +// OpenAI integration +import OpenAIPage from '../routes/integration/all/openai/index'; + const defaultState = getDefaultState(); const store = createStore(defaultState); @@ -230,6 +233,7 @@ const AppRouter = connect( + diff --git a/front/src/components/boxs/ecowatt/Ecowatt.jsx b/front/src/components/boxs/ecowatt/Ecowatt.jsx index b07d445224..2f9c4a267a 100644 --- a/front/src/components/boxs/ecowatt/Ecowatt.jsx +++ b/front/src/components/boxs/ecowatt/Ecowatt.jsx @@ -117,7 +117,7 @@ class Ecowatt extends Component { days.push({ day: dayjs(day.jour) .locale(this.props.user.language) - .format('LL'), + .format('ddd LL'), data: day.dvalue }); }); diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 1e7802c664..80670c0e2f 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -189,6 +189,7 @@ "editDashboardTitle": "Edit dashboard", "editDashboardDeleteButton": "Delete", "editDashboardCancelButton": "Cancel", + "editDashboardDeleteText": "Are you sure you want to delete this dashboard?", "editDashboardSaveButton": "Save", "emptyDashboardSentenceTop": "Looks like your dashboard is not configured yet.", "emptyDashboardSentenceBottom": "Click on the \"Edit\" button to design your dashboard.", @@ -1046,6 +1047,21 @@ "defaultDeletionError": "There was an error deleting the device.", "conflictError": "Current device is already in Gladys." } + }, + "openai": { + "title": "OpenAI GPT-3", + "description": "Give Gladys the power of artifical intelligence", + "firstExplanation": "This is an alpha integration that let you talk to OpenAI GPT-3.", + "itDoesNothing": "It has no action in Gladys, it's just a proof-of-concept.", + "aFewExamples": "A few examples: ", + "turnOnTheLight": "Turn on the light in the kitchen", + "sizeOfEiffelTower": "What's the size of the Eiffel Tower ?", + "whoIsJulesVerne": "Who is Jules Verne ?", + "eggDuration": "How do you make hard boiled eggs ?", + "rateLimit": "As GPT-3 API is not free, this integration is only available to Gladys Plus users and limited to 100 requests per month.", + "notOnGladysPlus": "As GPT-3 API is not free, this integration is only available to Gladys Plus users.", + "subscribeToGladysPlus": "Click here to subscribe to Gladys Plus.", + "licenseShouldBeActive": "This integration is only available to users with an active license (at least one payment). For trial users, please contact us on the forum or email." } }, "editScene": { @@ -2313,6 +2329,12 @@ "title": "Billing", "informationTitle": "Your billing information", "stripeDescription": "Billing is handled by Stripe. We never see you credit card.", - "stripeButton": "Edit subscription in Stripe" + "stripeButton": "Edit subscription in Stripe", + "yourCurrentPlan": "Your plan", + "monthlyPlan": "You are currently using the monthly plan. If you want to switch to the annual plan (99.99€/year), you can click on the button below. The first payment will be prorated based on your current month.", + "yearlyPlan": "You are currently on the annual plan.", + "upgradeToYearly": "Switch to annual plan", + "upgradeYearlyError": "An error occured while switching to the annual plan, please contact us by email.", + "upgradeYearlySuccess": "Thanks for switching to the annual plan!" } } diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 3081b12f18..5a0377871d 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -187,6 +187,7 @@ "editDashboardCancelButton": "Annuler", "editDashboardDeleteButton": "Supprimer", "editDashboardSaveButton": "Sauvegarder", + "editDashboardDeleteText": "Êtes-vous sûr de vouloir supprimer ce tableau de bord ?", "enableFullScreen": "Plein écran", "disableFullScreen": "Quitter plein écran", "editDashboardTitle": "Editer le tableau de bord", @@ -265,7 +266,7 @@ "lastThreeMonths": "Derniers 3 mois", "lastYear": "Dernière année", "noValue": "Pas de valeurs sur cet intervalle.", - "noValueWarning": "Attention, si vous venez de configurer cet appareil, les données peuvent mettre un certain temps avant d'être aggrégées. Pour les intervalles supérieurs à 24h, cela prend jusqu'à 24h le temps que Gladys collecte assez de données.", + "noValueWarning": "Attention, si vous venez de configurer cet appareil, les données peuvent mettre un certain temps avant d'être agrégées. Pour les intervalles supérieurs à 24h, cela prend jusqu'à 24h le temps que Gladys collecte assez de données.", "editNameLabel": "Entrez le nom de cette box", "editNamePlaceholder": "Nom affiché sur le tableau de bord", "editDeviceFeaturesLabel": "Sélectionnez les appareils que vous voulez afficher", @@ -1046,6 +1047,21 @@ "defaultDeletionError": "Une erreur s'est produite lors de la suppression de l'appareil.", "conflictError": "L'appareil actuel est déjà dans Gladys." } + }, + "openai": { + "title": "OpenAI GPT-3", + "description": "Donne à Gladys la puissance de l'intelligence artificielle.", + "firstExplanation": "Ceci est une alpha d'une intégration avec l'intelligence artificielle OpenAI GPT-3. Vous pouvez demander ce que vous voulez à Gladys ici.", + "itDoesNothing": "Cette alpha ne fait aucune action avec Gladys, ce n'est qu'une preuve de concept qui sera déployée dans Gladys si l'expérience est un succès.", + "aFewExamples": "Quelques exemples :", + "turnOnTheLight": "Allume la lumière du salon !", + "sizeOfEiffelTower": "Quelle est la taille de la tour Eiffel ?", + "whoIsJulesVerne": "Qui est Jules Verne ?", + "eggDuration": "Comment cuire les oeufs durs ?", + "rateLimit": "L'API GPT-3 étant payante, cette intégration n'est disponible que via Gladys Plus, et est pour l'instant limitée à 100 requêtes par mois.", + "notOnGladysPlus": "L'API GPT-3 étant payante, cette intégration est proposée via Gladys Plus uniquement.", + "subscribeToGladysPlus": "Cliquez-ici pour souscrire à Gladys Plus.", + "licenseShouldBeActive": "Cette intégration n'est disponible qu'aux utilisateurs Gladys Plus dont l'abonnement est actuellement actif avec au moins un paiement. Pour les utilisateurs en périodes d'essai, merci de me contacter sur le forum ou par email !" } }, "editScene": { @@ -1540,9 +1556,9 @@ "previous": "Précédent", "next": "Suivant", "jobTypes": { - "monthly-device-state-aggregate": "Aggrégation donnée capteur mensuelle", - "daily-device-state-aggregate": "Aggrégation donnée capteur journalière", - "hourly-device-state-aggregate": "Aggrégation donnée capteur horaire", + "monthly-device-state-aggregate": "Agrégation donnée capteur mensuelle", + "daily-device-state-aggregate": "Agrégation donnée capteur journalière", + "hourly-device-state-aggregate": "Agrégation donnée capteur horaire", "gladys-gateway-backup": "Sauvegarde Gladys Plus", "device-state-purge-single-feature": "Nettoyage des états d'un appareil", "vacuum": "Nettoyage de la base de donnée" @@ -2313,6 +2329,12 @@ "title": "Facturation", "informationTitle": "Vos informations de paiement", "stripeDescription": "Le paiement est géré par Stripe. Nous ne voyons jamais votre carte de crédit.", - "stripeButton": "Gérer la facturation dans Stripe" + "stripeButton": "Gérer la facturation dans Stripe", + "yourCurrentPlan": "Votre plan actuel", + "monthlyPlan": "Vous êtes actuellement en paiement mensuel. Si vous souhaitez passer au plan annuel ( 99.99€/an ), vous pouvez cliquer sur le bouton ci-dessous. Si vous êtes en cours d'un mois, le prix du premier paiement annuel sera fait au pro-rata pour vous éviter de payer plusieurs fois le mois en cours !", + "yearlyPlan": "Vous êtes actuellement en paiement annuel.", + "upgradeToYearly": "Passer au plan annuel", + "upgradeYearlyError": "Une erreur est survenue lors de la mise à jour vers le plan annuel, merci de nous contacter par email ou sur le forum.", + "upgradeYearlySuccess": "Merci d'être passé au plan annuel !" } } diff --git a/front/src/config/integrations/communications.json b/front/src/config/integrations/communications.json index 194ef92d83..4e918b28d0 100644 --- a/front/src/config/integrations/communications.json +++ b/front/src/config/integrations/communications.json @@ -14,5 +14,9 @@ { "key": "homekit", "img": "/assets/integrations/cover/homekit.jpg" + }, + { + "key": "openai", + "img": "/assets/integrations/cover/openai.jpg" } ] diff --git a/front/src/routes/dashboard/EditActions.jsx b/front/src/routes/dashboard/EditActions.jsx index ee36d13c5d..0d298a7929 100644 --- a/front/src/routes/dashboard/EditActions.jsx +++ b/front/src/routes/dashboard/EditActions.jsx @@ -4,17 +4,31 @@ const EditActions = props => ( diff --git a/front/src/routes/dashboard/index.js b/front/src/routes/dashboard/index.js index 06dc382056..86ac5e7bd3 100644 --- a/front/src/routes/dashboard/index.js +++ b/front/src/routes/dashboard/index.js @@ -249,6 +249,18 @@ class Dashboard extends Component { } }; + askDeleteCurrentDashboard = async () => { + await this.setState({ + askDeleteDashboard: true + }); + }; + + cancelDeleteCurrentDashboard = async () => { + await this.setState({ + askDeleteDashboard: false + }); + }; + deleteCurrentDashboard = async () => { try { await this.props.httpClient.delete(`/api/v1/dashboard/${this.state.currentDashboard.selector}`); @@ -261,7 +273,8 @@ class Dashboard extends Component { dashboards, currentDashboard, dashboardDropdownOpened: false, - dashboardEditMode: false + dashboardEditMode: false, + askDeleteDashboard: false }); this.init(); route('/dashboard'); @@ -314,7 +327,8 @@ class Dashboard extends Component { dashboardEditMode: false, browserFullScreenCompatible: this.isBrowserFullScreenCompatible(), dashboards: [], - newSelectedBoxType: {} + newSelectedBoxType: {}, + askDeleteDashboard: false }; } @@ -349,7 +363,8 @@ class Dashboard extends Component { browserFullScreenCompatible, dashboardValidationError, dashboardAlreadyExistError, - unknownError + unknownError, + askDeleteDashboard } ) { const dashboardConfigured = @@ -391,7 +406,10 @@ class Dashboard extends Component { updateBoxConfig={this.updateBoxConfig} toggleFullScreen={this.toggleFullScreen} updateCurrentDashboardName={this.updateCurrentDashboardName} + askDeleteCurrentDashboard={this.askDeleteCurrentDashboard} + cancelDeleteCurrentDashboard={this.cancelDeleteCurrentDashboard} deleteCurrentDashboard={this.deleteCurrentDashboard} + askDeleteDashboard={askDeleteDashboard} fullScreen={props.fullScreen} /> ); diff --git a/front/src/routes/integration/all/openai/Layout.jsx b/front/src/routes/integration/all/openai/Layout.jsx new file mode 100644 index 0000000000..844823c0d3 --- /dev/null +++ b/front/src/routes/integration/all/openai/Layout.jsx @@ -0,0 +1,15 @@ +const Layout = ({ children }) => ( +
+
+
+
+
+
{children}
+
+
+
+
+
+); + +export default Layout; diff --git a/front/src/routes/integration/all/openai/index.js b/front/src/routes/integration/all/openai/index.js new file mode 100644 index 0000000000..7310283789 --- /dev/null +++ b/front/src/routes/integration/all/openai/index.js @@ -0,0 +1,205 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import get from 'get-value'; +import uuid from 'uuid'; +import update from 'immutability-helper'; +import { Text, Localizer, MarkupText } from 'preact-i18n'; +import ChatItems from '../../../chat/ChatItems'; +import EmptyChat from '../../../chat/EmptyChat'; +import Layout from './Layout'; + +class OpenAIGateway extends Component { + scrollToBottom = () => { + setTimeout(() => { + const chatWindow = document.getElementById('chat-window'); + if (chatWindow) { + chatWindow.scrollTo(0, chatWindow.scrollHeight); + } + }, 20); + }; + sendMessage = async () => { + const { messages, currentMessageTextInput } = this.state; + const newMessages = update(messages, { + $push: [ + { + id: uuid.v4(), + text: currentMessageTextInput, + sender_id: this.props.user.id, + receiver_id: null, + created_at: new Date().toISOString() + } + ] + }); + await this.setState({ + messages: newMessages, + currentMessageTextInput: '', + error: null, + accountLicenseShouldBeActive: null, + gladysIsTyping: true + }); + this.scrollToBottom(); + try { + const body = { + question: currentMessageTextInput + }; + const lastUserQuestion = messages.findLast(msg => msg.sender_id !== null); + const lastGladysAnswer = messages.findLast(msg => msg.sender_id === null); + if (lastUserQuestion && lastGladysAnswer) { + body.previous_questions = [ + { + question: lastUserQuestion.text, + answer: lastGladysAnswer.text + } + ]; + } + const response = await this.props.httpClient.post('/api/v1/gateway/openai/ask', body); + const newState = update(this.state, { + messages: { + $push: [ + { + id: uuid.v4(), + text: response.answer, + sender_id: null, + receiver_id: this.props.user.id, + created_at: new Date().toISOString() + } + ] + }, + gladysIsTyping: { + $set: false + } + }); + await this.setState(newState); + this.scrollToBottom(); + } catch (e) { + console.error(e); + const errorMessage = get(e, 'response.data.message'); + if (errorMessage === 'Account license should be active') { + this.setState({ + accountLicenseShouldBeActive: true, + gladysIsTyping: false + }); + } else { + let message = `${e.message}, ${errorMessage}`; + this.setState({ + error: message, + gladysIsTyping: false + }); + } + } + }; + + updateMessageTextInput = e => { + this.setState({ + currentMessageTextInput: e.target.value + }); + }; + + onKeyPress = e => { + if (e.key === 'Enter') { + this.sendMessage(); + } + }; + + constructor(props) { + super(props); + this.props = props; + this.state = { + messages: [], + currentMessageTextInput: '', + gladysIsTyping: false + }; + } + + render(props, { messages, gladysIsTyping, currentMessageTextInput, error, accountLicenseShouldBeActive }) { + const notOnGladysPlus = props.session && props.session.getGatewayUser === undefined; + return ( + +
+
+
+
+
+

+ {' '} + + + +

+

+ +

+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+

+ +

+
+
+
+
+
+ {error &&
{error}
} + {accountLicenseShouldBeActive && ( +
+ +
+ )} + {notOnGladysPlus && ( +
+ +
+ +
+ )} + {messages && messages.length > 0 && ( + + )} + {messages && messages.length === 0 && } + +
+
+
+
+
+ ); + } +} + +export default connect('user,session,httpClient', {})(OpenAIGateway); diff --git a/front/src/routes/scene/edit-scene/actions/only-continue-if/Condition.jsx b/front/src/routes/scene/edit-scene/actions/only-continue-if/Condition.jsx index 38d03d07f8..829f274211 100644 --- a/front/src/routes/scene/edit-scene/actions/only-continue-if/Condition.jsx +++ b/front/src/routes/scene/edit-scene/actions/only-continue-if/Condition.jsx @@ -24,7 +24,7 @@ class Condition extends Component { }; handleValueChange = e => { - const newValue = Number.isInteger(parseInt(e.target.value, 10)) ? parseInt(e.target.value, 10) : null; + const newValue = Number.parseFloat(e.target.value); const newCondition = update(this.props.condition, { value: { $set: newValue @@ -114,11 +114,11 @@ class Condition extends Component { } value={props.condition.value} - onChange={this.handleValueChange} + onBlur={this.handleValueChange} /> diff --git a/front/src/routes/settings/settings-billing/GatewayBilling.jsx b/front/src/routes/settings/settings-billing/GatewayBilling.jsx index 2ad308e4a1..c5ab9afc5e 100644 --- a/front/src/routes/settings/settings-billing/GatewayBilling.jsx +++ b/front/src/routes/settings/settings-billing/GatewayBilling.jsx @@ -12,7 +12,7 @@ const Billing = ({ children, ...props }) => (
-
+

@@ -24,6 +24,34 @@ const Billing = ({ children, ...props }) => (
+ {props.plan && ( +
+
+

+ +

+ {props.upgradeYearlyError && ( + + )} + {props.upgradeYearlySuccess && ( + + )} +

+ {props.plan === 'yearly' && } + {props.plan === 'monthly' && } +

+ {props.plan === 'monthly' && ( + + )} +
+
+ )}
diff --git a/front/src/routes/settings/settings-billing/index.js b/front/src/routes/settings/settings-billing/index.js index ddd276f305..ebb61e7ec4 100644 --- a/front/src/routes/settings/settings-billing/index.js +++ b/front/src/routes/settings/settings-billing/index.js @@ -14,19 +14,51 @@ class SettingsBilling extends Component { loading: false }); }; + getCurrentPlan = async () => { + try { + const { plan } = await this.props.session.gatewayClient.getCurrentPlan(); + this.setState({ + plan + }); + } catch (e) { + console.error(e); + } + }; + upgradeMonthlyToYearly = async () => { + try { + await this.setState({ upgradeYearlyError: false, loading: true }); + await this.props.session.gatewayClient.upgradeMonthlyToYearly(); + await this.setState({ upgradeYearlySuccess: true }); + } catch (e) { + await this.setState({ upgradeYearlyError: true }); + console.error(e); + } + await this.getCurrentPlan(); + await this.setState({ loading: false }); + }; openStripeBilling = async () => { window.open( `${this.props.session.gladysGatewayApiUrl}/accounts/stripe_customer_portal/${this.state.setupState.stripe_portal_key}` ); }; + componentWillMount() { this.getSetupState(); + this.getCurrentPlan(); } - render(props, { loading }) { + render(props, { loading, upgradeYearlyError, plan, upgradeYearlySuccess }) { return ( - + ); } diff --git a/package-lock.json b/package-lock.json index 06dfa44b3a..2520a67682 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gladys", - "version": "4.14.0", + "version": "4.15.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "gladys", - "version": "4.14.0", + "version": "4.15.0", "hasInstallScript": true, "license": "Apache-2.0", "devDependencies": { diff --git a/package.json b/package.json index 1ce1ac75ce..ef4ec1a402 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gladys", - "version": "4.14.0", + "version": "4.15.0", "description": "A privacy-first, open-source home assistant", "main": "index.js", "engines": { diff --git a/server/api/controllers/gateway.controller.js b/server/api/controllers/gateway.controller.js index ad6dbcf168..83557c1a0d 100644 --- a/server/api/controllers/gateway.controller.js +++ b/server/api/controllers/gateway.controller.js @@ -125,6 +125,16 @@ module.exports = function GatewayController(gladys) { res.json(keys); } + /** + * @api {post} /api/v1/gateway/openai/ask + * @apiName askOpenAI + * @apiGroup Gateway + */ + async function openAIAsk(req, res) { + const response = await gladys.gateway.openAIAsk(req.body); + res.json(response); + } + return Object.freeze({ getStatus: asyncMiddleware(getStatus), login: asyncMiddleware(login), @@ -137,5 +147,6 @@ module.exports = function GatewayController(gladys) { restoreBackup: asyncMiddleware(restoreBackup), getInstanceKeysFingerprint: asyncMiddleware(getInstanceKeysFingerprint), getRestoreStatus: asyncMiddleware(getRestoreStatus), + openAIAsk: asyncMiddleware(openAIAsk), }); }; diff --git a/server/api/routes.js b/server/api/routes.js index a00705a800..6e2f162e4b 100644 --- a/server/api/routes.js +++ b/server/api/routes.js @@ -314,6 +314,10 @@ function getRoutes(gladys) { admin: true, controller: gatewayController.getInstanceKeysFingerprint, }, + 'post /api/v1/gateway/openai/ask': { + authenticated: true, + controller: gatewayController.openAIAsk, + }, // room 'get /api/v1/room': { authenticated: true, diff --git a/server/lib/gateway/gateway.openAIAsk.js b/server/lib/gateway/gateway.openAIAsk.js new file mode 100644 index 0000000000..7d670ebf7f --- /dev/null +++ b/server/lib/gateway/gateway.openAIAsk.js @@ -0,0 +1,33 @@ +const get = require('get-value'); +const logger = require('../../utils/logger'); +const { Error403, Error429 } = require('../../utils/httpErrors'); + +/** + * @description Ask OpenAI a question + * @param {Object} body - The query to ask. + * @example + * openAIAsk({ + * question + * }) + */ +async function openAIAsk(body) { + try { + const response = await this.gladysGatewayClient.openAIAsk(body); + return response; + } catch (e) { + logger.debug(e); + const status = get(e, 'response.status'); + const message = get(e, 'response.data.error_message'); + if (status === 403) { + throw new Error403(message); + } + if (status === 429) { + throw new Error429(message); + } + throw e; + } +} + +module.exports = { + openAIAsk, +}; diff --git a/server/lib/gateway/index.js b/server/lib/gateway/index.js index b95a540b72..7d08e2b441 100644 --- a/server/lib/gateway/index.js +++ b/server/lib/gateway/index.js @@ -32,6 +32,7 @@ const { restoreBackupEvent } = require('./gateway.restoreBackupEvent'); const { saveUsersKeys } = require('./gateway.saveUsersKeys'); const { refreshUserKeys } = require('./gateway.refreshUserKeys'); const { getEcowattSignals } = require('./gateway.getEcowattSignals'); +const { openAIAsk } = require('./gateway.openAIAsk'); const Gateway = function Gateway(variable, event, system, sequelize, config, user, stateManager, serviceManager, job) { this.variable = variable; @@ -93,5 +94,6 @@ Gateway.prototype.restoreBackupEvent = restoreBackupEvent; Gateway.prototype.saveUsersKeys = saveUsersKeys; Gateway.prototype.refreshUserKeys = refreshUserKeys; Gateway.prototype.getEcowattSignals = getEcowattSignals; +Gateway.prototype.openAIAsk = openAIAsk; module.exports = Gateway; diff --git a/server/package-lock.json b/server/package-lock.json index 57b5af56eb..d373945748 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,7 +8,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@gladysassistant/gladys-gateway-js": "^4.7.0", + "@gladysassistant/gladys-gateway-js": "^4.10.0", "@hapi/joi": "^17.1.0", "@hapi/joi-date": "^2.0.1", "async-retry": "^1.3.3", @@ -656,9 +656,9 @@ "optional": true }, "node_modules/@gladysassistant/gladys-gateway-js": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@gladysassistant/gladys-gateway-js/-/gladys-gateway-js-4.7.0.tgz", - "integrity": "sha512-KwqD9RkfNsN/ShKpUFQgU8iJVHhh76mMcZv+u6AkG5gd1/0VxFxU8VTJIVU/+5y5DrS4fD9tG3Gk47z181Mwdw==", + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@gladysassistant/gladys-gateway-js/-/gladys-gateway-js-4.10.0.tgz", + "integrity": "sha512-2GfKq5VqKKwd7m7bp82mnJPYo1YweW8S48CA4+g/xoB63HHqofE5GKkho+2/RCkEJ9ULw1nDOMJax6EBjkQ0zQ==", "dependencies": { "@ctrlpanel/pbkdf2": "^1.0.0", "array-buffer-to-hex": "^1.0.0", @@ -12082,9 +12082,9 @@ "optional": true }, "@gladysassistant/gladys-gateway-js": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@gladysassistant/gladys-gateway-js/-/gladys-gateway-js-4.7.0.tgz", - "integrity": "sha512-KwqD9RkfNsN/ShKpUFQgU8iJVHhh76mMcZv+u6AkG5gd1/0VxFxU8VTJIVU/+5y5DrS4fD9tG3Gk47z181Mwdw==", + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@gladysassistant/gladys-gateway-js/-/gladys-gateway-js-4.10.0.tgz", + "integrity": "sha512-2GfKq5VqKKwd7m7bp82mnJPYo1YweW8S48CA4+g/xoB63HHqofE5GKkho+2/RCkEJ9ULw1nDOMJax6EBjkQ0zQ==", "requires": { "@ctrlpanel/pbkdf2": "^1.0.0", "array-buffer-to-hex": "^1.0.0", diff --git a/server/package.json b/server/package.json index 98dc74d910..a19a6be867 100644 --- a/server/package.json +++ b/server/package.json @@ -74,7 +74,7 @@ "supertest": "^3.4.2" }, "dependencies": { - "@gladysassistant/gladys-gateway-js": "^4.7.0", + "@gladysassistant/gladys-gateway-js": "^4.10.0", "@hapi/joi": "^17.1.0", "@hapi/joi-date": "^2.0.1", "async-retry": "^1.3.3", diff --git a/server/services/mqtt/docker/eclipse-mosquitto-container.json b/server/services/mqtt/docker/eclipse-mosquitto-container.json index 153550fecd..df07e4526a 100644 --- a/server/services/mqtt/docker/eclipse-mosquitto-container.json +++ b/server/services/mqtt/docker/eclipse-mosquitto-container.json @@ -4,6 +4,12 @@ "ExposedPorts": { "1883/tcp": {} }, "HostConfig": { "Binds": ["/var/lib/gladysassistant/mosquitto:/mosquitto/config"], + "LogConfig": { + "Type": "json-file", + "Config": { + "max-size": "10m" + } + }, "PortBindings": { "1883/tcp": [ { diff --git a/server/services/mqtt/lib/constants.js b/server/services/mqtt/lib/constants.js index 2988adeea0..b3e0d1ee31 100644 --- a/server/services/mqtt/lib/constants.js +++ b/server/services/mqtt/lib/constants.js @@ -16,7 +16,7 @@ const DEFAULT = { IN_PROGRESS: 'IN_PROGRESS', ERROR: 'ERROR', }, - MOSQUITTO_VERSION: '2', + MOSQUITTO_VERSION: '3', }; module.exports = { diff --git a/server/services/mqtt/lib/installContainer.js b/server/services/mqtt/lib/installContainer.js index 269ad29634..b38320c853 100644 --- a/server/services/mqtt/lib/installContainer.js +++ b/server/services/mqtt/lib/installContainer.js @@ -62,6 +62,8 @@ async function installContainer(saveConfiguration = true) { } if (saveConfiguration) { + logger.info('MQTT saving configuration'); + await this.saveConfiguration({ mqttUrl: 'mqtt://localhost', mqttUsername: 'gladys', @@ -70,6 +72,8 @@ async function installContainer(saveConfiguration = true) { }); } + logger.info('MQTT installed'); + return container; } diff --git a/server/services/mqtt/lib/updateContainer.js b/server/services/mqtt/lib/updateContainer.js index def0e6b751..cd225da194 100644 --- a/server/services/mqtt/lib/updateContainer.js +++ b/server/services/mqtt/lib/updateContainer.js @@ -12,16 +12,16 @@ const containerParams = require('../docker/eclipse-mosquitto-container.json'); async function updateContainer(configuration) { logger.info('MQTT: checking for required changes...'); - // Check for port listener option + // Check for update request const { brokerContainerAvailable, mosquittoVersion } = configuration; - if (brokerContainerAvailable && !mosquittoVersion) { - logger.info('MQTT: update to mosquitto v2 required...'); + if (brokerContainerAvailable && mosquittoVersion !== DEFAULT.MOSQUITTO_VERSION) { + logger.info(`MQTT: update #${DEFAULT.MOSQUITTO_VERSION} of mosquitto container required...`); const dockerContainers = await this.gladys.system.getContainers({ all: true, filters: { name: [containerParams.name] }, }); - // Remove non versionned container + // Remove old container if (dockerContainers.length !== 0) { const [container] = dockerContainers; await this.gladys.system.removeContainer(container.id, { force: true }); @@ -36,7 +36,9 @@ async function updateContainer(configuration) { DEFAULT.MOSQUITTO_VERSION, this.serviceId, ); - logger.info('MQTT: update to mosquitto v2 done'); + logger.info(`MQTT: update #${DEFAULT.MOSQUITTO_VERSION} of mosquitto container done`); + } else { + logger.info('MQTT: no container update required'); } return configuration; diff --git a/server/services/zigbee2mqtt/exposes/numericType.js b/server/services/zigbee2mqtt/exposes/numericType.js index 5f6b04cf41..db16e6fc9d 100644 --- a/server/services/zigbee2mqtt/exposes/numericType.js +++ b/server/services/zigbee2mqtt/exposes/numericType.js @@ -60,6 +60,14 @@ module.exports = { max: 1000, }, }, + current_heating_setpoint: { + feature: { + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + min: 5, + max: 40, + }, + }, current_phase_b: { feature: { category: DEVICE_FEATURE_CATEGORIES.SWITCH, @@ -152,6 +160,22 @@ module.exports = { forceOverride: true, }, }, + occupied_cooling_setpoint: { + feature: { + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + min: 5, + max: 40, + }, + }, + occupied_heating_setpoint: { + feature: { + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + min: 5, + max: 40, + }, + }, position: { types: { cover: { @@ -184,6 +208,22 @@ module.exports = { max: 150, }, }, + unoccupied_cooling_setpoint: { + feature: { + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + min: 5, + max: 40, + }, + }, + unoccupied_heating_setpoint: { + feature: { + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + min: 5, + max: 40, + }, + }, voltage: { feature: { category: DEVICE_FEATURE_CATEGORIES.SWITCH, diff --git a/server/test/controllers/gateway/gateway.controller.test.js b/server/test/controllers/gateway/gateway.controller.test.js index 54146fe90b..c07d15ad0b 100644 --- a/server/test/controllers/gateway/gateway.controller.test.js +++ b/server/test/controllers/gateway/gateway.controller.test.js @@ -106,3 +106,18 @@ describe('GET /api/v1/gateway/backup/restore/status', () => { }); }); }); + +describe('POST /api/v1/gateway/openai/ask', () => { + it('should return GPT-3 response', async () => { + nock(config.gladysGatewayServerUrl) + .post('/openai/ask') + .reply(200, { + answer: 'this is my answer', + }); + const response = await authenticatedRequest + .post('/api/v1/gateway/openai/ask') + .expect('Content-Type', /json/) + .expect(200); + expect(response.body).to.have.property('answer', 'this is my answer'); + }); +}); diff --git a/server/test/lib/gateway/GladysGatewayClientMock.test.js b/server/test/lib/gateway/GladysGatewayClientMock.test.js index 96b2957b48..992de3cd76 100644 --- a/server/test/lib/gateway/GladysGatewayClientMock.test.js +++ b/server/test/lib/gateway/GladysGatewayClientMock.test.js @@ -77,6 +77,7 @@ const GladysGatewayClientMock = function GladysGatewayClientMock() { }, ]), getEcowattSignals: fake.resolves({ signals: [] }), + openAIAsk: fake.resolves({ answer: 'this is the answer' }), }; }; diff --git a/server/test/lib/gateway/gateway.openAi.test.js b/server/test/lib/gateway/gateway.openAi.test.js new file mode 100644 index 0000000000..ae5295309e --- /dev/null +++ b/server/test/lib/gateway/gateway.openAi.test.js @@ -0,0 +1,67 @@ +const { expect, assert } = require('chai'); +const { fake } = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); +const EventEmitter = require('events'); +const GladysGatewayClientMock = require('./GladysGatewayClientMock.test'); +const { Error403, Error429 } = require('../../../utils/httpErrors'); + +const event = new EventEmitter(); + +const Gateway = proxyquire('../../../lib/gateway', { + '@gladysassistant/gladys-gateway-js': GladysGatewayClientMock, +}); + +const job = { + wrapper: (type, func) => { + return async () => { + return func(); + }; + }, + updateProgress: fake.resolves({}), +}; + +describe('gateway.openAI.ask', () => { + const variable = { + getValue: fake.resolves(null), + setValue: fake.resolves(null), + }; + const system = {}; + let gateway; + beforeEach(() => { + gateway = new Gateway(variable, event, system, {}, {}, {}, {}, {}, job); + }); + it('should ask to GPT-3 a question', async () => { + const data = await gateway.openAIAsk({ question: 'Question ?' }); + expect(data).to.deep.equal({ answer: 'this is the answer' }); + }); + it('should return 403 forbidden', async () => { + const error = new Error('Forbidden'); + // @ts-ignore + error.response = { + status: 403, + }; + gateway.gladysGatewayClient.openAIAsk = fake.throws(error); + const promise = gateway.openAIAsk({ question: 'Question ?' }); + await assert.isRejected(promise, Error403); + }); + it('should return 429 too many requests', async () => { + const error = new Error('too many requests'); + // @ts-ignore + error.response = { + status: 429, + }; + gateway.gladysGatewayClient.openAIAsk = fake.rejects(error); + const promise = gateway.openAIAsk({ question: 'Question ?' }); + await assert.isRejected(promise, Error429); + }); + it('should return 500, server error', async () => { + const error = new Error('unknown error'); + // @ts-ignore + error.response = { + status: 500, + }; + gateway.gladysGatewayClient.openAIAsk = fake.rejects(error); + const promise = gateway.openAIAsk({ question: 'Question ?' }); + await assert.isRejected(promise, Error); + }); +}); diff --git a/server/test/services/mqtt/lib/installContainer.test.js b/server/test/services/mqtt/lib/installContainer.test.js index bca35f25fa..f4d38acba1 100644 --- a/server/test/services/mqtt/lib/installContainer.test.js +++ b/server/test/services/mqtt/lib/installContainer.test.js @@ -1,27 +1,33 @@ const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); const { expect } = require('chai'); const { assert, fake } = sinon; -const proxiquire = require('proxyquire').noCallThru(); - const { MockedMqttClient } = require('../mocks.test'); const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); const { DEFAULT } = require('../../../../services/mqtt/lib/constants'); const execMock = { exec: fake.resolves('command well executed') }; -const installContainer = proxiquire('../../../../services/mqtt/lib/installContainer', { +const installContainer = proxyquire('../../../../services/mqtt/lib/installContainer', { '../../../utils/childProcess': execMock, }); -const MqttHandler = proxiquire('../../../../services/mqtt/lib', { +const saveConfiguration = proxyquire('../../../../services/mqtt/lib/saveConfiguration', { + util: { + // Fake promisify to revolve it directly + promisify: () => () => {}, + }, +}); + +const MqttHandler = proxyquire('../../../../services/mqtt/lib', { './installContainer': installContainer, + './saveConfiguration': saveConfiguration, }); const serviceId = 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'; -describe('mqttHandler.installContainer', function Describe() { - this.timeout(8000); - beforeEach(() => { +describe('mqttHandler.installContainer', () => { + afterEach(() => { sinon.reset(); }); diff --git a/server/test/services/mqtt/lib/saveConfiguration.test.js b/server/test/services/mqtt/lib/saveConfiguration.test.js index b4ea90fd73..ff3ad0a455 100644 --- a/server/test/services/mqtt/lib/saveConfiguration.test.js +++ b/server/test/services/mqtt/lib/saveConfiguration.test.js @@ -1,18 +1,27 @@ const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); const { expect } = require('chai'); const { assert, fake } = sinon; const { MockedMqttClient } = require('../mocks.test'); const { CONFIGURATION, DEFAULT } = require('../../../../services/mqtt/lib/constants'); const { NotFoundError } = require('../../../../utils/coreErrors'); -const MqttHandler = require('../../../../services/mqtt/lib'); -const serviceId = 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'; +const saveConfiguration = proxyquire('../../../../services/mqtt/lib/saveConfiguration', { + util: { + // Fake promisify to revolve it directly + promisify: () => () => {}, + }, +}); -describe('mqttHandler.saveConfiguration', function Describe() { - this.timeout(15000); +const MqttHandler = proxyquire('../../../../services/mqtt/lib', { + './saveConfiguration': saveConfiguration, +}); + +const serviceId = 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'; - beforeEach(() => { +describe('mqttHandler.saveConfiguration', () => { + afterEach(() => { sinon.reset(); }); diff --git a/server/test/services/mqtt/lib/updateContainer.test.js b/server/test/services/mqtt/lib/updateContainer.test.js index 0ff68c10f5..03e5e23e3b 100644 --- a/server/test/services/mqtt/lib/updateContainer.test.js +++ b/server/test/services/mqtt/lib/updateContainer.test.js @@ -1,15 +1,22 @@ const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); const { assert, fake } = sinon; -const proxiquire = require('proxyquire').noCallThru(); - const { MockedMqttClient } = require('../mocks.test'); const { CONFIGURATION, DEFAULT } = require('../../../../services/mqtt/lib/constants'); const installContainerMock = { installContainer: fake.resolves({ id: 'id' }) }; -const MqttHandler = proxiquire('../../../../services/mqtt/lib', { +const saveConfiguration = proxyquire('../../../../services/mqtt/lib/saveConfiguration', { + util: { + // Fake promisify to revolve it directly + promisify: () => () => {}, + }, +}); + +const MqttHandler = proxyquire('../../../../services/mqtt/lib', { './installContainer': installContainerMock, + './saveConfiguration': saveConfiguration, }); const gladys = { @@ -24,9 +31,7 @@ const gladys = { }; const serviceId = 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'; -describe('mqttHandler.updateContainer', function Describe() { - this.timeout(8000); - +describe('mqttHandler.updateContainer', () => { let mqttHandler; beforeEach(() => { @@ -50,7 +55,10 @@ describe('mqttHandler.updateContainer', function Describe() { }); it('should updateContainer: already up-to-date', async () => { - const config = { brokerContainerAvailable: true, mosquittoVersion: '2' }; + const config = { + brokerContainerAvailable: true, + mosquittoVersion: DEFAULT.MOSQUITTO_VERSION, + }; await mqttHandler.updateContainer(config); @@ -71,8 +79,7 @@ describe('mqttHandler.updateContainer', function Describe() { assert.calledOnce(gladys.system.restartContainer); assert.calledOnce(installContainerMock.installContainer); - assert.calledOnce(gladys.variable.setValue); - assert.calledWith( + assert.calledOnceWithExactly( gladys.variable.setValue, CONFIGURATION.MQTT_MOSQUITTO_VERSION, DEFAULT.MOSQUITTO_VERSION, @@ -80,8 +87,11 @@ describe('mqttHandler.updateContainer', function Describe() { ); }); - it('should updateContainer: MQTT container found', async () => { - const config = { brokerContainerAvailable: true }; + it('should updateContainer: missing log limitation', async () => { + const config = { + brokerContainerAvailable: true, + mosquittoVersion: '2', + }; gladys.system.getContainers = fake.resolves([{ id: 'container' }]); await mqttHandler.updateContainer(config); @@ -95,8 +105,7 @@ describe('mqttHandler.updateContainer', function Describe() { assert.calledOnce(installContainerMock.installContainer); - assert.calledOnce(gladys.variable.setValue); - assert.calledWith( + assert.calledOnceWithExactly( gladys.variable.setValue, CONFIGURATION.MQTT_MOSQUITTO_VERSION, DEFAULT.MOSQUITTO_VERSION, diff --git a/server/utils/httpErrors.js b/server/utils/httpErrors.js index 6d0d3126ae..a912648896 100644 --- a/server/utils/httpErrors.js +++ b/server/utils/httpErrors.js @@ -60,6 +60,15 @@ class Error422 extends HttpError { } } +class Error429 extends HttpError { + constructor(properties) { + super(); + this.status = 429; + this.code = 'TOO_MANY_REQUESTS'; + this.properties = properties; + } +} + class Error500 extends HttpError { constructor(error) { super(); @@ -77,5 +86,6 @@ module.exports = { Error404, Error409, Error422, + Error429, Error500, };