Skip to content

Commit 082608f

Browse files
authored
Merge pull request #92 from meshcore-dev/2.2.0
2.2.0-missing
2 parents 0cfbf1d + 5c76341 commit 082608f

File tree

7 files changed

+249
-22
lines changed

7 files changed

+249
-22
lines changed

custom_components/meshcore/config_flow.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
CONF_CLIENT_DISABLE_PATH_RESET,
4444
DEFAULT_CLIENT_UPDATE_INTERVAL,
4545
CONF_CONTACT_REFRESH_INTERVAL,
46+
CONF_DEVICE_DISABLED,
4647
DEFAULT_CONTACT_REFRESH_INTERVAL,
4748
CONF_SELF_TELEMETRY_ENABLED,
4849
CONF_SELF_TELEMETRY_INTERVAL,
@@ -817,6 +818,7 @@ async def async_step_edit_repeater(self, user_input=None):
817818
repeater[CONF_REPEATER_TELEMETRY_ENABLED] = user_input[CONF_REPEATER_TELEMETRY_ENABLED]
818819
repeater[CONF_REPEATER_UPDATE_INTERVAL] = user_input[CONF_REPEATER_UPDATE_INTERVAL]
819820
repeater[CONF_REPEATER_DISABLE_PATH_RESET] = user_input[CONF_REPEATER_DISABLE_PATH_RESET]
821+
repeater[CONF_DEVICE_DISABLED] = user_input[CONF_DEVICE_DISABLED]
820822

821823
# Update config entry - deep copy entire data to ensure HA detects changes
822824
new_data = copy.deepcopy(dict(self.config_entry.data))
@@ -825,7 +827,7 @@ async def async_step_edit_repeater(self, user_input=None):
825827
self.hass.config_entries.async_update_entry(self.config_entry, data=new_data) # type: ignore
826828

