Read the GSM 03.38 message class where the spec puts it (#95)
* Regression tests for the GSM 03.38 message class behind sms.flash * Read the GSM 03.38 message class where the spec puts it, and refuse a flash message no alphabet can carry * Keep the code span in the message-class decision on one line * Stop offering the flash message class as an alphabet, and check the encoding option by name * Settle every refusable send option in one check, and record what the review left open * Say what the flash refusal and the encoding option actually do * Keep the README off the alphabet whose long messages do not fit
This commit is contained in:
@@ -207,7 +207,10 @@ exactly 140.
|
|||||||
stays there because that is a different peer rather than a second copy of this one. The waiting
|
stays there because that is a different peer rather than a second copy of this one. The waiting
|
||||||
helpers each file carries are copies, tolerated because a wrong one fails that file's own tests and
|
helpers each file carries are copies, tolerated because a wrong one fails that file's own tests and
|
||||||
nothing else, and a helper that only names the parameters of one `objToPdu()` call is on that same
|
nothing else, and a helper that only names the parameters of one `objToPdu()` call is on that same
|
||||||
footing — it encodes no wire fact `objToPdu()` does not already own.
|
footing — it encodes no wire fact `objToPdu()` does not already own. So is a stub standing in for a
|
||||||
|
collaborator the type system already keeps in step: `recordingDeps()` in `messaging-mode.test.ts`
|
||||||
|
and `message-class.test.ts` is one `SendSmsDeps.send` that answers nothing, and a field added to
|
||||||
|
that type fails to compile in both copies at once.
|
||||||
- `message_id` values the library generates are UUID v7.
|
- `message_id` values the library generates are UUID v7.
|
||||||
- A test that needs a dummy peer must `resume()` its sockets. An unread socket never processes the
|
- A test that needs a dummy peer must `resume()` its sockets. An unread socket never processes the
|
||||||
peer's FIN, so `server.close()` hangs forever — that is a test bug, not a library one.
|
peer's FIN, so `server.close()` hangs forever — that is a test bug, not a library one.
|
||||||
@@ -421,6 +424,46 @@ Grouped by what each one constrains.
|
|||||||
which goal 2 reports as undetermined rather than guessed. An inbound message is untouched: nothing
|
which goal 2 reports as undetermined rather than guessed. An inbound message is untouched: nothing
|
||||||
but `data_coding` can say how a message was written.
|
but `data_coding` can say how a message was written.
|
||||||
|
|
||||||
|
- **A message class is read where GSM 03.38 puts it, `flash` is class 0 alone, and a flash message
|
||||||
|
with no alphabet to carry it is refused.** Maintainer's call, 2026-09-09, closing the last target
|
||||||
|
of the interoperability plan: `sms.flash` was `(data_coding & 0xF0) === 0x10`, which called the
|
||||||
|
ME-, SIM- and TE-specific classes immediate display and missed the 0xF0 group entirely — the only
|
||||||
|
one SMPP 3.4 5.2.19 names, since it marks 0x0F to 0xBF reserved and hands 0xF0 to 0xFF to GSM
|
||||||
|
03.38, and the one SMPPSim demonstrated
|
||||||
|
([interop-tests/findings/02-smppsim.md](interop-tests/findings/02-smppsim.md), C17).
|
||||||
|
`messageClassOf()` is the single answer to whether a `data_coding` carries a class and which, as
|
||||||
|
`concatOf()` is to how a PDU says it is a segment: 03.38 section 4 puts the class in bits 1-0,
|
||||||
|
carried where bit 4 says so in every group below 0x80 and always in the 0xF0 group, and
|
||||||
|
`encodingByDataCoding()` reads the alphabet off that same test rather than repeating the group
|
||||||
|
masks beside it. It is exported for the reason `concatOf()` is — an application that needs a class
|
||||||
|
other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on the
|
||||||
|
`sms` event, which pays goal 6 for three classes nothing here acts on, where the boolean the
|
||||||
|
application already had covers the one it does. Compressed text is out of scope and stays out —
|
||||||
|
nothing here implements 3GPP TS 23.042, so a compressed body reaches the application as whatever
|
||||||
|
its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as
|
||||||
|
class 0 rather than special-cased into a wrong answer; 01xx is read for the same reason, 03.38
|
||||||
|
coding it exactly as 00xx. Rejected: reading only the two groups the defect named, which needs an
|
||||||
|
extra test to produce a wrong answer for a class the spec puts in plain sight. Accepted: the
|
||||||
|
alphabet is read only where a class is, so 0x58 is UCS2 while 0x48 — the same alphabet with the
|
||||||
|
class bit clear — stays ASCII, because below 0x10 SMPP's flat table contradicts 03.38 and wins
|
||||||
|
(0x03 is Latin-1 there, GSM 7-bit here) and a class is the only evidence a peer below 0x80 is
|
||||||
|
spelling 03.38 at all. Send-side: `flash`
|
||||||
|
is that class, so it goes out as 0x18 beside UCS2 and 0x10 beside GSM 7-bit, while
|
||||||
|
`encoding: 'LATIN1'` beside it is refused before a segment goes out, the way a messaging mode this
|
||||||
|
library cannot deliver is — 03.38's class groups hold GSM 7-bit, 8-bit data and UCS2, and Latin-1 is
|
||||||
|
SMPP's own flat-table alphabet, so the pair has no spelling. Rejected: 0x10 with Latin-1 octets,
|
||||||
|
which declares an alphabet the body is not in; rejected: 0x14, 8-bit data, which is not text to
|
||||||
|
the handset that would display it; rejected: promoting it to UCS2, which overrides the one option
|
||||||
|
the caller wrote in order to override a choice. Rejected with them: `encoding: 'FLASH'`, which
|
||||||
|
named `data_coding` 0x10 among the alphabets and so reached the class through the option that
|
||||||
|
chooses a charset — a second spelling of `flash: true` that also flattened every non-GSM character
|
||||||
|
to a space on the way. It leaves `EncodingName`, which is now exactly the three codecs `detect()`
|
||||||
|
and `encodingByDataCoding()` return, and `encoding` is checked by name like `messagingMode` so a
|
||||||
|
caller without types gets a refusal rather than a throw out of the codec table. `consts.ENCODING`
|
||||||
|
keeps its `FLASH` entry: the low-level surface reaches raw constants, and nothing reads that group
|
||||||
|
as an alphabet any more. Accepted: `flash` is now false for `data_coding` 0x11 to 0x13, which no
|
||||||
|
peer means as immediate display.
|
||||||
|
|
||||||
- **A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and
|
- **A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and
|
||||||
`SCHEDULED` say otherwise.** SMPP 3.4 Appendix B lists every other receipt state as final,
|
`SCHEDULED` say otherwise.** SMPP 3.4 Appendix B lists every other receipt state as final,
|
||||||
`UNKNOWN` and `ACCEPTED` included, so a peer writing `ACCEPTD` for a carrier-accepted step is taken
|
`UNKNOWN` and `ACCEPTED` included, so a peer writing `ACCEPTD` for a carrier-accepted step is taken
|
||||||
|
|||||||
@@ -122,6 +122,16 @@ defines the delivery report away, so pair it with `dlr: true` and the send is re
|
|||||||
leaving you waiting for a report that cannot come — as is any value naming no mode, both before a
|
leaving you waiting for a report that cannot come — as is any value naming no mode, both before a
|
||||||
segment goes out.
|
segment goes out.
|
||||||
|
|
||||||
|
`encoding` names the alphabet, and is `ASCII` (GSM 03.38's own 7-bit table), `LATIN1` or `UCS2`;
|
||||||
|
anything else is refused by name rather than guessed at. Leave it out and a message that fits GSM
|
||||||
|
7-bit goes as `ASCII` and everything else as `UCS2`; `LATIN1` is only ever used when you name it.
|
||||||
|
|
||||||
|
`flash` asks for GSM 03.38 message class 0, the class a handset shows on arrival instead of storing.
|
||||||
|
It travels in `data_coding` beside the alphabet, so a flash UCS2 message stays UCS2. Pairing it with
|
||||||
|
`encoding: 'LATIN1'` is the one combination with nowhere to go — a message class carries GSM 7-bit,
|
||||||
|
8-bit data or UCS2, and 8-bit data is not text a handset will display — so the send is refused
|
||||||
|
before anything goes out.
|
||||||
|
|
||||||
Messages too long for one SMS are split automatically and sent as a concatenated message. You get
|
Messages too long for one SMS are split automatically and sent as a concatenated message. You get
|
||||||
one id per segment:
|
one id per segment:
|
||||||
|
|
||||||
@@ -157,6 +167,10 @@ session.on('sms', async sms => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`sms.flash` is true where the message's `data_coding` carries GSM 03.38 message class 0, in every
|
||||||
|
coding group that carries one — so `0x10`, `0x18`, `0x50` and `0xF0` alike. The other three classes
|
||||||
|
name where the handset stores the message rather than that it displays it, so they are not flash.
|
||||||
|
|
||||||
Delivery receipts travel on the same SMPP command but reach you as `dlr`, so nothing you write has
|
Delivery receipts travel on the same SMPP command but reach you as `dlr`, so nothing you write has
|
||||||
to tell the two apart. `esm_class` is what tells them apart; where it names no message type a
|
to tell the two apart. `esm_class` is what tells them apart; where it names no message type a
|
||||||
`receipted_message_id` TLV does, and failing both the message body is read for the standard
|
`receipted_message_id` TLV does, and failing both the message body is read for the standard
|
||||||
@@ -545,7 +559,10 @@ answer to which of the two the peer used, and gives back the octets undecoded:
|
|||||||
`decodeMessage(octets, pduObj.params.data_coding, pduObj.params.esm_class)` turns them into text and
|
`decodeMessage(octets, pduObj.params.data_coding, pduObj.params.esm_class)` turns them into text and
|
||||||
hands back the UDH where the PDU carries one. `concatOf(pduObj)` is the same for concatenation: the
|
hands back the UDH where the PDU carries one. `concatOf(pduObj)` is the same for concatenation: the
|
||||||
`part`, `total` and `reference` a PDU declares, and the `spelling` — `'udh'` or `'sar'` — that
|
`part`, `total` and `reference` a PDU declares, and the `spelling` — `'udh'` or `'sar'` — that
|
||||||
carried them, or `undefined` where the PDU is a whole message.
|
carried them, or `undefined` where the PDU is a whole message. `messageClassOf(dataCoding)` is the
|
||||||
|
same for the GSM 03.38 message class: `0` for the flash class `sms.flash` already reports, `1`, `2`
|
||||||
|
and `3` for the ME-, SIM- and TE-specific ones, and `undefined` where that `data_coding`'s coding
|
||||||
|
group carries no class at all.
|
||||||
|
|
||||||
The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`,
|
The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`,
|
||||||
`types`, and the matching `*ById` maps) and grouped as `defs`.
|
`types`, and the matching `*ById` maps) and grouped as `defs`.
|
||||||
@@ -599,7 +616,10 @@ have worked around any of these, remove the workaround:
|
|||||||
- Every receipt went out as `esm_class` 0x04, which SMPP 3.4 defines as the report of a message's
|
- Every receipt went out as `esm_class` 0x04, which SMPP 3.4 defines as the report of a message's
|
||||||
final state. A receipt for a transient state — `sendDlr('ENROUTE')` — is now marked 0x20, the
|
final state. A receipt for a transient state — `sendDlr('ENROUTE')` — is now marked 0x20, the
|
||||||
intermediate delivery notification.
|
intermediate delivery notification.
|
||||||
- `flash: true` discarded UCS2, mangling flash messages containing non-GSM characters.
|
- `flash: true` discarded UCS2, mangling flash messages containing non-GSM characters, and put the
|
||||||
|
GSM alphabet on a Latin-1 message that has no `data_coding` at all — that pair is refused now.
|
||||||
|
Inbound, only a `data_coding` of exactly 0x10 counted as flash, so a flash UCS2 message and the
|
||||||
|
whole 0xF0 coding group arrived as ordinary messages.
|
||||||
- The multipart reference counter was shared by every session in the process.
|
- The multipart reference counter was shared by every session in the process.
|
||||||
- `tls: true` never performed a handshake, so the connection was not actually encrypted.
|
- `tls: true` never performed a handshake, so the connection was not actually encrypted.
|
||||||
- Alphanumeric senders were sent with TON 1 (international) instead of TON 5.
|
- Alphanumeric senders were sent with TON 1 (international) instead of TON 5.
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ tests passing, `malformed: 0`, `expert errors: 0` in both.
|
|||||||
| C17 (encodings over loopback) | pass | `smppsim - C17 …`, 5 sub-tests: Latin-1, UCS-2, flash (0x10), raw 0xF0 (not flash), raw UDH+8-bit-binary |
|
| C17 (encodings over loopback) | pass | `smppsim - C17 …`, 5 sub-tests: Latin-1, UCS-2, flash (0x10), raw 0xF0 (not flash), raw UDH+8-bit-binary |
|
||||||
| C18 (`smppsim-outbind`) | pass (record, not judge) | `smppsim-outbind - C18 …`; see below for the wire facts |
|
| C18 (`smppsim-outbind`) | pass (record, not judge) | `smppsim-outbind - C18 …`; see below for the wire facts |
|
||||||
|
|
||||||
|
C17's `raw 0xF0 (not flash)` recorded a `@larvit/smpp` defect, fixed in
|
||||||
|
[#95](https://github.com/larvit/larvitsmpp/pull/95): 0xF0 is GSM 03.38 message class 0, so it reads
|
||||||
|
as flash now.
|
||||||
|
|
||||||
## Defects in @larvit/smpp
|
## Defects in @larvit/smpp
|
||||||
|
|
||||||
### A delivery receipt's `data_coding` is trusted to decode its body, even though the spec makes the receipt a fixed text format
|
### A delivery receipt's `data_coding` is trusted to decode its body, even though the spec makes the receipt a fixed text format
|
||||||
|
|||||||
+28
-11
@@ -1,4 +1,4 @@
|
|||||||
export type EncodingName = 'ASCII' | 'FLASH' | 'LATIN1' | 'UCS2';
|
export type EncodingName = 'ASCII' | 'LATIN1' | 'UCS2';
|
||||||
|
|
||||||
export type Encoding = {
|
export type Encoding = {
|
||||||
decode: (buffer: Uint8Array) => string;
|
decode: (buffer: Uint8Array) => string;
|
||||||
@@ -104,11 +104,16 @@ const ucs2: Encoding = {
|
|||||||
|
|
||||||
export const encodings: Record<EncodingName, Encoding> = {
|
export const encodings: Record<EncodingName, Encoding> = {
|
||||||
ASCII: ascii,
|
ASCII: ascii,
|
||||||
FLASH: ascii,
|
|
||||||
LATIN1: latin1,
|
LATIN1: latin1,
|
||||||
UCS2: ucs2,
|
UCS2: ucs2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const encodingNames: readonly string[] = Object.keys(encodings);
|
||||||
|
|
||||||
|
export function isEncodingName(value: unknown): value is EncodingName {
|
||||||
|
return typeof value === 'string' && Object.hasOwn(encodings, value);
|
||||||
|
}
|
||||||
|
|
||||||
export function detect(value: string): EncodingName {
|
export function detect(value: string): EncodingName {
|
||||||
if (encodings.ASCII.match(value)) return 'ASCII';
|
if (encodings.ASCII.match(value)) return 'ASCII';
|
||||||
if (encodings.LATIN1.match(value)) return 'LATIN1';
|
if (encodings.LATIN1.match(value)) return 'LATIN1';
|
||||||
@@ -116,21 +121,33 @@ export function detect(value: string): EncodingName {
|
|||||||
return 'UCS2';
|
return 'UCS2';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The 0x1X and 0xFX ranges carry a GSM message class and put the alphabet in bits 3-2 or bit 2. */
|
/**
|
||||||
function messageClassEncoding(dataCoding: number): EncodingName | undefined {
|
* The GSM 03.38 section 4 message class a `data_coding` octet carries, in bits 1-0, or undefined
|
||||||
if ((dataCoding & 0xF0) === 0x10) {
|
* where its coding group carries none. Below 0x80 bit 4 says whether one is there; 0xF0 always is.
|
||||||
const alphabet = (dataCoding >> 2) & 0x03;
|
*/
|
||||||
|
export function messageClassOf(dataCoding: number): number | undefined {
|
||||||
if (alphabet === 0x01) return 'LATIN1';
|
if ((dataCoding & 0x80) === 0) {
|
||||||
|
return (dataCoding & 0x10) === 0x10 ? dataCoding & 0x03 : undefined;
|
||||||
return alphabet === 0x02 ? 'UCS2' : 'ASCII';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (dataCoding & 0xF0) === 0xF0 ? dataCoding & 0x03 : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A class is the only evidence a peer below 0x80 is spelling 03.38 rather than SMPP's flat table,
|
||||||
|
// which contradicts it and wins: 0x03 is Latin-1 here, GSM 7-bit there.
|
||||||
|
/** A class group puts the alphabet in bits 3-2, or in bit 2 alone above 0xF0. */
|
||||||
|
function messageClassEncoding(dataCoding: number): EncodingName | undefined {
|
||||||
|
if (messageClassOf(dataCoding) === undefined) return undefined;
|
||||||
|
|
||||||
if ((dataCoding & 0xF0) === 0xF0) {
|
if ((dataCoding & 0xF0) === 0xF0) {
|
||||||
return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'ASCII';
|
return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'ASCII';
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
const alphabet = (dataCoding >> 2) & 0x03;
|
||||||
|
|
||||||
|
if (alphabet === 0x01) return 'LATIN1';
|
||||||
|
|
||||||
|
return alphabet === 0x02 ? 'UCS2' : 'ASCII';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ export { Session } from './session.ts';
|
|||||||
|
|
||||||
export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.ts';
|
export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.ts';
|
||||||
export { consts, constsById } from './defs/constants.ts';
|
export { consts, constsById } from './defs/constants.ts';
|
||||||
export { detect, encodingByDataCoding, encodings } from './defs/encodings.ts';
|
export { detect, encodingByDataCoding, encodings, messageClassOf } from './defs/encodings.ts';
|
||||||
export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts';
|
export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts';
|
||||||
export { tlvs, tlvsById } from './defs/tlvs.ts';
|
export { tlvs, tlvsById } from './defs/tlvs.ts';
|
||||||
export { types } from './defs/types.ts';
|
export { types } from './defs/types.ts';
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ export function bitCount(message: string, encoding?: EncodingName): number {
|
|||||||
const encoded = encodings[resolved].encode(message);
|
const encoded = encodings[resolved].encode(message);
|
||||||
|
|
||||||
// GSM characters are packed seven bits to a septet; everything else stays octet-aligned.
|
// GSM characters are packed seven bits to a septet; everything else stays octet-aligned.
|
||||||
return resolved === 'ASCII' || resolved === 'FLASH' ? encoded.length * 7 : encoded.length * 8;
|
return resolved === 'ASCII' ? encoded.length * 7 : encoded.length * 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+56
-9
@@ -7,7 +7,7 @@ import type { SmppLog } from './log.ts';
|
|||||||
import type { SmsIdNotation } from './sms-id.ts';
|
import type { SmsIdNotation } from './sms-id.ts';
|
||||||
import { UnansweredError } from './unanswered-error.ts';
|
import { UnansweredError } from './unanswered-error.ts';
|
||||||
import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts';
|
import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts';
|
||||||
import { detect } from './defs/encodings.ts';
|
import { detect, encodingNames, isEncodingName } from './defs/encodings.ts';
|
||||||
import { namedValue } from './error-from.ts';
|
import { namedValue } from './error-from.ts';
|
||||||
import { normaliseSmsId } from './sms-id.ts';
|
import { normaliseSmsId } from './sms-id.ts';
|
||||||
import { paramText } from './defs/types.ts';
|
import { paramText } from './defs/types.ts';
|
||||||
@@ -32,8 +32,11 @@ export type SendSmsOptions = {
|
|||||||
validityPeriod?: Date | number | string;
|
validityPeriod?: Date | number | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The options as they arrive: a caller without types can put anything in the checked field. */
|
/** The options as they arrive: a caller without types can put anything in the checked fields. */
|
||||||
export type SendSmsInput = Omit<SendSmsOptions, 'messagingMode'> & { messagingMode?: unknown };
|
export type SendSmsInput = Omit<SendSmsOptions, 'encoding' | 'messagingMode'> & {
|
||||||
|
encoding?: unknown;
|
||||||
|
messagingMode?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
/** Both arrays hold what the peer accepted, so a partial failure names what is already delivered. */
|
/** Both arrays hold what the peer accepted, so a partial failure names what is already delivered. */
|
||||||
export type SendSmsResult = {
|
export type SendSmsResult = {
|
||||||
@@ -57,6 +60,12 @@ export type SendSmsDeps = {
|
|||||||
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** What the checks below settle, before a segment exists to carry it. */
|
||||||
|
type CheckedOptions = {
|
||||||
|
encoding: EncodingName;
|
||||||
|
messagingMode: SubmitMessagingMode;
|
||||||
|
};
|
||||||
|
|
||||||
type SegmentOptions = {
|
type SegmentOptions = {
|
||||||
encoding: EncodingName;
|
encoding: EncodingName;
|
||||||
messagingMode?: SubmitMessagingMode;
|
messagingMode?: SubmitMessagingMode;
|
||||||
@@ -140,6 +149,45 @@ function checkMessagingMode(
|
|||||||
return { err: refusedMode(mode) };
|
return { err: refusedMode(mode) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refusedEncoding(encoding: unknown): Error {
|
||||||
|
if (encoding === 'FLASH') {
|
||||||
|
return new Error('encoding FLASH is a message class rather than an alphabet; ask for it as flash: true beside the alphabet you want');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Error(`encoding must be ${encodingNames.join(', ')}, got ${namedValue(encoding)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkEncoding(encoding: unknown, message: string): Result<{ encoding: EncodingName }> {
|
||||||
|
if (encoding === undefined) return { encoding: detect(message) };
|
||||||
|
if (isEncodingName(encoding)) return { encoding };
|
||||||
|
|
||||||
|
return { err: refusedEncoding(encoding) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GSM 03.38 section 4 gives the class groups GSM 7-bit, 8-bit data and UCS2, and no Latin-1 at all. */
|
||||||
|
function checkFlash(encoding: EncodingName, flash: boolean): Error | undefined {
|
||||||
|
if (!flash || encoding !== 'LATIN1') return undefined;
|
||||||
|
|
||||||
|
return new Error('flash has no Latin-1 spelling: a message class carries GSM 7-bit, 8-bit data or UCS2, and 8-bit data is not text a handset will display, so send it as UCS2 or drop flash');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every option a send can be refused for, so nothing is built for a message that will not go. */
|
||||||
|
function checkOptions(sms: SendSmsInput): Result<CheckedOptions> {
|
||||||
|
const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true);
|
||||||
|
|
||||||
|
if (mode.err) return { err: mode.err };
|
||||||
|
|
||||||
|
const chosen = checkEncoding(sms.encoding, sms.message);
|
||||||
|
|
||||||
|
if (chosen.err) return { err: chosen.err };
|
||||||
|
|
||||||
|
const unspellable = checkFlash(chosen.encoding, sms.flash === true);
|
||||||
|
|
||||||
|
if (unspellable) return { err: unspellable };
|
||||||
|
|
||||||
|
return { encoding: chosen.encoding, messagingMode: mode.messagingMode };
|
||||||
|
}
|
||||||
|
|
||||||
/** Nothing goes on the wire until the whole message fits: a half-sent message bills twice. */
|
/** Nothing goes on the wire until the whole message fits: a half-sent message bills twice. */
|
||||||
function checkSegments(allowed: number, segments: number): Error | undefined {
|
function checkSegments(allowed: number, segments: number): Error | undefined {
|
||||||
if (!Number.isInteger(allowed) || allowed < 1 || allowed > maxSegments) {
|
if (!Number.isInteger(allowed) || allowed < 1 || allowed > maxSegments) {
|
||||||
@@ -186,14 +234,13 @@ function collectSent(
|
|||||||
|
|
||||||
/** Puts a message on the wire as one submit_sm per segment. */
|
/** Puts a message on the wire as one submit_sm per segment. */
|
||||||
export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise<SendSmsResult> {
|
export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise<SendSmsResult> {
|
||||||
const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true);
|
const options = checkOptions(sms);
|
||||||
|
|
||||||
if (mode.err) return unsent(mode.err);
|
if (options.err) return unsent(options.err);
|
||||||
|
|
||||||
const allowed = sms.maxSegments ?? maxSegments;
|
const encoding = options.encoding;
|
||||||
const encoding = sms.encoding ?? detect(sms.message);
|
|
||||||
const segments = splitMessage(sms.message, { encoding, reference: deps.reference });
|
const segments = splitMessage(sms.message, { encoding, reference: deps.reference });
|
||||||
const refused = checkSegments(allowed, segments.length);
|
const refused = checkSegments(sms.maxSegments ?? maxSegments, segments.length);
|
||||||
|
|
||||||
if (refused) return unsent(refused);
|
if (refused) return unsent(refused);
|
||||||
|
|
||||||
@@ -205,7 +252,7 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsInput): Promise<S
|
|||||||
// segment before answering — this library's own server does — would otherwise deadlock.
|
// segment before answering — this library's own server does — would otherwise deadlock.
|
||||||
const sent = await Promise.all(segments.map(segment => deps.send({
|
const sent = await Promise.all(segments.map(segment => deps.send({
|
||||||
cmdName: 'submit_sm',
|
cmdName: 'submit_sm',
|
||||||
params: submitSmParams(sms, segment, { encoding, messagingMode: mode.messagingMode, multipart }),
|
params: submitSmParams(sms, segment, { encoding, messagingMode: options.messagingMode, multipart }),
|
||||||
})));
|
})));
|
||||||
|
|
||||||
return collectSent(sent, deps.respIdNotation);
|
return collectSent(sent, deps.respIdNotation);
|
||||||
|
|||||||
+6
-1
@@ -5,6 +5,7 @@ import type { Result, VoidResult } from './result.ts';
|
|||||||
import type { Session } from './session.ts';
|
import type { Session } from './session.ts';
|
||||||
import { UnansweredError } from './unanswered-error.ts';
|
import { UnansweredError } from './unanswered-error.ts';
|
||||||
import { consts } from './defs/constants.ts';
|
import { consts } from './defs/constants.ts';
|
||||||
|
import { messageClassOf } from './defs/encodings.ts';
|
||||||
import { receiptCodes, transientStates } from './dlr.ts';
|
import { receiptCodes, transientStates } from './dlr.ts';
|
||||||
import { smppDate } from './message.ts';
|
import { smppDate } from './message.ts';
|
||||||
import { respIdParams, segmentId } from './sms-id.ts';
|
import { respIdParams, segmentId } from './sms-id.ts';
|
||||||
@@ -35,6 +36,7 @@ export type Sms = {
|
|||||||
*/
|
*/
|
||||||
answeredOnArrival: boolean;
|
answeredOnArrival: boolean;
|
||||||
dlr: boolean;
|
dlr: boolean;
|
||||||
|
/** GSM 03.38 message class 0: shown on arrival and not stored. */
|
||||||
flash: boolean;
|
flash: boolean;
|
||||||
from: string;
|
from: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -71,6 +73,9 @@ export type SmsHandlers = {
|
|||||||
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** GSM 03.38 section 4 gives class 0 immediate display; every other class is stored somewhere. */
|
||||||
|
const immediateDisplayClass = 0;
|
||||||
|
|
||||||
export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
|
export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
|
||||||
const first = input.pduObjs[0];
|
const first = input.pduObjs[0];
|
||||||
const registered = first?.params.registered_delivery;
|
const registered = first?.params.registered_delivery;
|
||||||
@@ -80,7 +85,7 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
|
|||||||
const sms: Sms = {
|
const sms: Sms = {
|
||||||
answeredOnArrival: input.answeredAs !== undefined,
|
answeredOnArrival: input.answeredAs !== undefined,
|
||||||
dlr: typeof registered === 'number' && registered !== 0,
|
dlr: typeof registered === 'number' && registered !== 0,
|
||||||
flash: typeof dataCoding === 'number' && (dataCoding & 0xF0) === 0x10,
|
flash: typeof dataCoding === 'number' && messageClassOf(dataCoding) === immediateDisplayClass,
|
||||||
from: input.from,
|
from: input.from,
|
||||||
message: input.message,
|
message: input.message,
|
||||||
pduObjs: input.pduObjs,
|
pduObjs: input.pduObjs,
|
||||||
|
|||||||
@@ -122,8 +122,19 @@ describe('encodingByDataCoding()', () => {
|
|||||||
|
|
||||||
test('reads the alphabet bits when a message class is present', () => {
|
test('reads the alphabet bits when a message class is present', () => {
|
||||||
assert.equal(encodingByDataCoding(0x10), 'ASCII');
|
assert.equal(encodingByDataCoding(0x10), 'ASCII');
|
||||||
|
assert.equal(encodingByDataCoding(0x11), 'ASCII');
|
||||||
assert.equal(encodingByDataCoding(0x18), 'UCS2');
|
assert.equal(encodingByDataCoding(0x18), 'UCS2');
|
||||||
|
assert.equal(encodingByDataCoding(0x1A), 'UCS2');
|
||||||
assert.equal(encodingByDataCoding(0xF0), 'ASCII');
|
assert.equal(encodingByDataCoding(0xF0), 'ASCII');
|
||||||
|
assert.equal(encodingByDataCoding(0xF1), 'ASCII');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The compressed and automatic-deletion groups put the alphabet where the plain one does.
|
||||||
|
test('reads them in the compressed and automatic-deletion groups too', () => {
|
||||||
|
assert.equal(encodingByDataCoding(0x30), 'ASCII');
|
||||||
|
assert.equal(encodingByDataCoding(0x38), 'UCS2');
|
||||||
|
assert.equal(encodingByDataCoding(0x54), 'LATIN1');
|
||||||
|
assert.equal(encodingByDataCoding(0x58), 'UCS2');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolves the 8-bit binary codings to the codec that keeps every octet', () => {
|
test('resolves the 8-bit binary codings to the codec that keeps every octet', () => {
|
||||||
@@ -135,5 +146,7 @@ describe('encodingByDataCoding()', () => {
|
|||||||
test('falls back to ASCII for alphabets it has no codec for', () => {
|
test('falls back to ASCII for alphabets it has no codec for', () => {
|
||||||
assert.equal(encodingByDataCoding(0x05), 'ASCII');
|
assert.equal(encodingByDataCoding(0x05), 'ASCII');
|
||||||
assert.equal(encodingByDataCoding(0x0E), 'ASCII');
|
assert.equal(encodingByDataCoding(0x0E), 'ASCII');
|
||||||
|
// No class, so nothing says the octet is spelled 03.38 rather than SMPP's own flat table.
|
||||||
|
assert.equal(encodingByDataCoding(0x48), 'ASCII');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { describe } from 'node:test';
|
||||||
|
import type { PduObjectInput } from '../src/pdu.ts';
|
||||||
|
import type { SendSmsDeps } from '../src/send-sms.ts';
|
||||||
|
import type { Session } from '../src/session.ts';
|
||||||
|
import type { Sms } from '../src/sms.ts';
|
||||||
|
import type { TestContext } from 'node:test';
|
||||||
|
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
|
||||||
|
import { client } from '../src/client.ts';
|
||||||
|
import { closeAfter } from './teardown.ts';
|
||||||
|
import { messageClassOf } from '../src/defs/encodings.ts';
|
||||||
|
import { paramNumber } from '../src/defs/types.ts';
|
||||||
|
import { pduToObj } from '../src/pdu.ts';
|
||||||
|
import { server } from '../src/server.ts';
|
||||||
|
import { silentLog } from '../src/log.ts';
|
||||||
|
import { submitSms } from '../src/send-sms.ts';
|
||||||
|
|
||||||
|
const from = '46701113311';
|
||||||
|
const to = '46709771337';
|
||||||
|
|
||||||
|
type MessagePeer = {
|
||||||
|
/** Every message the server session was handed, in arrival order. */
|
||||||
|
received: Sms[];
|
||||||
|
session: Session;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A server that answers every message, and a client to write raw submit_sm PDUs at it. */
|
||||||
|
async function messagesInto(t: TestContext): Promise<MessagePeer> {
|
||||||
|
const received: Sms[] = [];
|
||||||
|
const { err, server: smpp } = await server({ port: 0 });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(smpp);
|
||||||
|
closeAfter(t, smpp);
|
||||||
|
|
||||||
|
smpp.on('session', peer => peer.on('sms', sms => {
|
||||||
|
received.push(sms);
|
||||||
|
|
||||||
|
return sms.sendResp();
|
||||||
|
}));
|
||||||
|
|
||||||
|
const connected = await client({ port: smpp.port, reconnect: false });
|
||||||
|
|
||||||
|
assert.equal(connected.err, undefined);
|
||||||
|
assert.ok(connected.session);
|
||||||
|
closeAfter(t, connected.session);
|
||||||
|
|
||||||
|
return { received, session: connected.session };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataCodingsOf(octets: Buffer[]): number[] {
|
||||||
|
return octets.map(pdu => {
|
||||||
|
const { pduObj } = pduToObj(pdu);
|
||||||
|
|
||||||
|
assert.ok(pduObj);
|
||||||
|
|
||||||
|
return paramNumber(pduObj.params.data_coding, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A send that records what it is handed, so a refusal shows up as an empty attempt log. */
|
||||||
|
function recordingDeps(attempts: PduObjectInput[]): SendSmsDeps {
|
||||||
|
return {
|
||||||
|
log: silentLog,
|
||||||
|
reference: 1,
|
||||||
|
send: input => {
|
||||||
|
attempts.push(input);
|
||||||
|
|
||||||
|
return Promise.resolve({ err: new Error('the recording peer never answers') });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('messageClassOf()', () => {
|
||||||
|
test('reads the class out of both of the coding groups that carry one', () => {
|
||||||
|
const classes: [number, number | undefined][] = [
|
||||||
|
[0x10, 0], [0x11, 1], [0x12, 2], [0x13, 3],
|
||||||
|
[0x14, 0], [0x18, 0], [0x1B, 3],
|
||||||
|
[0xF0, 0], [0xF1, 1], [0xF2, 2], [0xF3, 3],
|
||||||
|
[0xF4, 0], [0xF7, 3],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [dataCoding, messageClass] of classes) {
|
||||||
|
assert.equal(messageClassOf(dataCoding), messageClass, dataCoding.toString(16));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bit 5 marks the text compressed and bits 7-6 mark it for automatic deletion; neither moves the
|
||||||
|
// class bits, and 01xx is coded exactly as 00xx.
|
||||||
|
test('reads the class of a compressed message and of the automatic-deletion group', () => {
|
||||||
|
const classes: [number, number][] = [[0x30, 0], [0x33, 3], [0x50, 0], [0x53, 3], [0x70, 0], [0x78, 0]];
|
||||||
|
|
||||||
|
for (const [dataCoding, messageClass] of classes) {
|
||||||
|
assert.equal(messageClassOf(dataCoding), messageClass, dataCoding.toString(16));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports no class where the coding group defines none', () => {
|
||||||
|
// Bit 4 clear below 0x80, then the reserved, message-waiting and reserved groups above it.
|
||||||
|
for (const dataCoding of [0x00, 0x01, 0x03, 0x08, 0x0F, 0x20, 0x40, 0x4F, 0x80, 0xC0, 0xD0, 0xE0, 0xEF]) {
|
||||||
|
assert.equal(messageClassOf(dataCoding), undefined, dataCoding.toString(16));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('an inbound message', () => {
|
||||||
|
test('is flash for message class 0 alone, in either coding group', async t => {
|
||||||
|
const peer = await messagesInto(t);
|
||||||
|
// 0.4.0 read 0x10 and nothing else; this branch read 0x10-0x1F, so the three stored classes
|
||||||
|
// arrived as flash and the whole 0xF0 group did not (interop-tests/findings/02-smppsim.md, C17).
|
||||||
|
const codings: [number, boolean][] = [
|
||||||
|
[0x00, false], [0x03, false], [0x08, false],
|
||||||
|
[0x10, true], [0x11, false], [0x12, false], [0x13, false],
|
||||||
|
[0x14, true], [0x18, true], [0x30, true], [0x50, true],
|
||||||
|
[0xF0, true], [0xF1, false], [0xF2, false], [0xF3, false], [0xF4, true],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [dataCoding] of codings) {
|
||||||
|
const sent = await peer.session.send({
|
||||||
|
cmdName: 'submit_sm',
|
||||||
|
params: {
|
||||||
|
data_coding: dataCoding,
|
||||||
|
destination_addr: to,
|
||||||
|
short_message: 'hi',
|
||||||
|
source_addr: from,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined, dataCoding.toString(16));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(peer.received.map(sms => sms.flash), codings.map(([, flash]) => flash));
|
||||||
|
assert.deepEqual(peer.received.map(sms => sms.message), codings.map(() => 'hi'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the alphabet its class group declares, so a flash UCS2 message still reads as UCS2', async t => {
|
||||||
|
const peer = await messagesInto(t);
|
||||||
|
const sent = await peer.session.send({
|
||||||
|
cmdName: 'submit_sm',
|
||||||
|
params: {
|
||||||
|
data_coding: 0x18,
|
||||||
|
destination_addr: to,
|
||||||
|
short_message: 'تست',
|
||||||
|
source_addr: from,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [sms] = peer.received;
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
assert.ok(sms);
|
||||||
|
assert.equal(sms.message, 'تست');
|
||||||
|
assert.equal(sms.flash, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendSms() flash', () => {
|
||||||
|
test('writes the message class into data_coding beside the alphabet, never over it', async t => {
|
||||||
|
const smsc = await dummySmsc(t);
|
||||||
|
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
|
||||||
|
const sends = [
|
||||||
|
{ from, message: 'Hello world', to },
|
||||||
|
{ flash: true, from, message: 'Hello world', to },
|
||||||
|
{ from, message: 'تست', to },
|
||||||
|
{ flash: true, from, message: 'تست', to },
|
||||||
|
{ encoding: 'LATIN1', from, message: 'Hello world', to },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const send of sends) {
|
||||||
|
assert.equal((await session.sendSms(send)).err, undefined, send.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0.4.0 forced 0x10 whatever the alphabet was, which mangled every non-GSM flash message.
|
||||||
|
assert.deepEqual(dataCodingsOf(smsc.octets), [0x01, 0x10, 0x08, 0x18, 0x03]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses a flash Latin-1 message, which no coding group carrying a class can spell', async () => {
|
||||||
|
const attempts: PduObjectInput[] = [];
|
||||||
|
const deps = recordingDeps(attempts);
|
||||||
|
const sent = await submitSms(deps, { encoding: 'LATIN1', flash: true, from, message: 'Hello world', to });
|
||||||
|
|
||||||
|
assert.ok(sent.err instanceof Error);
|
||||||
|
assert.match(sent.err.message, /flash/);
|
||||||
|
assert.match(sent.err.message, /Latin-1/);
|
||||||
|
assert.deepEqual(sent.pduObjs, []);
|
||||||
|
assert.deepEqual(sent.smsIds, []);
|
||||||
|
assert.equal(sent.unanswered, 0);
|
||||||
|
assert.equal(attempts.length, 0, 'a refused flash message puts nothing on the wire');
|
||||||
|
|
||||||
|
// Either half alone is fine; it is only the pair that has nowhere to go.
|
||||||
|
const latin1 = await submitSms(deps, { encoding: 'LATIN1', from, message: 'Hello world', to });
|
||||||
|
const flash = await submitSms(deps, { flash: true, from, message: 'Hello world', to });
|
||||||
|
|
||||||
|
assert.equal(latin1.err?.message, 'the recording peer never answers');
|
||||||
|
assert.equal(flash.err?.message, 'the recording peer never answers');
|
||||||
|
assert.equal(attempts.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// FLASH named data_coding 0x10 among the alphabets, so the class was reachable through the option
|
||||||
|
// that chooses a charset — and a non-GSM body sent that way was flattened to spaces, unrefused.
|
||||||
|
test('refuses the message class posing as an alphabet, naming the option that means it', async () => {
|
||||||
|
const attempts: PduObjectInput[] = [];
|
||||||
|
const sent = await submitSms(recordingDeps(attempts), { encoding: 'FLASH', from, message: 'Hello world', to });
|
||||||
|
|
||||||
|
assert.ok(sent.err instanceof Error);
|
||||||
|
assert.match(sent.err.message, /encoding FLASH is a message class/);
|
||||||
|
assert.match(sent.err.message, /flash: true/);
|
||||||
|
assert.equal(attempts.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses an encoding naming no alphabet rather than throwing out of the send', async () => {
|
||||||
|
const attempts: PduObjectInput[] = [];
|
||||||
|
const deps = recordingDeps(attempts);
|
||||||
|
|
||||||
|
for (const encoding of ['utf8', 'ascii', 8, {}]) {
|
||||||
|
const sent = await submitSms(deps, { encoding, from, message: 'Hello world', to });
|
||||||
|
|
||||||
|
assert.ok(sent.err instanceof Error, JSON.stringify(encoding));
|
||||||
|
assert.match(sent.err.message, /encoding must be ASCII, LATIN1, UCS2/);
|
||||||
|
assert.deepEqual(sent.smsIds, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(attempts.length, 0, 'a refused encoding puts nothing on the wire');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -120,11 +120,28 @@ session message is a change to every call site.
|
|||||||
another message's in a correlation table. The cost is that every consumer narrows, including
|
another message's in a correlation table. The cost is that every consumer narrows, including
|
||||||
the majority whose SMSC always names an id. Raised by the phase 11 product review, 2026-09-08.
|
the majority whose SMSC always names an id. Raised by the phase 11 product review, 2026-09-08.
|
||||||
**This one is 1.0.0-or-never** — after release it needs a major version.
|
**This one is 1.0.0-or-never** — after release it needs a major version.
|
||||||
- [ ] `sms.flash` is true only for the 0x10 coding group, never for 0xF0. GSM 03.38 gives 0xF0–0xF3
|
- [ ] **`bitCount()`, `encodeMessage()` and `splitMessage()` throw on an encoding name they have no
|
||||||
as data coding plus message class, where class 0 is immediate display — flash. `encodings.ts`
|
codec for**, where `sendSms()` now refuses one by name. All three are value-exported, so this
|
||||||
already reads the 0xF0 group for the alphabet; `sms.ts` does not read it for flash. SMPPSim
|
is the published surface, one door over from the one [#95](https://github.com/larvit/larvitsmpp/pull/95)
|
||||||
observed and recorded it as not-flash (`findings/02-smppsim.md`, C17), so it is measured, not
|
closed — and that PR sharpened the edge, since `encodeMessage(msg, 'FLASH')` used to encode
|
||||||
suspected. Either read the group or say in the README that `flash` means the 0x10 spelling.
|
quietly and now throws out of the codec table. `decodeMessage()` is unaffected; it resolves
|
||||||
|
through `encodingByDataCoding()`. Tag-relevant because of the fix shape: guarding them means
|
||||||
|
either a `Result` signature on three published functions, which needs a major version after
|
||||||
|
the tag, or a documented deviation saying a typed caller cannot get here. Maintainer's call.
|
||||||
|
Raised by the architecture review of [#95](https://github.com/larvit/larvitsmpp/pull/95),
|
||||||
|
2026-09-09.
|
||||||
|
- [ ] **A concatenated Latin-1 segment is 159 octets, and an SMS carries 140.** `segmentUnits` in
|
||||||
|
`message.ts` budgets 153 for everything that is not UCS2, which is right for GSM 7-bit alone —
|
||||||
|
the SMSC packs 153 septets into 134 octets. Latin-1 is 8-bit and never packed, so a long
|
||||||
|
`encoding: 'LATIN1'` message goes out with segments no SMSC can carry. Same family as the
|
||||||
|
0.4.0 "Short segments" row, in the other direction; goal 1 owns it, so it wants fixing before
|
||||||
|
the tag. One line — 134 octets for both unpacked alphabets — plus a regression test. Raised by
|
||||||
|
the architecture review of [#95](https://github.com/larvit/larvitsmpp/pull/95), 2026-09-09.
|
||||||
|
- [ ] **The interop suite still asserts the defect [#95](https://github.com/larvit/larvitsmpp/pull/95)
|
||||||
|
fixed.** `interop-tests/smppsim.test.ts:621`, `a raw submit_sm with data_coding 0xF0 is not
|
||||||
|
read as flash`, fails on the next `./interop-tests/run.py smppsim`; nothing in CI runs that
|
||||||
|
suite, so it fails silently until someone does. The test name and its assertion both need
|
||||||
|
inverting, and the C17 row in `findings/02-smppsim.md` names that sub-test, so it follows.
|
||||||
- [ ] Tag `v1.0.0` to publish.
|
- [ ] Tag `v1.0.0` to publish.
|
||||||
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
|
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
|
||||||
something CI should do.
|
something CI should do.
|
||||||
|
|||||||
Reference in New Issue
Block a user