build: Build and publish a proper CLI tool with options, also packaged as a Docker image (#2)

* Renamed environment variables

* Moved MqttSettings to main.tsx

* Using Commander for CLI arguments

* PinoLogger

* Option for json log format

* Updated mysa-js-sdk to latest version

* Moved options to their own module

* Extracted session file management to the session module

* Added deviceId meta to thermostat instance logger

* Display version from package.json; added copyright

* Create README.md

* Build with tsup

* Update .gitignore

* Remove prepublishOnly npm script

* Distributed CLI executable is now working

* Update README.md

* Dockerfile

* Minify the build output

* Update README.md

* Create initial Github workflow

* Create release.config.mjs

* Read package version at run-time, not build-time

* Update README.md

* Create CONTRIBUTING.md

* WIP: docker CI job

* Trying multiple tags

* Enable docker build cache

* Testing the docker build cache

* Dockerfile: set npm version in final stage for better caching

* Testing docker build cache

* Moved VERSION arg to the final build stage

* Finalized the `docker` build job

* Added copyright header to all source files

* Specify radix when parsing integer options
This commit is contained in:
Pascal Bourque
2025-06-06 11:33:18 -04:00
committed by GitHub
parent 7a3f94c37e
commit b7a80cb072
22 changed files with 8654 additions and 238 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.env.local
session.json

10
.env
View File

@@ -1,10 +0,0 @@
# "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace"
MYSA_2_MQTT_LOG_LEVEL=info
MYSA_2_MQTT_BROKER_HOST=localhost
MYSA_2_MQTT_BROKER_PORT=1883
# Set these variables in .env.local
# MYSA_2_MQTT_USERNAME=your-mysa-username
# MYSA_2_MQTT_PASSWORD=your-mysa-password
# MYSA_2_MQTT_BROKER_USERNAME=mqtt-username
# MYSA_2_MQTT_BROKER_PASSWORD=mqtt-password

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @bourquep

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: bourquep
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

25
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: 'npm' # See documentation for possible values
directory: '/' # Location of package manifests
schedule:
interval: 'daily'
labels:
- 'dependencies'
commit-message:
prefix: 'chore'
include: 'scope'
groups:
dev-dependencies:
patterns:
- '@types/*'
- 'eslint*'
- 'prettier*'
- 'typescript'
- 'semantic-release'
- '@semantic-release/*'

133
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,133 @@
name: 'CI: lint, build and release'
permissions:
contents: read
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: ['main']
pull_request:
branches: ['main']
workflow_dispatch:
env:
NODE_VERSION: 22.x
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run style-lint
- run: npm run lint
build:
needs:
- lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: ./dist
retention-days: 1
release:
if: github.event_name == 'workflow_dispatch'
needs: build
runs-on: ubuntu-latest
permissions:
contents: write # to be able to publish a GitHub release
issues: write # to be able to comment on released issues
pull-requests: write # to be able to comment on released pull requests
id-token: write # to be able to specify the provenance of the npm package
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Download build output
uses: actions/download-artifact@v4
with:
name: build-output
path: ./dist
- run: npm ci -D
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-release
- name: Get released version
id: version
run: |
version=$(node -p "require('./package.json').version")
echo "version=$version" >> $GITHUB_OUTPUT
docker:
if: github.event_name == 'workflow_dispatch' && needs.release.outputs.version
needs: release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
push: true
build-args: VERSION=${{ needs.release.outputs.version }}
tags: bourquep/mysa2mqtt:${{ needs.release.outputs.version }},bourquep/mysa2mqtt:latest

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules
dist
.env.local
session.json

160
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,160 @@
# Contributing to mysa2mqtt
First off, thank you for considering contributing to `mysa2mqtt`! Contributions from the community are essential in
making this project better. Whether you want to report a bug, propose new features, improve documentation or submit code
changes, I welcome your input and assistance. This guide will help you get started with contributing.
## Development Environment
This project uses Node.js and npm for development. Make sure you have Node.js 22.4.0 or higher installed.
### Setting Up Your Development Environment
1. Fork the repository on GitHub
2. Clone your fork locally
3. Navigate to the cloned directory
4. Install dependencies using npm:
```bash
npm install
```
### Project Structure
```
mysa2mqtt/
├── src/ # Source code
│ ├── commander.d.ts # Commander type definitions
│ ├── logger.ts # Logging utilities
│ ├── main.ts # Main application entry point
│ ├── options.ts # Command line options handling
│ ├── session.ts # Session management
│ └── thermostat.ts # Thermostat control logic
├── .github/ # GitHub configuration
│ ├── workflows/ # GitHub Actions workflows
│ │ └── ci.yml # Continuous integration workflow
│ ├── CODEOWNERS # Code ownership definitions
│ ├── FUNDING.yml # GitHub Sponsors configuration
│ └── dependabot.yml # Dependabot configuration
├── dist/ # Built JavaScript files (generated)
├── node_modules/ # Dependencies (generated)
├── .env.local # Local environment variables (not source-controlled)
├── session.json # Session data file (not source-controlled)
└── package.json # Project configuration and dependencies
```
### Development Workflow
1. Create a new branch for your feature or bug fix
2. Make your changes
3. Run linting checks: `npm run lint` and `npm run style-lint`
4. Build the project: `npm run build`
5. Commit your changes using conventional commit format
6. Push your changes to your fork
7. Create a pull request
### Building
Build the project using [tsup](https://github.com/egoist/tsup):
```bash
npm run build
```
This will generate JavaScript files in the `dist/` directory.
## Submitting Pull Requests
### Conventional Commits
This repository uses [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). This means that each commit
message must follow a specific format. The format is as follows:
```
<type>[optional scope]: <description>
```
The `type` must be one of the following:
| Type | Description |
| ---------- | ---------------------------------------------------------------------------------- |
| `feat` | A new feature. |
| `fix` | A bug fix. |
| `docs` | Documentation only changes. |
| `test` | Changes to tests. |
| `perf` | A code change that improves performance. |
| `refactor` | A code change that neither fixes a bug nor adds a feature. |
| `style` | Changes that do not affect the meaning of the code (white-space, formatting, etc). |
| `chore` | Regular maintenance tasks and updates. |
| `build` | Changes that affect the build system or external dependencies. |
| `ci` | Changes to CI configuration files and scripts. |
| `revert` | Reverting a previous commit. |
The `scope` is optional and should be used to specify the part of the codebase that the commit affects.
The `description` should be a short, concise summary of the changes made in the commit. The description will appear
as-is in the release notes, so make sure it is clear, informative and not too technical.
For example:
```
feat: Added support for dark mode
```
### Semantic Versioning
This repository uses [semantic versioning](https://semver.org/). This means that each release will be versioned
according to the following rules:
- Increment the major version for breaking changes
- Increment the minor version for new features
- Increment the patch version for bug fixes
Releases are automatically generated by [semantic-release](https://github.com/semantic-release/semantic-release) based
on the commit messages. The version number is determined by the type of commits since the last release.
### Coding Standards
This project adheres to a set of coding standards to ensure consistency and maintainability:
1. **TypeScript**: Write all code in TypeScript with proper type annotations.
2. **Documentation**: Use [TSDoc](https://tsdoc.org/) comments for all public APIs.
3. **Clean Code**: Write clear, self-explanatory code with meaningful variable names.
4. **Error Handling**: Properly handle errors and edge cases.
### Code Style
This repository uses [ESLint](https://eslint.org/) and [Prettier](https://prettier.io/) to enforce code style and
formatting. All code must pass both linting checks:
```bash
# Run ESLint
npm run lint
# Run Prettier
npm run style-lint
```
The project is configured with specific rules for:
- Maximum line length
- Indentation (2 spaces)
- Quote style (single quotes)
- Semi-colons (required)
- Trailing commas
- And more
These rules are automatically enforced and cannot be overridden. Please make sure that your code follows these
conventions.
### Pull Request Checklist
Before submitting a pull request, please make sure that:
- Your code follows the coding standards and conventions used in the project
- Your code passes linting checks: `npm run lint`
- Your code passes style checks: `npm run style-lint`
- The documentation has been updated to reflect any changes
- Your commit messages follow the conventional commits format
- The build completes successfully: `npm run build`
- You've verified that your changes work as expected

53
Dockerfile Normal file
View File

@@ -0,0 +1,53 @@
################################################################################
# Builder stage
FROM node:22-alpine AS builder
WORKDIR /app
# Copy package files and install all dependencies for building
COPY package*.json ./
RUN npm ci --ignore-scripts
# Copy source code and build
COPY src ./src
COPY tsconfig.json .
COPY tsup.config.cjs .
RUN npm run build
################################################################################
# Final stage
FROM node:22-alpine AS final
ARG VERSION
# Metadata
LABEL maintainer="Pascal Bourque <pascal@cosmos.moi>"
LABEL description="Expose Mysa smart thermostats to home automation platforms via MQTT."
LABEL org.opencontainers.image.source="https://github.com/bourquep/mysa2mqtt"
LABEL org.opencontainers.image.description="Expose Mysa smart thermostats to home automation platforms via MQTT"
LABEL org.opencontainers.image.licenses="MIT"
# Install security updates
RUN apk --no-cache upgrade
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S mysa2mqtt -u 1001
# Copy package files and install production dependencies only
COPY --from=builder /app/package*.json ./
RUN npm version ${VERSION} --no-git-tag-version && \
npm ci --only=production --ignore-scripts && \
npm cache clean --force
# Copy built application
COPY --from=builder /app/dist ./dist
# Change ownership to non-root user
RUN chown -R mysa2mqtt:nodejs /app
USER mysa2mqtt
ENTRYPOINT ["node", "dist/main.js"]

21
LICENSE.txt Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Pascal Bourque
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

359
README.md Normal file
View File

@@ -0,0 +1,359 @@
# mysa2mqtt
A Node.js application that bridges Mysa smart thermostats to MQTT, enabling integration with Home Assistant and other
home automation platforms.
## Features
- **MQTT Integration**: Exposes Mysa thermostats as MQTT devices compatible with Home Assistant's auto-discovery
- **Real-time Updates**: Live temperature, humidity, and power consumption monitoring
- **Full Control**: Set temperature, change modes (heat/off), and monitor thermostat status
- **Session Management**: Persistent authentication sessions to minimize API calls
- **Configurable Logging**: Support for JSON and pretty-printed log formats with adjustable levels
## Disclaimer
This tool was developed without the consent of the Mysa Smart Thermostats company, and makes use of undocumented and
unsupported APIs. Use at your own risk, and be aware that Mysa may change the APIs at any time and break this tool
permanently.
## Prerequisites
- Node.js 18+
- A Mysa account with configured thermostats
- An MQTT broker (like Mosquitto)
- Optional: Home Assistant for auto-discovery
## Installation
### Option 1: Global Installation (Recommended)
Install globally via npm to use the `mysa2mqtt` command anywhere:
```bash
npm install -g mysa2mqtt
```
### Option 2: Run with npx (No Installation Required)
Run directly without installing:
```bash
npx mysa2mqtt --help
```
### Option 3: Development Setup
For development or custom modifications:
1. Clone the repository:
```bash
git clone https://github.com/bourquep/mysa2mqtt.git
cd mysa2mqtt
```
2. Install dependencies:
```bash
npm install
```
3. Run the tool:
```bash
npm run dev
```
## Quick Start
1. **Install the CLI tool**:
```bash
npm install -g mysa2mqtt
```
2. **Run with basic configuration**:
```bash
mysa2mqtt --mqtt-host your-mqtt-broker.local --mysa-username your-email@example.com --mysa-password your-password
```
3. **For persistent configuration**, create a `.env` file:
```bash
M2M_MQTT_HOST=your-mqtt-broker.local
M2M_MYSA_USERNAME=your-mysa-email@example.com
M2M_MYSA_PASSWORD=your-mysa-password
```
Then simply run:
```bash
mysa2mqtt
```
4. **Check Home Assistant** (if using auto-discovery):
- Go to Settings → Devices & Services
- Look for automatically discovered Mysa devices
- Configure and add to your dashboard
## Configuration
The application can be configured using either command-line arguments or environment variables. Environment variables
take precedence over command-line defaults.
### Required Configuration
| CLI Option | Environment Variable | Description |
| --------------------- | -------------------- | -------------------------------- |
| `-H, --mqtt-host` | `M2M_MQTT_HOST` | Hostname of the MQTT broker |
| `-u, --mysa-username` | `M2M_MYSA_USERNAME` | Your Mysa account username/email |
| `-p, --mysa-password` | `M2M_MYSA_PASSWORD` | Your Mysa account password |
### Optional Configuration
#### MQTT Settings
| CLI Option | Environment Variable | Default | Description |
| ------------------------- | ----------------------- | ----------- | --------------------------------------- |
| `-P, --mqtt-port` | `M2M_MQTT_PORT` | `1883` | Port of the MQTT broker |
| `-U, --mqtt-username` | `M2M_MQTT_USERNAME` | - | Username for MQTT broker authentication |
| `-B, --mqtt-password` | `M2M_MQTT_PASSWORD` | - | Password for MQTT broker authentication |
| `-N, --mqtt-client-name` | `M2M_MQTT_CLIENT_NAME` | `mysa2mqtt` | Name of the MQTT client |
| `-T, --mqtt-topic-prefix` | `M2M_MQTT_TOPIC_PREFIX` | `mysa2mqtt` | Prefix for MQTT topics |
#### Application Settings
| CLI Option | Environment Variable | Default | Description |
| ------------------------- | ----------------------- | -------------- | ----------------------------------------------------------------------- |
| `-l, --log-level` | `M2M_LOG_LEVEL` | `info` | Log level: `silent`, `fatal`, `error`, `warn`, `info`, `debug`, `trace` |
| `-f, --log-format` | `M2M_LOG_FORMAT` | `pretty` | Log format: `pretty`, `json` |
| `-s, --mysa-session-file` | `M2M_MYSA_SESSION_FILE` | `session.json` | Path to Mysa session file |
## Usage Examples
### Using Environment Variables (.env file)
Create a `.env` file:
```bash
# Required
M2M_MQTT_HOST=mosquitto.local
M2M_MYSA_USERNAME=user@example.com
M2M_MYSA_PASSWORD=your-password
# Optional
M2M_MQTT_PORT=1883
M2M_MQTT_USERNAME=mqtt-user
M2M_MQTT_PASSWORD=mqtt-password
M2M_LOG_LEVEL=info
M2M_LOG_FORMAT=pretty
```
Then run:
```bash
mysa2mqtt
```
### Using Command Line Arguments
```bash
mysa2mqtt \
--mqtt-host mosquitto.local \
--mqtt-port 1883 \
--mqtt-username mqtt-user \
--mqtt-password mqtt-password \
--mysa-username user@example.com \
--mysa-password your-password \
--log-level debug \
--log-format json
```
### Mixed Configuration
You can combine both approaches. Environment variables will override command-line defaults:
```bash
# .env file
M2M_MQTT_HOST=mosquitto.local
M2M_MYSA_USERNAME=user@example.com
M2M_MYSA_PASSWORD=your-password
# Command line (will override .env if present)
mysa2mqtt --log-level debug --mqtt-port 8883
```
## Home Assistant Integration
When using Home Assistant, devices will be automatically discovered and appear in:
- **Settings → Devices & Services → MQTT**
- **Climate entities** for temperature control
- **Sensor entities** for power monitoring
## Troubleshooting
### Common Issues
1. **Authentication Failures**
- Verify your Mysa username and password
- Check if session.json exists and is valid
- Try deleting session.json to force re-authentication
2. **MQTT Connection Issues**
- Verify MQTT broker hostname and port
- Check MQTT credentials if authentication is required
- Ensure the MQTT broker is accessible from your network
3. **No Devices Found**
- Ensure your Mysa thermostats are properly configured in the Mysa app
- Check logs for API errors
- Verify your Mysa account has active devices
### Debug Mode
Enable debug logging to get more detailed information:
```bash
mysa2mqtt --log-level debug
```
Or set in environment:
```bash
M2M_LOG_LEVEL=debug
```
### Log Formats
- **Pretty format** (default): Human-readable colored output
- **JSON format**: Structured logging suitable for log aggregation
## Docker Usage
### Option 1: Pre-built Image (Recommended)
Use the official pre-built Docker image:
```bash
docker run -d --name mysa2mqtt \
-e M2M_MQTT_HOST=your-mqtt-broker \
-e M2M_MYSA_USERNAME=your-email \
-e M2M_MYSA_PASSWORD=your-password \
bourquep/mysa2mqtt:latest
```
With additional configuration:
```bash
docker run -d --name mysa2mqtt \
-e M2M_MQTT_HOST=your-mqtt-broker \
-e M2M_MQTT_PORT=1883 \
-e M2M_MQTT_USERNAME=mqtt-user \
-e M2M_MQTT_PASSWORD=mqtt-password \
-e M2M_MYSA_USERNAME=your-email \
-e M2M_MYSA_PASSWORD=your-password \
-e M2M_LOG_LEVEL=info \
-v $(pwd)/session.json:/app/session.json \
bourquep/mysa2mqtt:latest
```
### Option 2: Build Your Own Image
If you prefer to build your own image, create a `Dockerfile`:
```dockerfile
FROM node:22-alpine
WORKDIR /app
# Install mysa2mqtt globally
RUN npm install -g mysa2mqtt
CMD ["mysa2mqtt"]
```
Build and run:
```bash
docker build -t mysa2mqtt .
docker run -d --name mysa2mqtt \
-e M2M_MQTT_HOST=your-mqtt-broker \
-e M2M_MYSA_USERNAME=your-email \
-e M2M_MYSA_PASSWORD=your-password \
mysa2mqtt
```
### Option 3: Use Official Node.js Image
Run directly with the official Node.js image:
```bash
docker run -d --name mysa2mqtt \
-e M2M_MQTT_HOST=your-mqtt-broker \
-e M2M_MYSA_USERNAME=your-email \
-e M2M_MYSA_PASSWORD=your-password \
node:22-alpine \
sh -c "npm install -g mysa2mqtt && mysa2mqtt"
```
### Docker Compose
For easier management, create a `docker-compose.yml` file:
```yaml
services:
mysa2mqtt:
image: bourquep/mysa2mqtt:latest
container_name: mysa2mqtt
restart: unless-stopped
environment:
- M2M_MQTT_HOST=your-mqtt-broker
- M2M_MYSA_USERNAME=your-email
- M2M_MYSA_PASSWORD=your-password
- M2M_LOG_LEVEL=info
volumes:
- ./session.json:/app/session.json
```
Then run:
```bash
docker-compose up -d
```
## Contributing
If you want to contribute to this project, please read the [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines.
## License
`mysa2mqtt` is licensed under the MIT License. This is a permissive license that allows you to use, modify, and
redistribute this software in both private and commercial projects. You can change the code and distribute your changes
without being required to release your source code. The MIT License only requires that you include the original
copyright notice and license text in any copy of the software or substantial portion of it.
## Copyright
© 2025 Pascal Bourque
## Support
- **Issues**: Report bugs and feature requests on [GitHub Issues](https://github.com/bourquep/mysa2mqtt/issues)
- **Discussions**: Join the conversation on [GitHub Discussions](https://github.com/bourquep/mysa2mqtt/discussions)
## Acknowledgments
- [mysa-js-sdk](https://github.com/bourquep/mysa-js-sdk) - Mysa API client library
- This library would not be possible without the amazing work by [@dlenski](https://github.com/dlenski) in his
[mysotherm](https://github.com/dlenski/mysotherm) repository. He's the one who reversed-engineered the Mysa MQTT
protocol which is being used by this library.
- [mqtt2ha](https://github.com/bourquep/mqtt2ha) - MQTT to Home Assistant bridge library
- [Commander.js](https://github.com/tj/commander.js) - Command-line argument parsing
- [Pino](https://github.com/pinojs/pino) - Fast JSON logger

7459
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,72 @@
{
"name": "mysa2mqtt",
"version": "0.0.0",
"license": "MIT",
"private": false,
"description": "Expose Mysa smart thermostats to home automation platforms via MQTT.",
"keywords": [
"mysa",
"thermostat",
"mqtt",
"homeassistant",
"home-assistant"
],
"publishConfig": {
"provenance": true
},
"author": {
"name": "Pascal Bourque",
"email": "pascal@cosmos.moi"
},
"bugs": {
"url": "https://github.com/bourquep/mysa2mqtt/issues"
},
"homepage": "https://github.com/bourquep/mysa2mqtt",
"repository": {
"type": "git",
"url": "git+https://github.com/bourquep/mysa2mqtt.git"
},
"files": [
"README.md",
"LICENSE.txt",
"dist"
],
"type": "module",
"bin": {
"mysa2mqtt": "dist/main.js"
},
"engines": {
"node": ">=22.4.0"
},
"browser": false,
"scripts": {
"mysa2mqtt": "tsx src/main.ts",
"dev": "tsx src/main.ts",
"lint": "eslint --max-warnings 0 src/**/*.ts",
"style-lint": "prettier -c ."
"style-lint": "prettier -c .",
"build": "tsup"
},
"dependencies": {
"commander": "14.0.0",
"dotenv": "16.5.0",
"mqtt2ha": "4.0.0",
"mysa-js-sdk": "1.1.0",
"mysa-js-sdk": "1.1.2",
"pino": "9.7.0",
"pino-pretty": "13.0.0"
},
"devDependencies": {
"@commander-js/extra-typings": "14.0.0",
"@eslint/js": "9.27.0",
"@semantic-release/npm": "12.0.1",
"@types/node": "22.15.21",
"conventional-changelog-conventionalcommits": "9.0.0",
"eslint": "9.27.0",
"eslint-plugin-jsdoc": "50.6.17",
"eslint-plugin-tsdoc": "0.4.0",
"prettier": "3.5.3",
"prettier-plugin-jsdoc": "1.3.2",
"prettier-plugin-organize-imports": "4.1.0",
"semantic-release": "24.2.5",
"tsup": "8.5.0",
"tsx": "4.19.4",
"typescript": "5.8.3",
"typescript-eslint": "8.32.1"

38
release.config.mjs Normal file
View File

@@ -0,0 +1,38 @@
/** @type {import('semantic-release').GlobalConfig} */
const semanticReleaseConfig = {
branches: ['main'],
plugins: [
[
'@semantic-release/commit-analyzer',
{
preset: 'conventionalcommits',
releaseRules: [{ type: 'chore', scope: 'deps', release: 'patch' }]
}
],
[
'@semantic-release/release-notes-generator',
{
preset: 'conventionalcommits',
presetConfig: {
types: [
{ type: 'feat', section: '✨ Features' },
{ type: 'fix', section: '🐛 Bug Fixes' },
{ type: 'docs', section: '📚 Documentation' },
{ type: 'test', section: '🧪 Tests' },
{ type: 'perf', section: '⚡️ Performance Improvements' },
{ type: 'refactor', section: '♻️ Code Refactoring' },
{ type: 'style', section: '💄 Style' },
{ type: 'chore', section: '🔧 Maintenance' },
{ type: 'build', section: '📦 Build System' },
{ type: 'ci', section: '👷 Continuous Integration' },
{ type: 'revert', section: '⏪ Reverts' }
]
}
}
],
'@semantic-release/npm',
'@semantic-release/github'
]
};
export default semanticReleaseConfig;

27
src/commander.d.ts vendored Normal file
View File

@@ -0,0 +1,27 @@
/*
mysa2mqtt
Copyright (C) 2025 Pascal Bourque
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
// commander.d.ts
declare module 'commander' {
export * from '@commander-js/extra-typings';
}

65
src/logger.ts Normal file
View File

@@ -0,0 +1,65 @@
/*
mysa2mqtt
Copyright (C) 2025 Pascal Bourque
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import { Logger } from 'mqtt2ha';
import { pino } from 'pino';
export class PinoLogger implements Logger {
constructor(private readonly logger: pino.Logger) {}
debug(message: string, ...meta: unknown[]): void {
const obj = meta.at(0);
if (obj) {
this.logger.debug(obj, message, ...meta);
} else {
this.logger.debug(message, ...meta);
}
}
info(message: string, ...meta: unknown[]): void {
const obj = meta.at(0);
if (obj) {
this.logger.info(obj, message, ...meta);
} else {
this.logger.info(message, ...meta);
}
}
warn(message: string, ...meta: unknown[]): void {
const obj = meta.at(0);
if (obj) {
this.logger.warn(obj, message, ...meta);
} else {
this.logger.warn(message, ...meta);
}
}
error(message: string, ...meta: unknown[]): void {
const obj = meta.at(0);
if (obj) {
this.logger.error(obj, message, ...meta);
} else {
this.logger.error(message, ...meta);
}
}
}

View File

@@ -1,73 +1,89 @@
import { configDotenv } from 'dotenv';
import { readFile, rm, writeFile } from 'fs/promises';
import { MysaApiClient, MysaSession } from 'mysa-js-sdk';
import { pino } from 'pino';
import { Thermostat } from './thermostat';
#!/usr/bin/env node
configDotenv({
path: ['.env', '.env.local'],
override: true
});
/*
mysa2mqtt
Copyright (C) 2025 Pascal Bourque
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import { MqttSettings } from 'mqtt2ha';
import { MysaApiClient } from 'mysa-js-sdk';
import { pino } from 'pino';
import { PinoLogger } from './logger';
import { options } from './options';
import { loadSession, saveSession } from './session';
import { Thermostat } from './thermostat';
const rootLogger = pino({
name: 'mysa2mqtt',
level: process.env.MYSA_2_MQTT_LOG_LEVEL,
transport: {
target: 'pino-pretty',
options: {
colorize: true,
singleLine: true,
ignore: 'hostname,module',
messageFormat: '\x1b[33m[{module}]\x1b[39m {msg}'
}
}
}).child({ module: 'mysa2mqtt' });
level: options.logLevel,
transport:
options.logFormat === 'pretty'
? {
target: 'pino-pretty',
options: {
colorize: true,
singleLine: true,
ignore: 'hostname,module',
messageFormat: '\x1b[33m[{module}]\x1b[39m {msg}'
}
}
: undefined
});
/** Mysa2mqtt entry-point. */
async function main() {
rootLogger.info('Starting mysa2mqtt...');
let session: MysaSession | undefined;
try {
rootLogger.debug('Loading Mysa session...');
const sessionJson = await readFile('session.json', 'utf8');
session = JSON.parse(sessionJson);
} catch {
rootLogger.debug('No valid Mysa session file found.');
}
const client = new MysaApiClient(session, { logger: rootLogger.child({ module: 'mysa-js-sdk' }) });
const session = await loadSession(options.mysaSessionFile, rootLogger);
const client = new MysaApiClient(session, { logger: new PinoLogger(rootLogger.child({ module: 'mysa-js-sdk' })) });
client.emitter.on('sessionChanged', async (newSession) => {
if (newSession) {
rootLogger.debug('Saving new Mysa session...');
await writeFile('session.json', JSON.stringify(newSession));
} else {
try {
rootLogger.debug('Removing Mysa session file...');
await rm('session.json');
} catch {
// Ignore error if file does not exist
}
}
await saveSession(newSession, options.mysaSessionFile, rootLogger);
});
if (!client.isAuthenticated) {
rootLogger.info('Logging in...');
const username = process.env.MYSA_2_MQTT_USERNAME;
const password = process.env.MYSA_2_MQTT_PASSWORD;
if (!username || !password) {
throw new Error('Missing MYSA_2_MQTT_USERNAME or MYSA_2_MQTT_PASSWORD environment variables.');
}
await client.login(username, password);
await client.login(options.mysaUsername, options.mysaPassword);
}
const [devices, firmwares] = await Promise.all([client.getDevices(), client.getDeviceFirmwares()]);
const mqttSettings: MqttSettings = {
host: options.mqttHost,
port: options.mqttPort,
username: options.mqttUsername,
password: options.mqttPassword,
client_name: options.mqttClientName,
state_prefix: options.mqttTopicPrefix
};
const thermostats = Object.entries(devices.DevicesObj).map(
([, device]) =>
new Thermostat(client, device, rootLogger.child({ module: 'thermostat' }), firmwares.Firmware[device.Id])
new Thermostat(
client,
device,
mqttSettings,
new PinoLogger(rootLogger.child({ module: 'thermostat', deviceId: device.Id })),
firmwares.Firmware[device.Id]
)
);
for (const thermostat of thermostats) {

146
src/options.ts Normal file
View File

@@ -0,0 +1,146 @@
/*
mysa2mqtt
Copyright (C) 2025 Pascal Bourque
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import { Command, InvalidArgumentError, Option } from 'commander';
import { configDotenv } from 'dotenv';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
configDotenv({
path: ['.env', '.env.local'],
override: true
});
/**
* Gets the package version at runtime.
*
* @returns The package version or 'unknown' if it cannot be read.
*/
function getPackageVersion(): string {
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageJsonPath = join(__dirname, '..', 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
return packageJson.version || 'unknown';
} catch {
return 'unknown';
}
}
/**
* Parses a required integer value.
*
* @param value - The value to parse.
* @returns The parsed integer value.
* @throws InvalidArgumentError if the value is not a valid integer.
*/
function parseRequiredInt(value: string) {
const parsedValue = parseInt(value, 10);
if (isNaN(parsedValue)) {
throw new InvalidArgumentError('Must be a number.');
}
return parsedValue;
}
const extraHelpText = `
Copyright (c) 2025 Pascal Bourque
Licensed under the MIT License
Source code and documentation available at: https://github.com/bourquep/mysa2mqtt
`;
export const options = new Command('mysa2mqtt')
.version(getPackageVersion())
.description('Expose Mysa smart thermostats to home automation platforms via MQTT.')
.addHelpText('afterAll', extraHelpText)
.addOption(
new Option('-l, --log-level <logLevel>', 'log level')
.choices(['silent', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'])
.env('M2M_LOG_LEVEL')
.default('info')
.helpGroup('Configuration')
)
.addOption(
new Option('-f, --log-format <logFormat>', 'log format')
.choices(['pretty', 'json'])
.env('M2M_LOG_FORMAT')
.default('pretty')
.helpGroup('Configuration')
)
.addOption(
new Option('-H, --mqtt-host <mqttHost>', 'hostname of the MQTT broker')
.env('M2M_MQTT_HOST')
.makeOptionMandatory()
.helpGroup('MQTT')
)
.addOption(
new Option('-P, --mqtt-port <mqttPort>', 'port of the MQTT broker')
.env('M2M_MQTT_PORT')
.argParser(parseRequiredInt)
.default(1883)
.helpGroup('MQTT')
)
.addOption(
new Option('-U, --mqtt-username <mqttUsername>', 'username of the MQTT broker')
.env('M2M_MQTT_USERNAME')
.helpGroup('MQTT')
)
.addOption(
new Option('-B, --mqtt-password <mqttPassword>', 'password of the MQTT broker')
.env('M2M_MQTT_PASSWORD')
.helpGroup('MQTT')
)
.addOption(
new Option('-u, --mysa-username <mysaUsername>', 'Mysa account username')
.env('M2M_MYSA_USERNAME')
.makeOptionMandatory()
.helpGroup('Mysa')
)
.addOption(
new Option('-p, --mysa-password <mysaPassword>', 'Mysa account password')
.env('M2M_MYSA_PASSWORD')
.makeOptionMandatory()
.helpGroup('Mysa')
)
.addOption(
new Option('-s, --mysa-session-file <mysaSessionFile>', 'Mysa session file')
.env('M2M_MYSA_SESSION_FILE')
.default('session.json')
.helpGroup('Configuration')
)
.addOption(
new Option('-N, --mqtt-client-name <mqttClientName>', 'name of the MQTT client')
.env('M2M_MQTT_CLIENT_NAME')
.default('mysa2mqtt')
.helpGroup('MQTT')
)
.addOption(
new Option('-T, --mqtt-topic-prefix <mqttTopicPrefix>', 'prefix of the MQTT topic')
.env('M2M_MQTT_TOPIC_PREFIX')
.default('mysa2mqtt')
.helpGroup('MQTT')
)
.parse()
.opts();

69
src/session.ts Normal file
View File

@@ -0,0 +1,69 @@
/*
mysa2mqtt
Copyright (C) 2025 Pascal Bourque
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import { readFile, rm, writeFile } from 'fs/promises';
import { MysaSession } from 'mysa-js-sdk';
import pino from 'pino';
/**
* Loads a Mysa session from a file.
*
* @param filename - The path to the file containing the session data.
* @param logger - The logger instance to use for logging.
* @returns A promise that resolves to the loaded MysaSession object or undefined if the file is not found or invalid.
*/
export async function loadSession(filename: string, logger: pino.Logger): Promise<MysaSession | undefined> {
try {
logger.info('Loading Mysa session...');
const sessionJson = await readFile(filename, 'utf8');
return JSON.parse(sessionJson);
} catch {
logger.info('No valid Mysa session file found.');
}
}
/**
* Saves a Mysa session to a file.
*
* @param session - The MysaSession object to save.
* @param filename - The path to the file to save the session data to.
* @param logger - The logger instance to use for logging.
* @returns A promise that resolves when the session is saved.
*/
export async function saveSession(
session: MysaSession | undefined,
filename: string,
logger: pino.Logger
): Promise<void> {
if (session) {
logger.info('Saving Mysa session...');
await writeFile(filename, JSON.stringify(session));
} else {
try {
logger.debug('Removing Mysa session file...');
await rm(filename);
} catch {
// Ignore error if file does not exist
}
}
}

View File

@@ -1,37 +1,51 @@
/*
mysa2mqtt
Copyright (C) 2025 Pascal Bourque
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import { Climate, ClimateAction, DeviceConfiguration, Logger, MqttSettings, Sensor } from 'mqtt2ha';
import { DeviceBase, FirmwareDevice, MysaApiClient, MysaDeviceMode, StateChange, Status } from 'mysa-js-sdk';
export class Thermostat {
private isStarted = false;
private mqttSettings: MqttSettings;
private mqttDevice: DeviceConfiguration;
private mqttClimate: Climate;
private mqttPower: Sensor;
private readonly mqttDevice: DeviceConfiguration;
private readonly mqttClimate: Climate;
private readonly mqttPower: Sensor;
private readonly mysaStatusUpdateHandler = this.handleMysaStatusUpdate.bind(this);
private readonly mysaStateChangeHandler = this.handleMysaStateChange.bind(this);
constructor(
public client: MysaApiClient,
public device: DeviceBase,
private logger: Logger,
public deviceFirmware?: FirmwareDevice
public readonly mysaApiClient: MysaApiClient,
public readonly mysaDevice: DeviceBase,
private readonly mqttSettings: MqttSettings,
private readonly logger: Logger,
public readonly mysaDeviceFirmware?: FirmwareDevice
) {
this.mqttSettings = {
host: process.env.MYSA_2_MQTT_BROKER_HOST || 'localhost',
port: parseInt(process.env.MYSA_2_MQTT_BROKER_PORT || '1883'),
username: process.env.MYSA_2_MQTT_BROKER_USERNAME,
password: process.env.MYSA_2_MQTT_BROKER_PASSWORD,
client_name: 'mysa2mqtt',
state_prefix: 'mysa2mqtt'
};
this.mqttDevice = {
identifiers: device.Id,
name: device.Name,
identifiers: mysaDevice.Id,
name: mysaDevice.Name,
manufacturer: 'Mysa',
model: device.Model,
sw_version: deviceFirmware?.InstalledVersion
model: mysaDevice.Model,
sw_version: mysaDeviceFirmware?.InstalledVersion
};
this.mqttClimate = new Climate(
@@ -41,10 +55,10 @@ export class Thermostat {
component: {
component: 'climate',
device: this.mqttDevice,
unique_id: `mysa_${device.Id}_climate`,
unique_id: `mysa_${mysaDevice.Id}_climate`,
name: 'Thermostat',
min_temp: device.MinSetpoint,
max_temp: device.MaxSetpoint,
min_temp: mysaDevice.MinSetpoint,
max_temp: mysaDevice.MaxSetpoint,
modes: ['off', 'heat'], // TODO: AC
precision: 0.1,
temp_step: 0.5,
@@ -64,16 +78,16 @@ export class Thermostat {
async (topic, message) => {
switch (topic) {
case 'mode_command_topic':
this.client.setDeviceState(
this.device.Id,
this.mysaApiClient.setDeviceState(
this.mysaDevice.Id,
undefined,
message === 'off' ? 'off' : message === 'heat' ? 'heat' : undefined
);
break;
case 'power_command_topic':
this.client.setDeviceState(
this.device.Id,
this.mysaApiClient.setDeviceState(
this.mysaDevice.Id,
undefined,
message === 'OFF' ? 'off' : message === 'ON' ? 'heat' : undefined
);
@@ -81,9 +95,9 @@ export class Thermostat {
case 'temperature_command_topic':
if (message === '') {
this.client.setDeviceState(this.device.Id, undefined, undefined);
this.mysaApiClient.setDeviceState(this.mysaDevice.Id, undefined, undefined);
} else {
this.client.setDeviceState(this.device.Id, parseFloat(message), undefined);
this.mysaApiClient.setDeviceState(this.mysaDevice.Id, parseFloat(message), undefined);
}
break;
}
@@ -96,7 +110,7 @@ export class Thermostat {
component: {
component: 'sensor',
device: this.mqttDevice,
unique_id: `mysa_${device.Id}_power`,
unique_id: `mysa_${mysaDevice.Id}_power`,
device_class: 'power',
state_class: 'measurement',
unit_of_measurement: 'W',
@@ -114,8 +128,8 @@ export class Thermostat {
this.isStarted = true;
try {
const deviceStates = await this.client.getDeviceStates();
const state = deviceStates.DeviceStatesObj[this.device.Id];
const deviceStates = await this.mysaApiClient.getDeviceStates();
const state = deviceStates.DeviceStatesObj[this.mysaDevice.Id];
this.mqttClimate.currentTemperature = state.CorrectedTemp.v;
this.mqttClimate.currentHumidity = state.Humidity.v;
@@ -129,10 +143,10 @@ export class Thermostat {
await this.mqttPower.setState('state_topic', 'None');
await this.mqttPower.writeConfig();
this.client.emitter.on('statusChanged', this.mysaStatusUpdateHandler);
this.client.emitter.on('stateChanged', this.mysaStateChangeHandler);
this.mysaApiClient.emitter.on('statusChanged', this.mysaStatusUpdateHandler);
this.mysaApiClient.emitter.on('stateChanged', this.mysaStateChangeHandler);
await this.client.startRealtimeUpdates(this.device.Id);
await this.mysaApiClient.startRealtimeUpdates(this.mysaDevice.Id);
} catch (error) {
this.isStarted = false;
throw error;
@@ -146,16 +160,16 @@ export class Thermostat {
this.isStarted = false;
await this.client.stopRealtimeUpdates(this.device.Id);
await this.mysaApiClient.stopRealtimeUpdates(this.mysaDevice.Id);
this.client.emitter.off('statusChanged', this.mysaStatusUpdateHandler);
this.client.emitter.off('stateChanged', this.mysaStateChangeHandler);
this.mysaApiClient.emitter.off('statusChanged', this.mysaStatusUpdateHandler);
this.mysaApiClient.emitter.off('stateChanged', this.mysaStateChangeHandler);
await this.mqttPower.setState('state_topic', 'None');
}
private async handleMysaStatusUpdate(status: Status) {
if (!this.isStarted || status.deviceId !== this.device.Id) {
if (!this.isStarted || status.deviceId !== this.mysaDevice.Id) {
return;
}
@@ -165,7 +179,7 @@ export class Thermostat {
this.mqttClimate.targetTemperature = this.mqttClimate.currentMode !== 'off' ? status.setPoint : undefined;
if (status.current != null) {
const watts = this.device.Voltage * status.current;
const watts = this.mysaDevice.Voltage * status.current;
await this.mqttPower.setState('state_topic', watts.toFixed(2));
} else {
await this.mqttPower.setState('state_topic', 'None');
@@ -173,7 +187,7 @@ export class Thermostat {
}
private async handleMysaStateChange(state: StateChange) {
if (!this.isStarted || state.deviceId !== this.device.Id) {
if (!this.isStarted || state.deviceId !== this.mysaDevice.Id) {
return;
}

View File

@@ -6,10 +6,12 @@
"allowJs": false,
"strict": true,
"noEmit": true,
"resolveJsonModule": true,
"module": "ESNext",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "dist"]
}

38
tsup.config.cjs Normal file
View File

@@ -0,0 +1,38 @@
import { defineConfig } from 'tsup';
const banner = `/*
mysa2mqtt
Copyright (C) 2025 Pascal Bourque
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
`;
export default defineConfig({
entry: ['src/main.ts'],
platform: 'node',
format: ['esm'],
clean: true,
dts: false,
sourcemap: false,
minify: true,
banner: {
js: banner
}
});