827829
return await self.async_step_init()
828-
830+
829831
# Show current settings
830832
return self.async_show_form(
831833
step_id="edit_repeater",
@@ -837,6 +839,7 @@ async def async_step_edit_repeater(self, user_input=None):
837839
vol.Optional(CONF_REPEATER_TELEMETRY_ENABLED, default=repeater.get(CONF_REPEATER_TELEMETRY_ENABLED, False)): bool,
838840
vol.Optional(CONF_REPEATER_UPDATE_INTERVAL, default=repeater.get(CONF_REPEATER_UPDATE_INTERVAL, DEFAULT_REPEATER_UPDATE_INTERVAL)): vol.All(cv.positive_int, vol.Range(min=MIN_UPDATE_INTERVAL)),
839841
vol.Optional(CONF_REPEATER_DISABLE_PATH_RESET, default=repeater.get(CONF_REPEATER_DISABLE_PATH_RESET, False)): bool,
842+
vol.Optional(CONF_DEVICE_DISABLED, default=repeater.get(CONF_DEVICE_DISABLED, False)): bool,
840843
}),
841844
description_placeholders={
842845
"device_name": repeater.get("name", "Unknown")
@@ -861,20 +864,22 @@ async def async_step_edit_client(self, user_input=None):
861864
# Update client settings
862865
client[CONF_CLIENT_UPDATE_INTERVAL] = user_input[CONF_CLIENT_UPDATE_INTERVAL]
863866
client[CONF_CLIENT_DISABLE_PATH_RESET] = user_input[CONF_CLIENT_DISABLE_PATH_RESET]
867+
client[CONF_DEVICE_DISABLED] = user_input[CONF_DEVICE_DISABLED]
864868

865869
# Update config entry
866870
new_data = copy.deepcopy(dict(self.config_entry.data))
867871
new_data[CONF_TRACKED_CLIENTS] = copy.deepcopy(self.tracked_clients)
868872
self.hass.config_entries.async_update_entry(self.config_entry, data=new_data) # type: ignore
869873

870874
return await self.async_step_init()
871-
875+
872876
# Show current settings
873877
return self.async_show_form(
874878
step_id="edit_client",
875879
data_schema=vol.Schema({
876880
vol.Optional(CONF_CLIENT_UPDATE_INTERVAL, default=client.get(CONF_CLIENT_UPDATE_INTERVAL, DEFAULT_CLIENT_UPDATE_INTERVAL)): vol.All(cv.positive_int, vol.Range(min=MIN_UPDATE_INTERVAL)),
877881
vol.Optional(CONF_CLIENT_DISABLE_PATH_RESET, default=client.get(CONF_CLIENT_DISABLE_PATH_RESET, False)): bool,
882+
vol.Optional(CONF_DEVICE_DISABLED, default=client.get(CONF_DEVICE_DISABLED, False)): bool,
878883
}),
879884
description_placeholders={
880885
"device_name": client.get("name", "Unknown")

custom_components/meshcore/const.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@
7575
CONF_CLIENT_DISABLE_PATH_RESET: Final = "disable_path_reset"
7676
DEFAULT_CLIENT_UPDATE_INTERVAL: Final = 7200 # 2 hours in seconds
7777

78+
# Device monitoring
79+
CONF_DEVICE_DISABLED: Final = "disabled"
80+
7881
# Contact refresh interval
7982
CONF_CONTACT_REFRESH_INTERVAL: Final = "contact_refresh_interval"
8083
DEFAULT_CONTACT_REFRESH_INTERVAL: Final = 60 # 1 minute in seconds

custom_components/meshcore/coordinator.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
CONF_SELF_TELEMETRY_ENABLED,
4141
CONF_SELF_TELEMETRY_INTERVAL,
4242
DEFAULT_SELF_TELEMETRY_INTERVAL,
43+
CONF_DEVICE_DISABLED,
4344
)
4445
from .meshcore_api import MeshCoreAPI
4546

@@ -702,7 +703,10 @@ async def _async_update_data(self) -> None:
702703
if not repeater_config.get('name') or not repeater_config.get('pubkey_prefix'):
703704
_LOGGER.warning(f"Repeater config missing name or pubkey_prefix: {repeater_config}")
704705
continue
705-
706+
707+
if repeater_config.get(CONF_DEVICE_DISABLED, False):
708+
continue
709+
706710
pubkey_prefix = repeater_config.get("pubkey_prefix")
707711
repeater_name = repeater_config.get("name")
708712

@@ -737,11 +741,14 @@ async def _async_update_data(self) -> None:
737741
for repeater_config in self._tracked_repeaters:
738742
if not repeater_config.get('name') or not repeater_config.get('pubkey_prefix'):
739743
continue
740-
744+
745+
if repeater_config.get(CONF_DEVICE_DISABLED, False):
746+
continue
747+
741748
telemetry_enabled = repeater_config.get(CONF_REPEATER_TELEMETRY_ENABLED, False)
742749
if not telemetry_enabled:
743750
continue
744-
751+
745752
pubkey_prefix = repeater_config.get("pubkey_prefix")
746753
repeater_name = repeater_config.get("name")
747754

@@ -782,7 +789,10 @@ async def _async_update_data(self) -> None:
782789
if not client_config.get('name') or not client_config.get('pubkey_prefix'):
783790
_LOGGER.warning(f"Client config missing name or pubkey_prefix: {client_config}")
784791
continue
785-
792+
793+
if client_config.get(CONF_DEVICE_DISABLED, False):
794+
continue
795+
786796
pubkey_prefix = client_config.get("pubkey_prefix")
787797
client_name = client_config.get("name")
788798

custom_components/meshcore/select.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,11 +175,14 @@ def _get_contact_options(self) -> List[str]:
175175
# Format as "Name (pubkey12345)"
176176
option = f"{name} ({public_key[:12]})"
177177
contact_options.append(option)
178-
178+
179179
# Add a default option if no contacts found
180180
if not contact_options:
181181
return ["No contacts"]
182-
182+
183+
# Sort alphabetically (case-insensitive)
184+
contact_options.sort(key=str.lower)
185+
183186
return contact_options
184187
except Exception as ex:
185188
_LOGGER.error(f"Error getting contacts from API: {ex}")
@@ -276,7 +279,7 @@ def _get_discovered_contact_options(self) -> List[str]:
276279
"""Get list of discovered contacts not yet added."""
277280
all_contacts = self.coordinator.get_all_contacts()
278281

279-
discovered_options = [SELECT_NO_CONTACTS]
282+
discovered_options = []
280283

281284
for contact in all_contacts:
282285
if not isinstance(contact, dict):
@@ -289,7 +292,11 @@ def _get_discovered_contact_options(self) -> List[str]:
289292
option = f"{name} ({pubkey_prefix})"
290293
discovered_options.append(option)
291294

292-
return discovered_options
295+
# Sort alphabetically (case-insensitive)
296+
discovered_options.sort(key=str.lower)
297+
298+
# Add placeholder at the beginning
299+
return [SELECT_NO_CONTACTS] + discovered_options
293300

294301
@callback
295302
def _handle_coordinator_update(self) -> None:
@@ -345,7 +352,7 @@ def _get_added_contact_options(self) -> List[str]:
345352
"""Get list of contacts already added to node."""
346353
all_contacts = self.coordinator.get_all_contacts()
347354

348-
added_options = [SELECT_NO_CONTACTS]
355+
added_options = []
349356
for contact in all_contacts:
350357
if not isinstance(contact, dict):
351358
continue
@@ -357,7 +364,11 @@ def _get_added_contact_options(self) -> List[str]:
357364
option = f"{name} ({pubkey_prefix})"
358365
added_options.append(option)
359366

360-
return added_options
367+
# Sort alphabetically (case-insensitive)
368+
added_options.sort(key=str.lower)
369+
370+
# Add placeholder at the beginning
371+
return [SELECT_NO_CONTACTS] + added_options
361372

362373
@callback
363374
def _handle_coordinator_update(self) -> None:

custom_components/meshcore/services.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,17 @@ async def async_execute_command_service(call: ServiceCall) -> None:
483483
_LOGGER.debug(f"Executing {command_name} with prepared arguments: {prepared_args}")
484484
result = await command_method(*prepared_args)
485485

486+
# Update coordinator channel info after set_channel
487+
if command_name == "set_channel" and result.type != EventType.ERROR:
488+
channel_idx = prepared_args[0]
489+
# Fetch updated channel info
490+
channel_info_result = await api.mesh_core.commands.get_channel(channel_idx)
491+
if channel_info_result.type != EventType.ERROR:
492+
coordinator._channel_info[channel_idx] = channel_info_result.payload
493+
_LOGGER.info(f"Updated channel {channel_idx} info: {channel_info_result.payload}")
494+
# Trigger coordinator update to refresh select entities
495+
coordinator.async_set_updated_data(coordinator.data)
496+
486497
# Mark contacts as dirty after add_contact or remove_contact so next ensure_contacts() will sync
487498
if command_name == "add_contact" and result.type != EventType.ERROR:
488499
api.mesh_core._contacts_dirty = True

docs/docs/messaging.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,121 @@ Message activity creates binary sensor entities that track communication:
111111
- **State**: Always "Active" when messages exist
112112
- **Attributes**: Public key
113113

114+
## Channel Configuration
115+
116+
Meshcore devices support multiple channels with configurable names and hash-based encryption.
117+
118+
### Hash-Based Channel Encryption
119+
120+
Channels use hash-based encryption where the channel name is hashed to derive the encryption key. Only nodes with the exact channel name can decrypt messages on that channel.
121+
122+
**How it works:**
123+
- Each channel has a name and a hash derived from that name
124+
- The hash is used as the encryption key for the channel
125+
- Only devices configured with the same name+hash combination can communicate
126+
- You can create private channels by using unique names and sharing them securely
127+
128+
### Setting Channel Names
129+
130+
Use the `set_channel` command to configure channel names:
131+
132+
```yaml
133+
service: meshcore.execute_command
134+
data:
135+
command: "set_channel 1 #pdx {{ '#pdx' | sha256 | truncate(32, true, '') }}"
136+
```
137+
138+
**Important**: When using `#` in YAML, the entire command must be quoted (as shown above) since `#` starts a comment in YAML.
139+
140+
#### Command Format
141+
142+
```
143+
set_channel <channel_idx> <name> <hash>
144+
```
145+
146+
- **channel_idx**: Channel number (e.g., 0, 1, 2, etc.)
147+
- **name**: Display name (e.g., `#pdx`, `private`, `work`, `my-secret-channel`)
148+
- **hash**: SHA256 hash of the channel name, truncated to 32 characters
149+
150+
The template `{{ '#pdx' | sha256 | truncate(32, true, '') }}` automatically generates the correct hash from the name.
151+
152+
**Note**: The `#` prefix is a convention for public/community channels but is not required. You can name channels anything you want.
153+
154+
### Channel Management UI Card
155+
156+
Create a dashboard card to manage your channels:
157+
158+
```yaml
159+
type: vertical-stack
160+
cards:
161+
- type: markdown
162+
content: |
163+
## Channel Configuration
164+
Configure channels with hash-based encryption.
165+
- type: entities
166+
entities:
167+
- entity: input_text.meshcore_channel_name
168+
- entity: input_number.meshcore_channel_index
169+
- type: button
170+
name: Set Channel
171+
icon: mdi:pound
172+
tap_action:
173+
action: call-service
174+
service: meshcore.execute_command
175+
data:
176+
command: >
177+
set_channel {{ states('input_number.meshcore_channel_index') | int }}
178+
{{ states('input_text.meshcore_channel_name') }}
179+
{{ states('input_text.meshcore_channel_name') | sha256 | truncate(32, true, '') }}
180+
```
181+
182+
**Required Helper Entities** (create in Settings → Devices & Services → Helpers):
183+
184+
1. **Channel Index** (Number):
185+
- Name: `Meshcore Channel Index`
186+
- Entity ID: `input_number.meshcore_channel_index`
187+
- Min: 0, Max: 99, Step: 1
188+
- Icon: `mdi:numeric`
189+
190+
2. **Channel Name** (Text):
191+
- Name: `Meshcore Channel Name`
192+
- Entity ID: `input_text.meshcore_channel_name`
193+
- Max length: 32
194+
- Icon: `mdi:pound`
195+
196+
### Example Channel Configurations
197+
198+
#### Public Channel (Convention: # prefix)
199+
```yaml
200+
service: meshcore.execute_command
201+
data:
202+
command: "set_channel 0 #public {{ '#public' | sha256 | truncate(32, true, '') }}"
203+
```
204+
205+
#### Regional Channel
206+
```yaml
207+
service: meshcore.execute_command
208+
data:
209+
command: "set_channel 1 #pdx {{ '#pdx' | sha256 | truncate(32, true, '') }}"
210+
```
211+
212+
#### Private Channel (Any name)
213+
```yaml
214+
service: meshcore.execute_command
215+
data:
216+
command: "set_channel 2 my-secret-channel {{ 'my-secret-channel' | sha256 | truncate(32, true, '') }}"
217+
```
218+
219+
### Viewing Configured Channels
220+
221+
To see your currently configured channels, use the **MeshCore Channel** select entity:
222+
- Entity ID: `select.meshcore_channel`
223+
- Shows all configured channels with their names
224+
- Displays as "Name (idx)" format (e.g., "#pdx (1)", "work (2)")
225+
- Updates automatically when channels are configured
226+
227+
Use this select entity in your messaging UI to choose which channel to send to.
228+
114229
## Message Services
115230

116231
Send messages using these services:

0 commit comments

Comments
 (0)